From 4bd2c5abc8a6ebfae6dd9614edd36b0c5d747160 Mon Sep 17 00:00:00 2001 From: Jalil Date: Fri, 13 Nov 2026 16:57:45 -0800 Subject: [PATCH] feat(palette): find individual settings and open the page at them Cmd/Ctrl+K knew settings pages and Advanced sections, not the settings inside a page: "language", "notifications" or "show workspace run mode" found nothing. Computer use and Devices were missing although the settings sidebar lists them, and items that only shared scattered letters with the query ("devices" finding MCP servers) buried or replaced real matches. - Index 18 settings across Preferences, Appearance, Updates and Advanced (settings-search-index.ts), offered only on the systems that show them. - A result opens /settings//: the row scrolls into view, takes focus and is outlined briefly (LayoutSectionItem anchor, useRevealSetting). The anchor survives the redirect that adds the workspace to the path. - List the same pages as the settings sidebar, including Computer use on desktop and Devices. - Items that contain every typed word win. Matching letters in order only applies when nothing contains the words, so typos still find something. Separators in a pasted menu path are read as spaces. - The Advanced engine section is listed under its page heading, "OpenCode engine". No feature flag: every setting was already reachable; this makes the existing search find it. Signed-off-by: Jalil --- .../domains/settings/advanced-sections.ts | 2 +- .../settings/appearance/language-section.tsx | 2 +- .../settings/appearance/theme-section.tsx | 2 +- .../settings/appearance/window-section.tsx | 4 +- .../settings/pages/advanced-view-sections.tsx | 8 +- .../settings/pages/preferences-view.tsx | 14 +- .../domains/settings/pages/updates-view.tsx | 6 +- .../domains/settings/settings-anchor.ts | 46 +++++ .../domains/settings/settings-layout.tsx | 17 +- .../domains/settings/settings-search-index.ts | 175 ++++++++++++++++++ .../react-app/shell/command-palette-search.ts | 25 ++- .../shell/command-palette-settings.ts | 36 +++- .../src/react-app/shell/command-palette.tsx | 2 + .../src/react-app/shell/settings-route.tsx | 10 +- .../command-palette-settings-search.test.ts | 143 ++++++++++++++ 15 files changed, 458 insertions(+), 34 deletions(-) create mode 100644 apps/app/src/react-app/domains/settings/settings-anchor.ts create mode 100644 apps/app/src/react-app/domains/settings/settings-search-index.ts create mode 100644 apps/app/tests/command-palette-settings-search.test.ts diff --git a/apps/app/src/react-app/domains/settings/advanced-sections.ts b/apps/app/src/react-app/domains/settings/advanced-sections.ts index 72acc2673e4..9bcc043a7dc 100644 --- a/apps/app/src/react-app/domains/settings/advanced-sections.ts +++ b/apps/app/src/react-app/domains/settings/advanced-sections.ts @@ -3,7 +3,7 @@ export const ADVANCED_SETTINGS_SECTIONS = [ { id: "runtime", title: "Runtime", keywords: ["opencode engine", "openwork server", "connection status"] }, { id: "agent-access", title: "Agent access diagnostics", keywords: ["cloud mcp", "health", "tools", "connections"] }, { id: "config-sources", title: "OpenCode config sources", keywords: ["runtime db", "injected config", "project config", "global config"] }, - { id: "experimental-engine", title: "Experimental engine", keywords: ["engine v2", "chat engine", "preview"] }, + { id: "experimental-engine", title: "OpenCode engine", keywords: ["engine v2", "experimental engine", "preview"] }, { id: "workspace-run-mode", title: "Workspace run mode", keywords: ["approvals", "permissions", "keep going", "feature flag"] }, { id: "developer", title: "Developer", keywords: ["developer mode", "debug", "deep link"] }, ]; diff --git a/apps/app/src/react-app/domains/settings/appearance/language-section.tsx b/apps/app/src/react-app/domains/settings/appearance/language-section.tsx index d2c53364619..0d9fe7e1c1d 100644 --- a/apps/app/src/react-app/domains/settings/appearance/language-section.tsx +++ b/apps/app/src/react-app/domains/settings/appearance/language-section.tsx @@ -21,7 +21,7 @@ interface LanguageSectionProps extends Pick + {t("settings.language")} {t("settings.language.description")} diff --git a/apps/app/src/react-app/domains/settings/appearance/theme-section.tsx b/apps/app/src/react-app/domains/settings/appearance/theme-section.tsx index 3d86bc7f831..5980340fd4e 100644 --- a/apps/app/src/react-app/domains/settings/appearance/theme-section.tsx +++ b/apps/app/src/react-app/domains/settings/appearance/theme-section.tsx @@ -19,7 +19,7 @@ interface ThemeSectionProps export function ThemeSection(props: ThemeSectionProps) { return ( - + {t("settings.theme_title")} {t("settings.appearance_hint")} diff --git a/apps/app/src/react-app/domains/settings/appearance/window-section.tsx b/apps/app/src/react-app/domains/settings/appearance/window-section.tsx index 5267eb54ee6..873ab85a4f3 100644 --- a/apps/app/src/react-app/domains/settings/appearance/window-section.tsx +++ b/apps/app/src/react-app/domains/settings/appearance/window-section.tsx @@ -29,7 +29,7 @@ export function WindowSection(props: WindowSectionProps) { {t("settings.window_appearance_desc")} - + {t("settings.hide_titlebar")} {t("settings.hide_titlebar_desc")} @@ -43,7 +43,7 @@ export function WindowSection(props: WindowSectionProps) { - + Display menu bar Show the native desktop menu bar. diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx index 4bde15876cb..5534e1dcf08 100644 --- a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx @@ -711,7 +711,7 @@ export function AdvancedWorkspaceRunModeSection() { Workspace run mode Experimental approval controls in the composer. - + Show workspace run mode @@ -743,7 +743,7 @@ export function AdvancedEngineV2PreviewSection(props: { client: OpenworkServerCl return ( OpenCode engine - + Chat engine @@ -786,7 +786,7 @@ export function AdvancedDeveloperSection(props: AdvancedDeveloperSectionProps) { {t("settings.developer")} - + {t("settings.developer_mode_title")} {t("settings.developer_mode_desc")} @@ -880,7 +880,7 @@ function ComputerUseRecordingItem() { .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); }; return ( - + Show computer use in screen recordings diff --git a/apps/app/src/react-app/domains/settings/pages/preferences-view.tsx b/apps/app/src/react-app/domains/settings/pages/preferences-view.tsx index 29e33953dbc..e57c5375c66 100644 --- a/apps/app/src/react-app/domains/settings/pages/preferences-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/preferences-view.tsx @@ -85,7 +85,7 @@ export function PreferencesView(props: PreferencesViewProps) { {/* Show reasoning */} - + {t("settings.show_model_reasoning")} {t("settings.show_model_reasoning_desc")} @@ -101,7 +101,7 @@ export function PreferencesView(props: PreferencesViewProps) { {/* Auto context compaction */} - + {t("settings.auto_compact")} {t("settings.auto_compact_desc")} @@ -122,7 +122,7 @@ export function PreferencesView(props: PreferencesViewProps) { {t("settings.follow_up.title")} {t("settings.follow_up.section_desc")} - + {t("settings.follow_up.enter")} @@ -158,7 +158,7 @@ export function PreferencesView(props: PreferencesViewProps) { {t("settings.desktop_notifications.section_desc")} - + {t("settings.desktop_notifications.mode")} {t("settings.desktop_notifications.mode_desc")} @@ -199,7 +199,7 @@ export function PreferencesView(props: PreferencesViewProps) { {t("settings.power_title")} - + {t("settings.keep_awake")} {t("settings.keep_awake_desc")} @@ -225,7 +225,7 @@ export function PreferencesView(props: PreferencesViewProps) { {t("settings.browser_section_desc")} - + {t("settings.links.open_in")} {t("settings.links.open_in_desc")} @@ -260,7 +260,7 @@ export function PreferencesView(props: PreferencesViewProps) { {t("settings.privacy_section_desc")} - + {t("settings.analytics_toggle")} {t("settings.analytics_toggle_desc")} diff --git a/apps/app/src/react-app/domains/settings/pages/updates-view.tsx b/apps/app/src/react-app/domains/settings/pages/updates-view.tsx index fa81b6989f4..fabc541d43d 100644 --- a/apps/app/src/react-app/domains/settings/pages/updates-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/updates-view.tsx @@ -276,7 +276,7 @@ export function UpdatesView(props: UpdatesViewProps) { <> {props.alphaChannelSupported && props.releaseChannel ? ( - + Release channel @@ -311,7 +311,7 @@ export function UpdatesView(props: UpdatesViewProps) { ) : null} - + {t("settings.background_checks_title")} {t("settings.background_checks_desc")} @@ -325,7 +325,7 @@ export function UpdatesView(props: UpdatesViewProps) { - + {t("settings.auto_update_title")} {t("settings.auto_update_desc")} diff --git a/apps/app/src/react-app/domains/settings/settings-anchor.ts b/apps/app/src/react-app/domains/settings/settings-anchor.ts new file mode 100644 index 00000000000..80d10dc8400 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/settings-anchor.ts @@ -0,0 +1,46 @@ +import { useEffect } from "react"; + +/** How long a setting opened from search stays outlined. */ +const FOUND_MS = 1600; +/** Rows that wait on loaded state get about a second to appear. */ +const MAX_FRAMES = 60; + +const ANCHOR_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function isSettingAnchor(value: string | undefined): value is string { + return value !== undefined && ANCHOR_PATTERN.test(value); +} + +/** + * Scrolls the setting named by `/settings//` into view, moves + * focus to it and outlines it briefly, so someone who searched for one option + * does not have to scan the page for it. `navigationKey` re-runs the reveal + * when the same setting is opened again. + */ +export function useRevealSetting(anchor: string | undefined, navigationKey: string) { + useEffect(() => { + if (!isSettingAnchor(anchor)) return; + let frame = 0; + let frames = 0; + let timer = 0; + let found: HTMLElement | null = null; + const reveal = () => { + found = document.querySelector(`[data-setting-anchor="${anchor}"]`); + if (!found) { + frames += 1; + if (frames < MAX_FRAMES) frame = requestAnimationFrame(reveal); + return; + } + found.scrollIntoView({ block: "center" }); + found.focus({ preventScroll: true }); + found.setAttribute("data-setting-found", ""); + timer = window.setTimeout(() => found?.removeAttribute("data-setting-found"), FOUND_MS); + }; + frame = requestAnimationFrame(reveal); + return () => { + cancelAnimationFrame(frame); + window.clearTimeout(timer); + found?.removeAttribute("data-setting-found"); + }; + }, [anchor, navigationKey]); +} diff --git a/apps/app/src/react-app/domains/settings/settings-layout.tsx b/apps/app/src/react-app/domains/settings/settings-layout.tsx index a4fef5d19b3..9fac5f290cf 100644 --- a/apps/app/src/react-app/domains/settings/settings-layout.tsx +++ b/apps/app/src/react-app/domains/settings/settings-layout.tsx @@ -62,13 +62,26 @@ export function LayoutSectionDescription({ children, className }: LayoutSectionD } interface LayoutSectionItemProps { + /** + * Names this setting so search can open the page at it: + * `/settings//`. List it in `settings-search-index.ts`. + */ + anchor?: string; children: React.ReactNode; className?: string; } -export function LayoutSectionItem({ children, className }: LayoutSectionItemProps) { +export function LayoutSectionItem({ anchor, children, className }: LayoutSectionItemProps) { return ( -
+
{children}
); diff --git a/apps/app/src/react-app/domains/settings/settings-search-index.ts b/apps/app/src/react-app/domains/settings/settings-search-index.ts new file mode 100644 index 00000000000..74ef118f488 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/settings-search-index.ts @@ -0,0 +1,175 @@ +import type { SettingsTab } from "@/app/types"; +import { t } from "@/i18n"; + +export type SettingsSearchOs = "macos" | "windows" | "linux"; + +const DESKTOP: SettingsSearchOs[] = ["macos", "windows", "linux"]; + +/** + * One setting inside a settings page, as Cmd/Ctrl+K finds it. `title` and + * `detail` repeat the row's own label and description so a search for the + * words on the page lands on it. + */ +export type SettingsSearchEntry = { + tab: SettingsTab; + /** The row's `LayoutSectionItem anchor`, unique within its page. */ + anchor: string; + title: string; + detail?: string; + /** Heading the setting sits under, shown in the result's path. */ + section?: string; + keywords?: string[]; + /** Desktop systems that show this setting. Omitted: shown everywhere. */ + os?: SettingsSearchOs[]; +}; + +/** Read at search time so titles follow the chosen language. */ +export function getSettingsSearchEntries(): SettingsSearchEntry[] { + return [ + { + tab: "preferences", + anchor: "show-model-reasoning", + title: t("settings.show_model_reasoning"), + detail: t("settings.show_model_reasoning_desc"), + section: t("settings.model_title"), + keywords: ["thinking", "thoughts", "reasoning traces"], + }, + { + tab: "preferences", + anchor: "auto-compact", + title: t("settings.auto_compact"), + detail: t("settings.auto_compact_desc"), + section: t("settings.model_title"), + keywords: ["compaction", "context window", "summarize"], + }, + { + tab: "preferences", + anchor: "follow-up", + title: t("settings.follow_up.enter"), + detail: t("settings.follow_up.section_desc"), + section: t("settings.follow_up.title"), + keywords: ["queue", "steer", "interrupt", "enter key", "send while working"], + }, + { + tab: "preferences", + anchor: "desktop-notifications", + title: t("settings.desktop_notifications.mode"), + detail: t("settings.desktop_notifications.mode_desc"), + section: t("settings.desktop_notifications.title"), + keywords: ["notifications", "alerts", "notify"], + }, + { + tab: "preferences", + anchor: "keep-awake", + title: t("settings.keep_awake"), + detail: t("settings.keep_awake_desc"), + section: t("settings.power_title"), + keywords: ["sleep", "prevent sleep", "caffeinate", "long tasks"], + os: DESKTOP, + }, + { + tab: "preferences", + anchor: "open-links-in", + title: t("settings.links.open_in"), + detail: t("settings.links.open_in_desc"), + section: t("settings.browser_title"), + keywords: ["links", "external browser", "default browser", "built-in browser"], + os: DESKTOP, + }, + { + tab: "preferences", + anchor: "usage-data", + title: t("settings.analytics_toggle"), + detail: t("settings.analytics_toggle_desc"), + section: t("settings.privacy_title"), + keywords: ["analytics", "telemetry", "tracking", "privacy"], + }, + { + tab: "appearance", + anchor: "theme", + title: t("settings.theme_title"), + detail: t("settings.appearance_hint"), + keywords: ["dark mode", "light mode", "system theme", "color scheme"], + }, + { + tab: "appearance", + anchor: "language", + title: t("settings.language"), + detail: t("settings.language.description"), + keywords: ["locale", "translation", "english"], + }, + { + tab: "appearance", + anchor: "hide-titlebar", + title: t("settings.hide_titlebar"), + detail: t("settings.hide_titlebar_desc"), + section: t("settings.window_title"), + keywords: ["title bar", "window frame", "tiling"], + os: DESKTOP, + }, + { + tab: "appearance", + anchor: "menu-bar", + title: "Display menu bar", + detail: "Show the native desktop menu bar.", + section: t("settings.window_title"), + keywords: ["menubar", "application menu"], + os: ["windows", "linux"], + }, + { + tab: "updates", + anchor: "release-channel", + title: "Release channel", + detail: "Stable gets fully tested releases. Alpha includes the very latest changes.", + keywords: ["stable", "alpha", "preview builds"], + os: ["macos"], + }, + { + tab: "updates", + anchor: "check-automatically", + title: t("settings.background_checks_title"), + detail: t("settings.background_checks_desc"), + keywords: ["background checks", "check for updates", "auto update"], + }, + { + tab: "updates", + anchor: "download-automatically", + title: t("settings.auto_update_title"), + detail: t("settings.auto_update_desc"), + keywords: ["auto update", "install updates", "background download"], + }, + { + tab: "advanced", + anchor: "show-workspace-run-mode", + title: "Show workspace run mode", + detail: "Choose when OpenWork asks before acting, using the icon beside attachments.", + section: "Workspace run mode", + keywords: ["approvals", "ask before acting"], + }, + { + tab: "advanced", + anchor: "chat-engine", + title: "Chat engine", + section: "OpenCode engine", + keywords: ["opencode v1", "opencode v2", "engine preview"], + os: DESKTOP, + }, + { + tab: "advanced", + anchor: "developer-mode", + title: t("settings.developer_mode_title"), + detail: t("settings.developer_mode_desc"), + section: t("settings.developer"), + keywords: ["debug tools", "diagnostics"], + }, + { + tab: "advanced", + anchor: "screen-recordings", + title: "Show computer use in screen recordings", + detail: "The agent's cursor, frame and controls appear in screen recordings and screen sharing, for demos.", + section: t("settings.developer"), + keywords: ["screen sharing", "demo", "overlay", "cursor"], + os: ["macos"], + }, + ]; +} diff --git a/apps/app/src/react-app/shell/command-palette-search.ts b/apps/app/src/react-app/shell/command-palette-search.ts index bd4dc951091..05e178275da 100644 --- a/apps/app/src/react-app/shell/command-palette-search.ts +++ b/apps/app/src/react-app/shell/command-palette-search.ts @@ -49,8 +49,12 @@ function itemSearchText(item: PaletteItem) { .join(" "); } +// A menu path copied from a chat or a result ("Settings → Advanced → Runtime") +// is searched by its words; the separators are not part of any name. +const PATH_SEPARATORS = /[→›»>]/g; + function normalizeSearchText(value: string) { - return value.trim().toLowerCase().replace(/\s+/g, " "); + return value.replace(PATH_SEPARATORS, " ").trim().toLowerCase().replace(/\s+/g, " "); } function resultGroup(value: PaletteGroup, items: PaletteItem[]): PaletteResultGroup { @@ -93,13 +97,24 @@ export function rankPaletteItems( } const normalizedQuery = normalizeSearchText(searchQuery); + if (!normalizedQuery) return []; const tokens = normalizedQuery.split(" "); - const tokenMatchedItems = candidates.filter((item) => { - const text = itemSearchText(item); - return tokens.every((token) => fuzzysort.single(token, text) !== null); + // Items that contain every typed word win outright. A looser match, the + // typed letters in order anywhere in an item, only counts when no item + // contains the words: typos still find something, and real matches are not + // buried under coincidences. + const wordMatchedItems = candidates.filter((item) => { + const text = normalizeSearchText(itemSearchText(item)); + return tokens.every((token) => text.includes(token)); }); + const tokenMatchedItems = wordMatchedItems.length > 0 + ? wordMatchedItems + : candidates.filter((item) => { + const text = itemSearchText(item); + return tokens.every((token) => fuzzysort.single(token, text) !== null); + }); const recentIdSet = new Set(recentIds); - const ranked = fuzzysort.go(searchQuery, tokenMatchedItems, { + const ranked = fuzzysort.go(normalizedQuery, tokenMatchedItems, { keys: [ (item) => item.title, (item) => itemKeywords(item), diff --git a/apps/app/src/react-app/shell/command-palette-settings.ts b/apps/app/src/react-app/shell/command-palette-settings.ts index 9f0d61605bc..5b7e7046c66 100644 --- a/apps/app/src/react-app/shell/command-palette-settings.ts +++ b/apps/app/src/react-app/shell/command-palette-settings.ts @@ -1,4 +1,3 @@ -import { isDesktopRuntime } from "@/app/lib/runtime-env"; import type { PlatformCapabilities } from "@/app/lib/platform-capabilities"; import { isSettingsTabAllowed, type DesktopAppRestrictionChecker } from "@/app/cloud/desktop-app-restrictions"; import type { SettingsTab } from "@/app/types"; @@ -11,6 +10,7 @@ import { } from "@/react-app/domains/settings/shell/settings-page"; import { ADVANCED_SETTINGS_SECTIONS } from "@/react-app/domains/settings/advanced-sections"; +import { getSettingsSearchEntries, type SettingsSearchOs } from "@/react-app/domains/settings/settings-search-index"; import type { PaletteItem } from "./command-palette-search"; @@ -20,14 +20,17 @@ const SETTINGS_KEYWORDS: Partial> = { preferences: ["default model", "reasoning", "thinking", "compaction", "effort"], permissions: ["authorized folders", "folder access", "file access", "allow", "permission denied", "sandbox", "approvals"], extensions: ["library", "skills", "plugins", "mcp", "connections", "tools", "apps"], + import: ["claude", "claude code", "chat history", "migrate"], environment: ["env", "environment variables", "secrets", "tokens", "api keys"], + computer: ["desktop control", "accessibility", "screen recording", "macos permissions", "mouse", "keyboard", "apps"], + devices: ["computers", "cloud computer", "desktops", "remote", "machines"], advanced: ["advanced settings", "runtime", "developer", "connection", "server", "port", "reset", "fix", "repair", "clean up", "troubleshoot", "recovery"], shortcuts: ["shortcut", "shortcuts", "keyboard", "keys", "hotkey", "keybinding", "switch model", "fast"], appearance: ["theme", "dark mode", "light mode", "color", "font", "look"], updates: ["version", "upgrade", "check for updates", "release"], debug: ["logs", "diagnostics", "developer mode"], usage: ["usage", "limit", "spend", "budget", "quota", "increase"], - "cloud-account": ["sign in", "log in", "login", "account", "organization", "org", "den", "cloud", "openwork cloud"], + "cloud-account": ["sign in", "sign out", "log in", "log out", "login", "account", "organization", "org", "den", "cloud", "openwork cloud"], general: ["settings", "preferences", "options", "configure"], }; @@ -43,19 +46,23 @@ const LIBRARY_SECTIONS = [ export function buildCommandPaletteSettingsItems(input: { developerMode: boolean; capabilities: Pick; + /** The desktop app's system. Left out on the web, which has no desktop-only settings. */ + os?: SettingsSearchOs; /** Desktop policy checker; when `allowControlSettings` is blocked only the Cloud tabs remain. */ checkRestriction?: DesktopAppRestrictionChecker; onOpenSettings: (route: string) => void; onOpenExtensions: (section?: string) => void; }): PaletteItem[] { const checkRestriction = input.checkRestriction ?? (() => false); + // The settings sidebar's pages, in its order (`useSettingsNavGroups`). const tabs = ([ "general", ...getWorkspaceSettingsTabs(), - ...getGlobalSettingsTabs(input.developerMode, input.capabilities), + ...getGlobalSettingsTabs(input.developerMode, input.capabilities, { desktop: input.os !== undefined }), // Not in the sidebar (opened from AI Providers), but still searchable. "ollama", ...CLOUD_SETTINGS_TABS, + "devices", ] satisfies SettingsTab[]).filter((tab) => isSettingsTabAllowed({ tab, checkRestriction })); const tabItems = tabs.map((tab): PaletteItem => ({ @@ -85,7 +92,7 @@ export function buildCommandPaletteSettingsItems(input: { })); const advancedItems: PaletteItem[] = tabs.includes("advanced") - ? ADVANCED_SETTINGS_SECTIONS.filter((section) => section.id !== "experimental-engine" || isDesktopRuntime()).map((section) => ({ + ? ADVANCED_SETTINGS_SECTIONS.filter((section) => section.id !== "experimental-engine" || input.os !== undefined).map((section) => ({ id: `settings:advanced/${section.id}`, title: section.title, keywords: section.keywords, @@ -95,7 +102,24 @@ export function buildCommandPaletteSettingsItems(input: { })) : []; + // One result per setting inside a page, so a search for an option's own + // name opens the page at that option. + const settingItems = getSettingsSearchEntries() + .filter((entry) => tabs.includes(entry.tab)) + .filter((entry) => !entry.os || (input.os !== undefined && entry.os.includes(input.os))) + .map((entry): PaletteItem => ({ + id: `settings:${entry.tab}/${entry.anchor}`, + title: entry.title, + detail: entry.detail, + keywords: entry.keywords, + breadcrumb: ["Settings", getSettingsTabLabel(entry.tab), entry.section].filter(Boolean).join(" › "), + group: "settings", + action: () => input.onOpenSettings(`/settings/${entry.tab}/${entry.anchor}`), + })); + + // Settings inside pages come last: the empty palette previews the first few + // entries, which stay the pages themselves. return input.developerMode - ? [...advancedItems, ...tabItems, ...libraryItems] - : [...tabItems, ...advancedItems, ...libraryItems]; + ? [...advancedItems, ...tabItems, ...libraryItems, ...settingItems] + : [...tabItems, ...advancedItems, ...libraryItems, ...settingItems]; } diff --git a/apps/app/src/react-app/shell/command-palette.tsx b/apps/app/src/react-app/shell/command-palette.tsx index 9c05f9773f4..7b00bce83ca 100644 --- a/apps/app/src/react-app/shell/command-palette.tsx +++ b/apps/app/src/react-app/shell/command-palette.tsx @@ -361,6 +361,7 @@ export function CommandPalette(props: CommandPaletteProps) { () => buildCommandPaletteSettingsItems({ developerMode: props.developerMode, capabilities: platform.capabilities, + os: platform.os, checkRestriction: checkDesktopRestriction, onOpenSettings: (route) => { props.onClose(); @@ -374,6 +375,7 @@ export function CommandPalette(props: CommandPaletteProps) { [ checkDesktopRestriction, platform.capabilities, + platform.os, props.developerMode, props.onClose, props.onOpenExtensions, diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 0b9334b77cc..71573d46495 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -107,6 +107,7 @@ import { GeneralSettingsView } from "@/react-app/domains/settings/pages/general- import { AuthorizedFoldersPanel } from "@/react-app/domains/settings/panels/authorized-folders-panel"; import { EffectivePermissionsPanel } from "@/react-app/domains/settings/panels/effective-permissions-panel"; import { SettingsStack } from "@/react-app/domains/settings/settings-section"; +import { isSettingAnchor, useRevealSetting } from "@/react-app/domains/settings/settings-anchor"; import { AdvancedView } from "@/react-app/domains/settings/pages/advanced-view"; import { ClaudeImportView } from "@/react-app/domains/settings/pages/claude-import-view"; import { AppearanceView } from "@/react-app/domains/settings/pages/appearance-view"; @@ -295,6 +296,8 @@ export function parseSettingsPath(pathname: string): { tab: SettingsTab; redirectPath: string | null; advancedSection?: string; + /** One setting on the page, opened from search: `/settings//`. */ + anchor?: string; extensionsSection?: ExtensionsSection; extensionDetailId?: string; } { @@ -321,9 +324,10 @@ export function parseSettingsPath(pathname: string): { case "updates": case "debug": case "import": - return { tab: head, redirectPath: null }; + return { tab: head, redirectPath: null, anchor: isSettingAnchor(tail) ? tail : undefined }; case "advanced": - return { tab: "advanced", redirectPath: null, advancedSection: tail }; + // The tail names a whole section (AdvancedView scrolls to it) or one setting in it. + return { tab: "advanced", redirectPath: null, advancedSection: tail, anchor: isSettingAnchor(tail) ? tail : undefined }; case "cloud-account": case "cloud-providers": case "usage": @@ -436,6 +440,7 @@ function findSessionWorkspaceId( export function settingsPathForRoute(route: ReturnType) { if (route.tab === "advanced" && route.advancedSection) return `advanced/${route.advancedSection}`; + if (route.anchor) return `${route.tab}/${route.anchor}`; if (route.tab === "extensions" && route.extensionDetailId) { return `extensions/${encodeURIComponent(route.extensionDetailId)}`; } @@ -484,6 +489,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { : props.standaloneExtensions ? parseExtensionsPath(location.pathname) : parseSettingsPath(location.pathname); + useRevealSetting(route.anchor, location.key); const navigationWorkspaceId = readNavigationWorkspaceId(location.state); const navigationSessionId = readNavigationSessionId(location.state); diff --git a/apps/app/tests/command-palette-settings-search.test.ts b/apps/app/tests/command-palette-settings-search.test.ts new file mode 100644 index 00000000000..24eed153b36 --- /dev/null +++ b/apps/app/tests/command-palette-settings-search.test.ts @@ -0,0 +1,143 @@ +import { afterAll, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +const ownedDom = typeof window === "undefined"; +if (ownedDom) GlobalRegistrator.register({ url: "http://localhost/" }); +const { buildCommandPaletteSettingsItems } = await import("../src/react-app/shell/command-palette-settings"); +const { rankPaletteItems } = await import("../src/react-app/shell/command-palette-search"); +const { getSettingsSearchEntries } = await import("../src/react-app/domains/settings/settings-search-index"); +const { isSettingAnchor } = await import("../src/react-app/domains/settings/settings-anchor"); +const { parseSettingsPath, settingsPathForRoute } = await import("../src/react-app/shell/settings-route"); + +afterAll(async () => { + if (ownedDom) await GlobalRegistrator.unregister(); +}); + +type Os = "macos" | "windows" | "linux"; + +function settingsItems(os: Os | undefined, opened: string[] = []) { + return buildCommandPaletteSettingsItems({ + developerMode: false, + capabilities: { autoUpdate: true }, + os, + onOpenSettings: (route) => opened.push(route), + onOpenExtensions: () => {}, + }); +} + +function search(query: string, os: Os | undefined = "macos") { + return rankPaletteItems(query, settingsItems(os), []).flatMap((group) => group.items.map((item) => item.id)); +} + +test("the name of one setting finds that setting and opens the page at it", () => { + const opened: string[] = []; + const [first, ...rest] = rankPaletteItems("show workspace run mode", settingsItems("macos", opened), []) + .flatMap((group) => group.items); + expect(first?.id).toBe("settings:advanced/show-workspace-run-mode"); + expect(first?.breadcrumb).toBe("Settings › Advanced › Workspace run mode"); + expect(rest).toEqual([]); + first?.action(); + expect(opened).toEqual(["/settings/advanced/show-workspace-run-mode"]); +}); + +test("a menu path copied from a chat finds the setting it names", () => { + expect(search("Settings → Advanced → Workspace run mode → Show workspace run mode")) + .toEqual(["settings:advanced/show-workspace-run-mode"]); + expect(search("Settings > Preferences > Keep awake")).toEqual(["settings:preferences/keep-awake"]); +}); + +test("settings inside a page are found by their own words", () => { + const expected: Array<[query: string, id: string]> = [ + ["keep awake", "settings:preferences/keep-awake"], + ["notifications", "settings:preferences/desktop-notifications"], + ["enter sends", "settings:preferences/follow-up"], + ["open links in", "settings:preferences/open-links-in"], + ["telemetry", "settings:preferences/usage-data"], + ["language", "settings:appearance/language"], + ["titlebar", "settings:appearance/hide-titlebar"], + ["release channel", "settings:updates/release-channel"], + ["download automatically", "settings:updates/download-automatically"], + ["chat engine", "settings:advanced/chat-engine"], + ]; + for (const [query, id] of expected) expect([query, search(query)[0]]).toEqual([query, id]); +}); + +test("a real match is not buried under items that only share scattered letters", () => { + expect(search("sleep")).toEqual(["settings:preferences/keep-awake"]); + expect(search("devices")).toEqual(["settings:devices"]); + expect(search("alpha")).toEqual(["settings:updates/release-channel"]); +}); + +test("a typo still finds the nearest setting", () => { + expect(search("wokspace run mode")[0]).toBe("settings:advanced/workspace-run-mode"); + expect(search("notifcations")[0]).toBe("settings:preferences/desktop-notifications"); +}); + +test("search lists the same pages as the settings sidebar", () => { + const desktop = settingsItems("macos").map((item) => item.id); + const web = settingsItems(undefined).map((item) => item.id); + expect(desktop).toContain("settings:computer"); + expect(desktop).toContain("settings:devices"); + expect(web).not.toContain("settings:computer"); + expect(web).toContain("settings:devices"); + expect(search("computer use")[0]).toBe("settings:computer"); +}); + +test("a setting is offered only where its page shows it", () => { + const ids = (os: Os | undefined) => settingsItems(os).map((item) => item.id); + expect(ids(undefined)).not.toContain("settings:preferences/keep-awake"); + expect(ids("windows")).toContain("settings:preferences/keep-awake"); + expect(ids("macos")).not.toContain("settings:appearance/menu-bar"); + expect(ids("linux")).toContain("settings:appearance/menu-bar"); + expect(ids("windows")).not.toContain("settings:updates/release-channel"); + expect(ids("macos")).toContain("settings:updates/release-channel"); + const withoutUpdates = buildCommandPaletteSettingsItems({ + developerMode: false, + capabilities: { autoUpdate: false }, + os: "macos", + onOpenSettings: () => {}, + onOpenExtensions: () => {}, + }).map((item) => item.id); + expect(withoutUpdates).not.toContain("settings:updates/check-automatically"); +}); + +test("the empty palette still previews pages, not settings inside them", () => { + const preview = rankPaletteItems("", settingsItems("macos"), []) + .flatMap((group) => group.items.map((item) => item.id)); + expect(preview.length).toBeGreaterThan(0); + expect(preview.filter((id) => /^settings:[a-z-]+\/[a-z-]+$/.test(id) && !id.startsWith("settings:advanced/") && !id.startsWith("settings:extensions/"))).toEqual([]); +}); + +test("a settings link keeps the setting it names when the workspace is added to the path", () => { + const route = parseSettingsPath("/settings/preferences/keep-awake"); + expect([route.tab, route.anchor]).toEqual(["preferences", "keep-awake"]); + expect(settingsPathForRoute(route)).toBe("preferences/keep-awake"); + + const advanced = parseSettingsPath("/workspace/w1/settings/advanced/show-workspace-run-mode"); + expect(advanced.anchor).toBe("show-workspace-run-mode"); + expect(settingsPathForRoute(advanced)).toBe("advanced/show-workspace-run-mode"); + + expect(parseSettingsPath("/settings/preferences/Not%20a%20setting").anchor).toBeUndefined(); + expect(settingsPathForRoute(parseSettingsPath("/settings/preferences"))).toBe("preferences"); +}); + +function settingsSources(directory: string): string { + return readdirSync(directory, { withFileTypes: true }) + .map((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return settingsSources(path); + return entry.name.endsWith(".tsx") ? readFileSync(path, "utf8") : ""; + }) + .join("\n"); +} + +test("every indexed setting has its anchor on a settings page", () => { + const source = settingsSources(join(import.meta.dir, "../src/react-app/domains/settings")); + const entries = getSettingsSearchEntries(); + const missing = entries.filter((entry) => !isSettingAnchor(entry.anchor) || !source.includes(`anchor="${entry.anchor}"`)); + expect(missing.map((entry) => `${entry.tab}/${entry.anchor}`)).toEqual([]); + const keys = entries.map((entry) => `${entry.tab}/${entry.anchor}`); + expect(new Set(keys).size).toBe(keys.length); +});