From 86c53479a10fcb28f5ddca961673108f8ac83e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sun, 2 Aug 2026 16:34:14 +0800 Subject: [PATCH] feat(settings): add global settings search --- .../editor/EditorSettingsDialog.vue | 307 +++++++++++++++--- apps/desktop/src/i18n/locales/en.ts | 5 + apps/desktop/src/i18n/locales/es.ts | 5 + apps/desktop/src/i18n/locales/it.ts | 5 + apps/desktop/src/i18n/locales/ja.ts | 5 + apps/desktop/src/i18n/locales/ko.ts | 5 + apps/desktop/src/i18n/locales/pt-BR.ts | 5 + apps/desktop/src/i18n/locales/zh-CN.ts | 5 + apps/desktop/src/i18n/locales/zh-TW.ts | 5 + .../settings/__tests__/settingsSearch.spec.ts | 174 ++++++++++ .../src/lib/settings/settingsSearch.ts | 245 ++++++++++++++ apps/desktop/src/styles/globals.css | 23 ++ 12 files changed, 742 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts create mode 100644 apps/desktop/src/lib/settings/settingsSearch.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index bd2212142..a33d87bef 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -121,6 +121,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { currentLocale, setLocale, type Locale } from "@/i18n"; +import { SETTINGS_SEARCH_DEFINITIONS, TOOLBAR_VISIBILITY_ITEMS, createShortcutSettingsSearchDefinitions, resolveSettingsSearchEntries, searchSettings, toolbarVisibilityItemLabel, type SettingsCategory, type SettingsSearchEntry, type ToolbarVisibilityItem } from "@/lib/settings/settingsSearch"; import { LOCALE_OPTIONS } from "@/lib/app/localeOptions"; import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PATH, normalizedWebDavAutoUploadInterval, writeWebDavAutoUploadFields } from "@/lib/webdav/webdavAutoUploadConfig"; import { apiUrl } from "@/lib/common/webPath"; @@ -410,6 +411,10 @@ const editExportRowLimit = ref(settingsStore.editorSettings.exportRowLimit); const editQueryExportKeysetOptimizationEnabled = ref(settingsStore.editorSettings.queryExportKeysetOptimizationEnabled); const editUpdateDownloadSource = ref(settingsStore.editorSettings.updateDownloadSource); const editToolbarItems = ref({ ...settingsStore.editorSettings.toolbarItems }); +const toolbarVisibilityItems = TOOLBAR_VISIBILITY_ITEMS; +function getToolbarVisibilityItemLabel(item: ToolbarVisibilityItem): string { + return toolbarVisibilityItemLabel(item, t); +} const systemFonts = ref([]); const systemFontsLoading = ref(false); const systemFontsLoaded = ref(false); @@ -1369,7 +1374,6 @@ const appSupportInfoLabels = computed(() => ({ unknown: t("settings.supportInfoUnknown"), })); const appSupportInfoRows = computed(() => (appSupportInfo.value ? buildAppSupportInfoRows(appSupportInfo.value, appSupportInfoLabels.value) : [])); -type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "backups" | "tunnels" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about"; const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [ { value: "appearance", label: t("settings.appearanceTab") }, { value: "editor", label: t("settings.editorTab") }, @@ -1399,6 +1403,164 @@ function settingsCategoryButton(value: SettingsCategory): string { ].join(" "); } +const settingsSearchQuery = ref(""); +const settingsSearchOpen = ref(false); +const settingsSearchActiveIndex = ref(0); +const settingsSearchInputContainerRef = ref(null); +const highlightedSettingsSearchTargetId = ref(""); +let highlightedSettingsSearchElement: HTMLElement | null = null; +let pendingSettingsSearchResult: SettingsSearchEntry | null = null; +let settingsSearchHighlightTimer: ReturnType | null = null; +let settingsSearchHighlightAnimationHandler: ((event: AnimationEvent) => void) | null = null; +const settingsSearchHighlightClasses = ["rounded-md", "bg-primary/5", "transition-[box-shadow,background-color]", "duration-200", "settings-search-highlight-breathe"]; + +const settingsSearchCategoryLabels = computed(() => Object.fromEntries(settingsCategoryNav.value.map((category) => [category.value, category.label])) as Record); +const settingsSearchEntries = computed(() => + resolveSettingsSearchEntries( + [...SETTINGS_SEARCH_DEFINITIONS, ...createShortcutSettingsSearchDefinitions(SHORTCUT_DEFINITIONS)], + { + isWeb, + visibleCategories: new Set(settingsCategoryNav.value.map((category) => category.value)), + }, + t, + settingsSearchCategoryLabels.value, + ), +); +const settingsSearchResults = computed(() => searchSettings(settingsSearchEntries.value, settingsSearchQuery.value, currentLocale())); +const settingsSearchActive = computed(() => Boolean(settingsSearchQuery.value.trim())); +const settingsSearchVisible = computed(() => settingsSearchOpen.value && settingsSearchActive.value); +const settingsSearchResultGroups = computed(() => { + const groups = new Map(); + for (const result of settingsSearchResults.value) { + const group = groups.get(result.category) ?? { categoryLabel: result.categoryLabel, results: [] }; + group.results.push(result); + groups.set(result.category, group); + } + return Array.from(groups, ([category, group]) => ({ category, ...group })); +}); + +function clearSettingsSearchHighlight() { + if (settingsSearchHighlightTimer) { + window.clearTimeout(settingsSearchHighlightTimer); + settingsSearchHighlightTimer = null; + } + if (highlightedSettingsSearchElement && settingsSearchHighlightAnimationHandler) { + highlightedSettingsSearchElement.removeEventListener("animationend", settingsSearchHighlightAnimationHandler); + } + settingsSearchHighlightAnimationHandler = null; + highlightedSettingsSearchElement?.classList.remove(...settingsSearchHighlightClasses); + highlightedSettingsSearchElement = null; + highlightedSettingsSearchTargetId.value = ""; +} + +function resetSettingsSearchState() { + settingsSearchQuery.value = ""; + settingsSearchOpen.value = false; + settingsSearchActiveIndex.value = 0; + pendingSettingsSearchResult = null; + shortcutSearchQuery.value = ""; + clearSettingsSearchHighlight(); +} + +function exitSettingsSearch() { + settingsSearchQuery.value = ""; + settingsSearchOpen.value = false; +} + +async function focusSettingsSearchInput() { + await nextTick(); + settingsSearchInputContainerRef.value?.querySelector("input")?.focus(); +} + +function settingsSearchTargetClass(targetId: string): string { + return highlightedSettingsSearchTargetId.value === targetId ? "ring-2 ring-primary ring-offset-2 ring-offset-background transition-shadow" : ""; +} + +function onSettingsCategoryClick(category: SettingsCategory) { + settingsSearchOpen.value = false; + activeSettingsTab.value = category; +} + +function applySettingsSearchRoute(result: SettingsSearchEntry) { + if (result.route?.syncMethodTab) syncMethodTab.value = result.route.syncMethodTab; +} + +function normalizeSettingsSearchText(value: string | null | undefined): string { + return value?.replace(/\s+/g, " ").trim() ?? ""; +} + +function findSettingsSearchHighlightTarget(searchRoot: HTMLElement, title: string): HTMLElement { + const titleElement = Array.from(searchRoot.querySelectorAll("label, h3, h4")).find((element) => normalizeSettingsSearchText(element.textContent) === title); + if (!titleElement) return searchRoot; + + let candidate = titleElement.parentElement; + while (candidate && candidate !== searchRoot) { + if (candidate.classList.contains("rounded-md") && candidate.classList.contains("border")) return candidate; + if (candidate.querySelector("input, button, [role='combobox'], textarea")) return candidate; + candidate = candidate.parentElement; + } + return titleElement; +} + +async function revealSettingsSearchTarget(result: SettingsSearchEntry) { + await nextTick(); + const searchRoot = settingsContentScrollRef.value?.querySelector(`[data-settings-search-id="${result.targetId}"]`); + if (!searchRoot) return; + const target = findSettingsSearchHighlightTarget(searchRoot, result.title); + target.scrollIntoView({ block: "center", behavior: "smooth" }); + clearSettingsSearchHighlight(); + if (target === searchRoot) { + highlightedSettingsSearchTargetId.value = result.targetId; + } + target.classList.add(...settingsSearchHighlightClasses); + highlightedSettingsSearchElement = target; + + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + settingsSearchHighlightTimer = window.setTimeout(clearSettingsSearchHighlight, 1500); + return; + } + settingsSearchHighlightAnimationHandler = (event) => { + if (event.animationName === "settings-search-highlight-breathe") clearSettingsSearchHighlight(); + }; + target.addEventListener("animationend", settingsSearchHighlightAnimationHandler); +} + +async function selectSettingsSearchResult(result: SettingsSearchEntry) { + pendingSettingsSearchResult = result; + if (result.shortcutId) shortcutSearchQuery.value = result.title; + applySettingsSearchRoute(result); + settingsSearchQuery.value = ""; + settingsSearchOpen.value = false; + settingsSearchActiveIndex.value = 0; + if (activeSettingsTab.value === result.category) { + pendingSettingsSearchResult = null; + await revealSettingsSearchTarget(result); + return; + } + activeSettingsTab.value = result.category; +} + +function onSettingsSearchKeydown(event: KeyboardEvent) { + const results = settingsSearchResults.value; + if (event.key === "Escape") { + event.preventDefault(); + exitSettingsSearch(); + return; + } + if (!results.length) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + settingsSearchOpen.value = true; + const direction = event.key === "ArrowDown" ? 1 : -1; + settingsSearchActiveIndex.value = (settingsSearchActiveIndex.value + direction + results.length) % results.length; + return; + } + if (event.key === "Enter") { + event.preventDefault(); + void selectSettingsSearchResult(results[settingsSearchActiveIndex.value] ?? results[0]); + } +} + async function resetSettingsContentScroll() { await nextTick(); const scroller = settingsContentScrollRef.value; @@ -2041,6 +2203,8 @@ watch( () => settingsVisible.value, async (open) => { if (open) { + resetSettingsSearchState(); + void focusSettingsSearchInput(); snippetSyncSettingsLoading.value = true; mcpPolicyLoading.value = true; mcpPolicyLoadError.value = ""; @@ -2095,6 +2259,8 @@ watch( if (!isWeb && activeSettingsTab.value === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus(); if (activeSettingsTab.value === "about") void refreshAppSupportInfo(); await scrollToInitialSettingsSection(); + } else { + resetSettingsSearchState(); } }, { immediate: true }, @@ -2155,6 +2321,16 @@ watch(activeSettingsTab, async (tab) => { checkLayoutDescTruncation(); checkIconThemeDescTruncation(); } + const result = pendingSettingsSearchResult; + if (result) { + pendingSettingsSearchResult = null; + await revealSettingsSearchTarget(result); + } +}); + +watch(settingsSearchQuery, (query) => { + settingsSearchActiveIndex.value = 0; + settingsSearchOpen.value = Boolean(query.trim()); }); // If the store finishes loading while the AI tab is already open (e.g. a retry @@ -3225,7 +3401,10 @@ watch( }, ); -onUnmounted(cleanupPreviewEditor); +onUnmounted(() => { + cleanupPreviewEditor(); + resetSettingsSearchState(); +});