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); +});