diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl
index 2a3330d890..41a8268cb3 100644
--- a/templates/repo/diff/box.tmpl
+++ b/templates/repo/diff/box.tmpl
@@ -10,7 +10,7 @@
diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue
index e2934b967e..b662808566 100644
--- a/web_src/js/components/DiffFileTree.vue
+++ b/web_src/js/components/DiffFileTree.vue
@@ -4,6 +4,7 @@ import {toggleElem} from '../utils/dom.ts';
import {diffTreeStore} from '../modules/diff-file.ts';
import {setFileFolding} from '../features/file-fold.ts';
import {onMounted, onUnmounted} from 'vue';
+import {localUserSettings} from '../modules/user-settings.ts';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
@@ -11,7 +12,7 @@ const store = diffTreeStore();
onMounted(() => {
// Default to true if unset
- store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
+ store.fileTreeIsVisible = localUserSettings.getBoolean(LOCAL_STORAGE_KEY, true);
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
hashChangeListener();
@@ -43,7 +44,7 @@ function toggleVisibility() {
function updateVisibility(visible: boolean) {
store.fileTreeIsVisible = visible;
- localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
+ localUserSettings.setBoolean(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
updateState(store.fileTreeIsVisible);
}
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 357a2ba10e..e4c15d2001 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -8,6 +8,7 @@ import {renderAnsi} from '../render/ansi.ts';
import {POST, DELETE} from '../modules/fetch.ts';
import type {IntervalId} from '../types.ts';
import {toggleFullScreen} from '../utils.ts';
+import {localUserSettings} from '../modules/user-settings.ts';
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
@@ -71,15 +72,6 @@ type LocaleStorageOptions = {
expandRunning: boolean;
};
-function getLocaleStorageOptions(): LocaleStorageOptions {
- try {
- const optsJson = localStorage.getItem('actions-view-options');
- if (optsJson) return JSON.parse(optsJson);
- } catch {}
- // if no options in localStorage, or failed to parse, return default options
- return {autoScroll: true, expandRunning: false};
-}
-
export default defineComponent({
name: 'RepoActionView',
components: {
@@ -106,7 +98,8 @@ export default defineComponent({
},
data() {
- const {autoScroll, expandRunning} = getLocaleStorageOptions();
+ const defaultViewOptions: LocaleStorageOptions = {autoScroll: true, expandRunning: false};
+ const {autoScroll, expandRunning} = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
return {
// internal state
loadingAbortController: null as AbortController | null,
@@ -224,7 +217,7 @@ export default defineComponent({
methods: {
saveLocaleStorageOptions() {
const opts: LocaleStorageOptions = {autoScroll: this.optionAlwaysAutoScroll, expandRunning: this.optionAlwaysExpandRunning};
- localStorage.setItem('actions-view-options', JSON.stringify(opts));
+ localUserSettings.setJsonObject('actions-view-options', opts);
},
// get the job step logs container ('.job-step-logs')
diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts
index d5ecb52e72..c81c1af1fa 100644
--- a/web_src/js/features/citation.ts
+++ b/web_src/js/features/citation.ts
@@ -1,5 +1,6 @@
import {getCurrentLocale} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {localUserSettings} from '../modules/user-settings.ts';
const {pageData} = window.config;
@@ -38,7 +39,7 @@ export async function initCitationFileCopyContent() {
if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return;
const updateUi = () => {
- const isBibtex = (localStorage.getItem('citation-copy-format') || defaultCitationFormat) === 'bibtex';
+ const isBibtex = localUserSettings.getString('citation-copy-format', defaultCitationFormat) === 'bibtex';
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text')!;
inputContent.value = copyContent;
citationCopyBibtex.classList.toggle('primary', isBibtex);
@@ -55,12 +56,12 @@ export async function initCitationFileCopyContent() {
updateUi();
citationCopyApa.addEventListener('click', () => {
- localStorage.setItem('citation-copy-format', 'apa');
+ localUserSettings.setString('citation-copy-format', 'apa');
updateUi();
});
citationCopyBibtex.addEventListener('click', () => {
- localStorage.setItem('citation-copy-format', 'bibtex');
+ localUserSettings.setString('citation-copy-format', 'bibtex');
updateUi();
});
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts
index 46096ecac1..fdc8a1d601 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.ts
+++ b/web_src/js/features/comp/ComboMarkdownEditor.ts
@@ -24,6 +24,7 @@ import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import type EasyMDE from 'easymde';
+import {localUserSettings} from '../../modules/user-settings.ts';
/**
* validate if the given textarea is non-empty.
@@ -81,6 +82,8 @@ export class ComboMarkdownEditor {
textareaMarkdownToolbar: HTMLElement;
textareaAutosize: any;
+ buttonMonospace: HTMLButtonElement;
+
dropzone: HTMLElement | null;
attachedDropzoneInst: any;
@@ -140,19 +143,13 @@ export class ComboMarkdownEditor {
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
}
- const monospaceButton = this.container.querySelector('.markdown-switch-monospace')!;
- const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
- const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text')!;
- monospaceButton.setAttribute('data-tooltip-content', monospaceText);
- monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
- monospaceButton.addEventListener('click', (e) => {
+ this.buttonMonospace = this.container.querySelector('.markdown-switch-monospace')!;
+ this.applyMonospace();
+ this.buttonMonospace.addEventListener('click', (e) => {
e.preventDefault();
- const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
- localStorage.setItem('markdown-editor-monospace', String(enabled));
- this.textarea.classList.toggle('tw-font-mono', enabled);
- const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!;
- monospaceButton.setAttribute('data-tooltip-content', text);
- monospaceButton.setAttribute('aria-checked', String(enabled));
+ const enabled = !localUserSettings.getBoolean('markdown-editor-monospace');
+ localUserSettings.setBoolean('markdown-editor-monospace', enabled);
+ applyMonospaceToAllEditors();
});
if (this.supportEasyMDE) {
@@ -403,10 +400,27 @@ export class ComboMarkdownEditor {
}
get userPreferredEditor(): string {
- return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`) || '';
+ return localUserSettings.getString(`markdown-editor-${this.previewMode ?? 'default'}`);
}
+
set userPreferredEditor(s: string) {
- window.localStorage.setItem(`markdown-editor-${this.previewMode ?? 'default'}`, s);
+ localUserSettings.setString(`markdown-editor-${this.previewMode ?? 'default'}`, s);
+ }
+
+ applyMonospace() {
+ const enabled = localUserSettings.getBoolean('markdown-editor-monospace');
+ const text = this.buttonMonospace.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!;
+ this.textarea.classList.toggle('tw-font-mono', enabled);
+ this.buttonMonospace.setAttribute('data-tooltip-content', text);
+ this.buttonMonospace.setAttribute('aria-checked', String(enabled));
+ }
+}
+
+function applyMonospaceToAllEditors() {
+ const editors = document.querySelectorAll('.combo-markdown-editor');
+ for (const editorContainer of editors) {
+ const editor = getComboMarkdownEditor(editorContainer);
+ if (editor) editor.applyMonospace();
}
}
diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts
index ac753805d3..e8fb257c18 100644
--- a/web_src/js/features/repo-common.ts
+++ b/web_src/js/features/repo-common.ts
@@ -6,6 +6,7 @@ import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
import {createApp} from 'vue';
import {toOriginUrl} from '../utils/url.ts';
import {createTippy} from '../modules/tippy.ts';
+import {localUserSettings} from '../modules/user-settings.ts';
async function onDownloadArchive(e: Event) {
e.preventDefault();
@@ -57,7 +58,7 @@ function initCloneSchemeUrlSelection(parent: Element) {
const tabSsh = parent.querySelector('.repo-clone-ssh');
const tabTea = parent.querySelector('.repo-clone-tea');
const updateClonePanelUi = function() {
- let scheme = localStorage.getItem('repo-clone-protocol')!;
+ let scheme = localUserSettings.getString('repo-clone-protocol');
if (!['https', 'ssh', 'tea'].includes(scheme)) {
scheme = 'https';
}
@@ -114,15 +115,15 @@ function initCloneSchemeUrlSelection(parent: Element) {
updateClonePanelUi();
// tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server
tabHttps?.addEventListener('click', () => {
- localStorage.setItem('repo-clone-protocol', 'https');
+ localUserSettings.setString('repo-clone-protocol', 'https');
updateClonePanelUi();
});
tabSsh?.addEventListener('click', () => {
- localStorage.setItem('repo-clone-protocol', 'ssh');
+ localUserSettings.setString('repo-clone-protocol', 'ssh');
updateClonePanelUi();
});
tabTea?.addEventListener('click', () => {
- localStorage.setItem('repo-clone-protocol', 'tea');
+ localUserSettings.setString('repo-clone-protocol', 'tea');
updateClonePanelUi();
});
elCloneUrlInput.addEventListener('focus', () => {
diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts
index 9770f639a7..5528c890dc 100644
--- a/web_src/js/globals.d.ts
+++ b/web_src/js/globals.d.ts
@@ -77,6 +77,7 @@ interface Window {
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
},
codeEditors: any[], // export editor for customization
+ localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings,
// various captcha plugins
grecaptcha: any,
diff --git a/web_src/js/index.ts b/web_src/js/index.ts
index 153b8049c9..2de29f52b9 100644
--- a/web_src/js/index.ts
+++ b/web_src/js/index.ts
@@ -6,6 +6,7 @@ import './bootstrap.ts';
import './globals.ts';
import './webcomponents/index.ts';
+import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts
import {onDomReady} from './utils/dom.ts';
// TODO: There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
diff --git a/web_src/js/modules/user-settings.ts b/web_src/js/modules/user-settings.ts
new file mode 100644
index 0000000000..dbf69f384d
--- /dev/null
+++ b/web_src/js/modules/user-settings.ts
@@ -0,0 +1,72 @@
+// Some people deploy Gitea under a subpath, so it needs prefix to avoid local storage key conflicts.
+// And these keys are for user settings only, it also needs a specific prefix,
+// in case in the future there are other uses of local storage, and/or we need to clear some keys when the quota is exceeded.
+const itemKeyPrefix = 'gitea:setting:';
+
+function handleLocalStorageError(e: any) {
+ // in the future, maybe we need to handle quota exceeded errors differently
+ console.error('Error using local storage for user settings', e);
+}
+
+function getLocalStorageUserSetting(settingKey: string): string | null {
+ const legacyKey = settingKey;
+ const itemKey = `${itemKeyPrefix}${settingKey}`;
+ try {
+ const legacyValue = localStorage?.getItem(legacyKey) ?? null;
+ const value = localStorage?.getItem(itemKey) ?? null; // avoid undefined
+ if (value !== null && legacyValue !== null) {
+ // if both values exist, remove the legacy one
+ localStorage?.removeItem(legacyKey);
+ } else if (value === null && legacyValue !== null) {
+ // migrate legacy value to new key
+ localStorage?.removeItem(legacyKey);
+ localStorage?.setItem(itemKey, legacyValue);
+ return legacyValue;
+ }
+ return value;
+ } catch (e) {
+ handleLocalStorageError(e);
+ }
+ return null;
+}
+
+function setLocalStorageUserSetting(settingKey: string, value: string) {
+ const legacyKey = settingKey;
+ const itemKey = `${itemKeyPrefix}${settingKey}`;
+ try {
+ localStorage?.removeItem(legacyKey);
+ localStorage?.setItem(itemKey, value);
+ } catch (e) {
+ handleLocalStorageError(e);
+ }
+}
+
+export const localUserSettings = {
+ getString: (key: string, def: string = ''): string => {
+ return getLocalStorageUserSetting(key) ?? def;
+ },
+ setString: (key: string, value: string) => {
+ setLocalStorageUserSetting(key, value);
+ },
+ getBoolean: (key: string, def: boolean = false): boolean => {
+ return localUserSettings.getString(key, String(def)) === 'true';
+ },
+ setBoolean: (key: string, value: boolean) => {
+ localUserSettings.setString(key, String(value));
+ },
+ getJsonObject: >(key: string, def: T): T => {
+ const value = getLocalStorageUserSetting(key);
+ try {
+ const decoded = value !== null ? JSON.parse(value) : def;
+ return decoded ?? def;
+ } catch (e) {
+ console.error(`Unable to parse JSON value for local user settings ${key}=${value}`, e);
+ }
+ return def;
+ },
+ setJsonObject: >(key: string, value: T) => {
+ localUserSettings.setString(key, JSON.stringify(value));
+ },
+};
+
+window.localUserSettings = localUserSettings;