diff --git a/packages/webui/docs/README.md b/packages/webui/docs/README.md index 8088a97ed..a3ab06970 100644 --- a/packages/webui/docs/README.md +++ b/packages/webui/docs/README.md @@ -90,7 +90,9 @@ is the sole decision record for Mini App hosting. ADR 0021 records the model pic with two states, and the cascade it replaces. ADR 0024 records the client-side workspace boundary for a diff body: a diff entry outside the workspace keeps its name and its counts and loses its content, the boundary is lexical, and a diff whose workspace root the client does not know is not -withheld. A new decision takes the next free number. +withheld. ADR 0025 records the settings page's language as a pack inside the settings business, +and the boundary between that and the client-wide extraction PR #33 deferred. A new decision takes +the next free number. | Retired | Was | Why it is not in the log | Where the live facts are | | --- | --- | --- | --- | diff --git a/packages/webui/docs/adr/0025-webui-settings-page-carries-its-own-language-pack.md b/packages/webui/docs/adr/0025-webui-settings-page-carries-its-own-language-pack.md new file mode 100644 index 000000000..887f0722f --- /dev/null +++ b/packages/webui/docs/adr/0025-webui-settings-page-carries-its-own-language-pack.md @@ -0,0 +1,79 @@ +# The settings page carries its own language pack + +**Status:** Accepted. +**Date:** 2026-10-24. + +The settings page's copy lives in **two packs** — Chinese and English — inside the settings business. +The language is read from `mavis-locale`, the key the page already read and wrote, and a row in the +通用 page switches it. No new dependency, no client-wide i18n framework, and no other surface +changes. + +This covers the settings page only. The conversation, the file panel, the workspace and the rest of +the client keep their own copy. + +## What was already there + +`client/features/settings/ui/SettingsModal.public.tsx` read `mavis-locale` into a `useState` that had +**no setter**, and wrote it back on every mount. The key existed, the read existed, and nothing a +user could do ever wrote a different value. The page's words were Chinese literals in the component +that rendered them. + +The settings search made the gap visible. `filterSettingsTabs` matched a tab's **name and key**, so +a setting that lives on a row was only findable if the row's words happened to be the tab's words. +Asking the settings search for 「语言」 answered 「没有匹配的设置」 — the one question a language +switch would exist to answer. + +## Why a pack in the settings business, and not a client-wide framework + +- **The layer matrix already says where it goes.** A pack is a derivation — read, never written — so + it is a **view** module. `client/features//projection/` is a view layer, and a view + module may import another view module while a binding module may import a view module. The pack + therefore reaches both the tab registry and the dialog without a new edge, and without the + platform layer reaching down into a business. +- **The client-wide extraction is a different change.** The client's Chinese copy is roughly 8,600 + characters across 77 of its 109 client files. PR #33 measured this and deliberately deferred + 「多语言 (P-3)」 for exactly that reason: a full extraction is its own work, with its own cost and + its own review. This decision does the part that is small enough to be correct — an entry point, + a minimal pack, and a page that actually switches — and leaves the extraction to that change. +- **A framework would have been a dependency.** The pack is data and a lookup. Anything more would + be a dependency the browser bundle carries, for two languages and one page. + +## Why Chinese is the base language + +The settings page was written in Chinese, and Chinese is what a reader who has the app open sees. +So Chinese is the base pack and a key the English pack does not carry **falls back to the Chinese +text** rather than to a raw key: a half-translated pack reads as Chinese instead of as a bug. The +English pack's type is the Chinese key set, so a key it is missing is a compile error, and the +unit suite asserts the same parity again for the case where the type is satisfied by a value the +pack does not really hold. + +A stored value that is not a language at all resolves to Chinese rather than to English. A user +whose storage was written by a build that never had a setter has no opinion about the language, and +the page they know is the Chinese one. + +## The search has to reach a row, not only a tab + +A setting is found by the words **on its row** — 「语言」, 「外观」, 「文件预览自动换行」 — and a +name is not what a reader remembers about it. `filterSettingsTabs` therefore matches the row-level +copy of each tab's page as well as the tab's own name, and it searches **both packs**: a reader who +looks for 「语言」 in an English page, or for "Language" in a Chinese one, is looking for the same +setting, and a search that knew only the current language would answer 「没有匹配的设置」 to half of +its own users. + +A search that matched a row also takes the reader to the page that owns it, and marks the row. A +tab match needs no help — the sidebar shows the tab and the reader clicks it — but a row match is +only half an answer without the page. + +## Constraints this decision puts on later work + +- **A second business that needs a language switch copies the shape; it does not import the + settings pack.** The settings pack is the settings business's copy. The moment another business + needs one, the shared part — the locale value, the lookup, the fallback rule — is what gets + lifted into a client-wide module, and that lift is the extraction PR #33 deferred. +- **A page's own copy stays in its own component until it is translated.** The packs cover the + settings page. The 浏览器, 用量与模型, 账户 and 工作树 pages keep their literals, and the search + does not promise a row it cannot name. +- **The search matches both packs, so a language switch does not narrow the search.** Adding a + language adds text to search, not text that hides. +- **`mavis-locale` stays the key.** A second key for the same choice would leave two answers to + one question, and the one already written would be the one nobody reads. diff --git a/packages/webui/src/client/features/settings/projection/settings-locale.ts b/packages/webui/src/client/features/settings/projection/settings-locale.ts new file mode 100644 index 000000000..13c250f94 --- /dev/null +++ b/packages/webui/src/client/features/settings/projection/settings-locale.ts @@ -0,0 +1,290 @@ +// The settings page's two language packs and the one way to read them. +// +// Why this module exists: the settings page's copy was hardcoded Chinese in the +// component that renders it, and `mavis-locale` was read by a `useState` that +// had no setter — the key was there, the read was there, and nothing a user +// could do ever wrote a different value. A language switch needs three things +// this module owns: the packs, a locale value, and a lookup that cannot fail +// in a way the caller has to handle. +// +// Why it lives in the settings business's projection folder: the copy is a +// derivation, not a capability — it is read, never written, and both the +// sidebar's tab list and the pages' own rows need it. The layer matrix lets a +// view module import another view module and a binding module import a view +// module, so a pack here reaches the registry and the dialog without a new +// dependency and without the platform layer reaching down into a business. +// +// The packs are Chinese and English. Chinese is the base language: it is the +// language the settings page was written in, and a key the English pack does +// not carry falls back to the Chinese text rather than to a raw key. A +// half-translated pack then reads as Chinese instead of as a bug. +// +// The scope is the settings page and nothing else. The conversation, the file +// panel and the workspace keep their own copy; a full extraction of the +// client's 8,000-odd Chinese characters is a different change with a different +// cost, and this module is shaped so that one can extend it (a third pack, more +// keys) without rewriting it. + +export type SettingsLocale = "zh" | "en"; + +/** + * The storage key the language choice lives under. + * + * The key already existed and was already written on every mount, so the + * switch reuses it instead of inventing a second one: a user who has the app + * open already has this key in their storage, and a second key would leave the + * two disagreeing about the same choice. + */ +export const SETTINGS_LOCALE_STORAGE_KEY = "mavis-locale"; + +/** + * The Chinese pack — the base language. + * + * Every string the settings page shows in Chinese lives here, so the page has + * one copy of its words rather than one per call site. The keys are namespaced + * by the surface that reads them (`tab.`, `shell.`, `generic.`) because a + * flat namespace would let two surfaces claim the same word. + */ +const ZH_COPY = { + // The sidebar's group headings. + "group.preferences": "偏好", + "group.management": "管理", + "group.coding": "编码", + "group.archived": "归档", + // The sidebar's tab names. + "tab.desktop": "通用", + "tab.voice": "语音", + "tab.shortcuts": "快捷键", + "tab.custom-instructions": "个性化", + "tab.browser": "浏览器", + "tab.usage": "用量与模型", + "tab.connection": "连接", + "tab.account": "账户", + "tab.coding": "代码审查", + "tab.worktree": "工作树", + "tab.archived": "已归档任务", + // The dialog's own chrome. + "shell.dialogLabel": "设置", + "shell.back": "返回应用", + "shell.backLabel": "返回", + "shell.searchPlaceholder": "搜索设置...", + "shell.searchLabel": "搜索设置", + "shell.clearSearch": "清空设置搜索", + "shell.navLabel": "设置分类", + "shell.noResults": "没有匹配的设置", + "shell.deleteAll": "全部删除", + // The 通用 page: sections. + "generic.section.mode": "模式", + "generic.section.application": "应用", + "generic.section.link": "链接", + "generic.section.file": "文件", + "generic.section.session": "会话管理", + "generic.section.agentControl": "Agent 控制权限", + "generic.section.about": "关于", + // The 通用 page: the mode cards. + "generic.mode.coding.title": "适用于编程开发", + "generic.mode.coding.description": "保留技术细节与开发工具", + "generic.mode.work.title": "适用于日常工作", + "generic.mode.work.description": "同样强大,减少技术细节干扰", + // The 通用 page: appearance. + "generic.appearance.title": "外观", + "generic.appearance.description": "选择应用的显示主题", + "generic.theme.light": "浅色模式", + "generic.theme.dark": "深色模式", + "generic.theme.system": "跟随系统", + "generic.themePreviewAlt": "{theme}预览", + // The 通用 page: the language row. + "generic.language.title": "语言", + "generic.language.description": "选择设置页显示的语言", + // The 通用 page: link destinations. + "generic.link.web.title": "网页链接打开位置", + "generic.link.web.description": "公开网页链接默认打开位置", + "generic.link.local.title": "本地链接打开位置", + "generic.link.local.description": "本地开发页面默认打开位置", + "generic.link.builtinBrowser": "内置浏览器", + // The 通用 page: files. + "generic.file.newTab.title": "在新的标签页打开文件", + "generic.file.newTab.description": "关闭后,默认复用未固定的文件标签页;已固定的标签页会保留。", + "generic.file.wrap.title": "文件预览自动换行", + "generic.file.wrap.description": "开启后,超出预览区域宽度的文本和代码会自动折行;关闭后可横向滚动查看。不修改文件内容。", + // The 通用 page: session management. + "generic.session.contextWindow.title": "显示上下文窗口使用情况", + // The 通用 page: agent control. + "generic.agent.autoOpenBrowser.title": "自动打开浏览器面板", + "generic.agent.autoOpenBrowser.description": "Agent 操作网页时,自动打开右侧浏览器面板", + // The 通用 page: about. + "generic.about.uploadLogs.title": "上传日志", + "generic.about.uploadLogs.description": "上传日志以协助排查问题", + "generic.about.uploadLogs.button": "上传", + "generic.about.version.title": "应用版本", + "generic.about.version.button": "检查更新", +} as const; + +/** Every key the settings page can ask for. */ +export type SettingsCopyKey = keyof typeof ZH_COPY; + +/** + * The English pack. + * + * The type is the Chinese key set, so a key the English pack is missing is a + * compile error rather than a silent fallback: the fallback in + * `settingsCopyValue` is for a pack that lost a key at runtime, not for a pack + * that was never finished. The unit suite asserts the same parity again, for + * the case where the type is satisfied by a value the pack does not really + * hold. + */ +const EN_COPY: Record = { + // The sidebar's group headings. + "group.preferences": "Preferences", + "group.management": "Management", + "group.coding": "Coding", + "group.archived": "Archived", + // The sidebar's tab names. + "tab.desktop": "General", + "tab.voice": "Voice", + "tab.shortcuts": "Shortcuts", + "tab.custom-instructions": "Personalization", + "tab.browser": "Browser", + "tab.usage": "Usage & Models", + "tab.connection": "Connection", + "tab.account": "Account", + "tab.coding": "Code Review", + "tab.worktree": "Worktrees", + "tab.archived": "Archived Tasks", + // The dialog's own chrome. + "shell.dialogLabel": "Settings", + "shell.back": "Back to app", + "shell.backLabel": "Back", + "shell.searchPlaceholder": "Search settings...", + "shell.searchLabel": "Search settings", + "shell.clearSearch": "Clear settings search", + "shell.navLabel": "Settings categories", + "shell.noResults": "No matching settings", + "shell.deleteAll": "Delete all", + // The 通用 page: sections. + "generic.section.mode": "Mode", + "generic.section.application": "Application", + "generic.section.link": "Links", + "generic.section.file": "Files", + "generic.section.session": "Session management", + "generic.section.agentControl": "Agent control permissions", + "generic.section.about": "About", + // The 通用 page: the mode cards. + "generic.mode.coding.title": "For programming and development", + "generic.mode.coding.description": "Keeps technical detail and developer tooling", + "generic.mode.work.title": "For everyday work", + "generic.mode.work.description": "Just as capable, with less technical noise", + // The 通用 page: appearance. + "generic.appearance.title": "Appearance", + "generic.appearance.description": "Choose how the app looks", + "generic.theme.light": "Light mode", + "generic.theme.dark": "Dark mode", + "generic.theme.system": "Follow system", + "generic.themePreviewAlt": "{theme} preview", + // The 通用 page: the language row. + "generic.language.title": "Language", + "generic.language.description": "Choose the language the settings page is shown in", + // The 通用 page: link destinations. + "generic.link.web.title": "Where web links open", + "generic.link.web.description": "Default location for public web links", + "generic.link.local.title": "Where local links open", + "generic.link.local.description": "Default location for local development pages", + "generic.link.builtinBrowser": "Built-in browser", + // The 通用 page: files. + "generic.file.newTab.title": "Open files in a new tab", + "generic.file.newTab.description": "When off, unpinned file tabs are reused; pinned tabs are kept.", + "generic.file.wrap.title": "Wrap lines in file previews", + "generic.file.wrap.description": "When on, text and code wider than the preview wrap automatically; when off, scroll sideways. File contents are not changed.", + // The 通用 page: session management. + "generic.session.contextWindow.title": "Show context window usage", + // The 通用 page: agent control. + "generic.agent.autoOpenBrowser.title": "Open the browser panel automatically", + "generic.agent.autoOpenBrowser.description": "Open the browser panel on the right when the agent drives a web page", + // The 通用 page: about. + "generic.about.uploadLogs.title": "Upload logs", + "generic.about.uploadLogs.description": "Upload logs to help diagnose a problem", + "generic.about.uploadLogs.button": "Upload", + "generic.about.version.title": "App version", + "generic.about.version.button": "Check for updates", +}; + +/** + * One pack: a key set whose values are strings, and whose absent keys are + * `undefined` rather than a hole in the type. The `undefined` matters — it is + * what makes the fallback in `settingsCopyValue` reachable rather than dead + * code the compiler could rule out. + */ +export type SettingsCopyPack = Readonly>; + +/** The two packs, read as one pair. */ +export interface SettingsCopyPacks { + readonly zh: SettingsCopyPack; + readonly en: SettingsCopyPack; +} + +/** The packs the settings page reads. */ +export const SETTINGS_COPY_PACKS: SettingsCopyPacks = { zh: ZH_COPY, en: EN_COPY }; + +/** + * The locale a stored value means. + * + * Anything that is not a Chinese tag is English, and anything unrecognised is + * Chinese — the language the page was written in. A stored value that is not a + * locale at all must not blank the page, and it must not silently read as + * English either: a user whose storage was written by an older build has no + * opinion about the language, and the page they know is the Chinese one. + */ +export function resolveSettingsLocale(raw: string | null | undefined): SettingsLocale { + const value = (raw ?? "").trim().toLowerCase(); + if (value.startsWith("zh")) return "zh"; + if (value.startsWith("en")) return "en"; + return "zh"; +} + +/** + * Reads one string. + * + * The locale's pack wins; a key the pack does not carry falls back to the + * Chinese text, and a key neither pack carries falls back to the key itself — + * which is visible, and therefore a bug someone can report, rather than a + * blank the user cannot describe. + * + * The packs are a parameter so the fallback is a rule that can be tested + * rather than a branch nothing can reach. + */ +export function settingsCopyValue( + locale: SettingsLocale, + key: string, + packs: SettingsCopyPacks = SETTINGS_COPY_PACKS, +): string { + return packs[locale][key] ?? packs.zh[key] ?? key; +} + +/** + * Reads one string and fills its placeholders. + * + * A placeholder is `{name}`; a name the caller did not supply is left as it + * was written, so a missing parameter shows up as a literal `{name}` instead of + * as an empty gap in a sentence. + */ +export function settingsText( + locale: SettingsLocale, + key: SettingsCopyKey, + params?: Readonly>, +): string { + const template = settingsCopyValue(locale, key); + if (!params) return template; + return template.replace(/\{(\w+)\}/gu, (match, name: string) => params[name] ?? match); +} + +/** + * The languages the switch offers, each written in its own language. + * + * An endonym is the convention: a reader who cannot read the current interface + * language is exactly the reader who needs this list, and "Deutsch" in a + * German list is easier to recognise than "German". + */ +export const SETTINGS_LANGUAGE_OPTIONS: readonly { readonly value: SettingsLocale; readonly label: string }[] = [ + { value: "zh", label: "中文" }, + { value: "en", label: "English" }, +]; diff --git a/packages/webui/src/client/features/settings/projection/settings-view.ts b/packages/webui/src/client/features/settings/projection/settings-view.ts index 56358911c..2e94fb4f4 100644 --- a/packages/webui/src/client/features/settings/projection/settings-view.ts +++ b/packages/webui/src/client/features/settings/projection/settings-view.ts @@ -7,23 +7,134 @@ // // The text below is the declaration as it stood in the source module — no // behaviour change. +// +// The language work that came later does change one thing here: a tab's name +// is now read from the language pack (`settings-locale.ts`) rather than being +// a literal, and the registry keeps the Chinese name as its `label` so the +// registry still reads the way it always did. A second key, `labelKey`, is +// what a locale lookup reads — the pack holds the words, the registry holds +// the structure. + +import { + settingsText, + type SettingsCopyKey, + type SettingsLocale, +} from "./settings-locale.js"; export type SettingsTabKey = "desktop" | "shortcuts" | "voice" | "custom-instructions" | "browser" | "usage" | "connection" | "account" | "coding" | "worktree" | "archived"; -export interface SettingsTabDefinition { readonly key: SettingsTabKey; readonly group: "preferences" | "management" | "coding" | "archived"; readonly label: string; readonly icon: string; readonly disabled?: boolean; } +/** The sidebar's group headings, in the order the sidebar shows them. */ +export type SettingsGroupKey = "preferences" | "management" | "coding" | "archived"; + +export interface SettingsTabDefinition { readonly key: SettingsTabKey; readonly group: SettingsGroupKey; /** The tab's name in the page's base language. Read from the pack, so the registry and the pack cannot drift apart. */ readonly label: string; /** The pack key the name is read from when a locale is applied. */ readonly labelKey: SettingsCopyKey; readonly icon: string; readonly disabled?: boolean; } + +export interface SettingsGroupDefinition { readonly key: SettingsGroupKey; readonly label: string; readonly labelKey: SettingsCopyKey; } export const DESKTOP_SETTINGS_TABS: readonly SettingsTabDefinition[] = [ -{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts", disabled: true }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" }, +{ key: "desktop", group: "preferences", label: settingsText("zh", "tab.desktop"), labelKey: "tab.desktop", icon: "desktop" }, { key: "voice", group: "preferences", label: settingsText("zh", "tab.voice"), labelKey: "tab.voice", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: settingsText("zh", "tab.shortcuts"), labelKey: "tab.shortcuts", icon: "shortcuts", disabled: true }, { key: "custom-instructions", group: "preferences", label: settingsText("zh", "tab.custom-instructions"), labelKey: "tab.custom-instructions", icon: "custom-instructions" }, // The browser switches were under the browser panel, in the tool area. A // setting that lives in a panel is a setting nobody finds without opening // that panel first, so they are a page of their own — under 偏好, next to // the other preferences. - { key: "browser", group: "preferences", label: "浏览器", icon: "browser" }, - { key: "usage", group: "management", label: "用量与模型", icon: "chart" }, { key: "connection", group: "management", label: "连接", icon: "link", disabled: true }, { key: "account", group: "management", label: "账户", icon: "user" }, { key: "coding", group: "coding", label: "代码审查", icon: "coding" }, { key: "worktree", group: "coding", label: "工作树", icon: "worktree" }, { key: "archived", group: "archived", label: "已归档任务", icon: "archived" }, + { key: "browser", group: "preferences", label: settingsText("zh", "tab.browser"), labelKey: "tab.browser", icon: "browser" }, + { key: "usage", group: "management", label: settingsText("zh", "tab.usage"), labelKey: "tab.usage", icon: "chart" }, { key: "connection", group: "management", label: settingsText("zh", "tab.connection"), labelKey: "tab.connection", icon: "link", disabled: true }, { key: "account", group: "management", label: settingsText("zh", "tab.account"), labelKey: "tab.account", icon: "user" }, { key: "coding", group: "coding", label: settingsText("zh", "tab.coding"), labelKey: "tab.coding", icon: "coding" }, { key: "worktree", group: "coding", label: settingsText("zh", "tab.worktree"), labelKey: "tab.worktree", icon: "worktree" }, { key: "archived", group: "archived", label: settingsText("zh", "tab.archived"), labelKey: "tab.archived", icon: "archived" }, +]; + +export const SETTINGS_GROUPS: readonly SettingsGroupDefinition[] = [ + { key: "preferences", label: settingsText("zh", "group.preferences"), labelKey: "group.preferences" }, + { key: "management", label: settingsText("zh", "group.management"), labelKey: "group.management" }, + { key: "coding", label: settingsText("zh", "group.coding"), labelKey: "group.coding" }, + { key: "archived", label: settingsText("zh", "group.archived"), labelKey: "group.archived" }, ]; export function resolveThemePreference(preference: string, systemDark: boolean): "light" | "dark" { return preference === "system" ? (systemDark ? "dark" : "light") : preference === "dark" ? "dark" : "light"; } -export function filterSettingsTabs(query: string): readonly SettingsTabDefinition[] { const needle = query.trim().toLowerCase(); return DESKTOP_SETTINGS_TABS.filter((tab) => !needle || `${tab.label} ${tab.key}`.toLowerCase().includes(needle)); } +/** + * The row-level copy each tab's page carries. + * + * The search matches a tab on its own name, and a name is not what a reader + * remembers about a setting: they remember the words on the row — 「语言」, + * 「外观」, 「文件预览自动换行」. A setting whose row nobody can search for is + * a setting nobody finds, which is exactly how the language row was + * unreachable before this list existed. + * + * Only the 通用 page is listed. The other pages keep their own copy, in their + * own components, and the language work covers the settings page — a list that + * named rows this change does not translate would promise a search that could + * not deliver. + */ +export const SETTINGS_TAB_ROW_COPY: Readonly> = { + desktop: [ + "generic.section.mode", + "generic.mode.coding.title", "generic.mode.coding.description", + "generic.mode.work.title", "generic.mode.work.description", + "generic.section.application", + "generic.appearance.title", "generic.appearance.description", + "generic.theme.light", "generic.theme.dark", "generic.theme.system", + "generic.language.title", "generic.language.description", + "generic.section.link", + "generic.link.web.title", "generic.link.web.description", + "generic.link.local.title", "generic.link.local.description", + "generic.section.file", + "generic.file.newTab.title", "generic.file.newTab.description", + "generic.file.wrap.title", "generic.file.wrap.description", + "generic.section.session", + "generic.session.contextWindow.title", + "generic.section.agentControl", + "generic.agent.autoOpenBrowser.title", "generic.agent.autoOpenBrowser.description", + "generic.section.about", + "generic.about.uploadLogs.title", "generic.about.uploadLogs.description", + "generic.about.version.title", + ], + voice: [], shortcuts: [], "custom-instructions": [], browser: [], usage: [], connection: [], account: [], coding: [], worktree: [], archived: [], +}; + +/** The tabs with their names in `locale`. */ +export function settingsTabs(locale: SettingsLocale): readonly SettingsTabDefinition[] { + return DESKTOP_SETTINGS_TABS.map((tab) => ({ ...tab, label: settingsText(locale, tab.labelKey) })); +} + +/** The sidebar's groups with their names in `locale`. */ +export function settingsGroups(locale: SettingsLocale): readonly SettingsGroupDefinition[] { + return SETTINGS_GROUPS.map((group) => ({ ...group, label: settingsText(locale, group.labelKey) })); +} + +/** + * Everything the search matches a tab on: its name, its key, and the copy of + * the rows its page carries. + * + * Both packs are searched, not only the one on screen. A reader who looks for + * 「语言」 in an English page, or for "Language" in a Chinese one, is looking + * for the same setting — and a search that only knew the current language + * would answer 「没有匹配的设置」 to half of its own users. + */ +export function settingsTabSearchText(tab: SettingsTabDefinition): string { + const rowCopy = SETTINGS_TAB_ROW_COPY[tab.key].flatMap((key) => [settingsText("zh", key), settingsText("en", key)]); + return [tab.label, settingsText("zh", tab.labelKey), settingsText("en", tab.labelKey), tab.key, ...rowCopy].join(" ").toLowerCase(); +} + +export function filterSettingsTabs(query: string, locale: SettingsLocale = "zh"): readonly SettingsTabDefinition[] { const needle = query.trim().toLowerCase(); if (!needle) return settingsTabs(locale); return settingsTabs(locale).filter((tab) => settingsTabSearchText(tab).includes(needle)); } + +/** A row the search matched, and the tab that owns it. */ +export interface SettingsSearchHit { readonly tabKey: SettingsTabKey; readonly copyKey: SettingsCopyKey; } -export const SETTINGS_GROUPS = [{ key: "preferences", label: "偏好" }, { key: "management", label: "管理" }, { key: "coding", label: "编码" }, { key: "archived", label: "归档" }] as const; +/** + * The row a search matched, if it matched a row rather than a tab. + * + * A tab match needs no help: the sidebar shows the tab and the reader clicks + * it. A row match is different — the reader was looking for a setting they + * saw on some page, and finding the page is only half of what they asked for. + * This says which tab the row lives on, so the dialog can go there and mark + * the row. + */ +export function findSettingsSearchHit(query: string): SettingsSearchHit | undefined { + const needle = query.trim().toLowerCase(); + if (!needle) return undefined; + for (const tab of DESKTOP_SETTINGS_TABS) { + for (const copyKey of SETTINGS_TAB_ROW_COPY[tab.key]) { + const text = [settingsText("zh", copyKey), settingsText("en", copyKey)].join(" ").toLowerCase(); + if (text.includes(needle)) return { tabKey: tab.key, copyKey }; + } + } + return undefined; +} diff --git a/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx b/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx index a9fa7ee27..c222b69e1 100644 --- a/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx +++ b/packages/webui/src/client/features/settings/ui/SettingsModal.public.tsx @@ -52,8 +52,10 @@ import { useWebuiSessionStoreContext } from "../../../bindings/application-conte import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js"; import { selectWebuiCatalogFlatPage } from "../../../features/sessions/application/session-catalog.public.js"; import { BrowserSettingsPage } from "../../browser/ui/BrowserSettingsPage.public.js"; -import { DESKTOP_SETTINGS_TABS, resolveThemePreference, filterSettingsTabs, SETTINGS_GROUPS } from "../projection/settings-view.js"; -import type { SettingsTabKey } from "../projection/settings-view.js"; +import { resolveThemePreference, filterSettingsTabs, settingsGroups, settingsTabs, findSettingsSearchHit } from "../projection/settings-view.js"; +import type { SettingsTabKey, SettingsSearchHit } from "../projection/settings-view.js"; +import { SETTINGS_LANGUAGE_OPTIONS, SETTINGS_LOCALE_STORAGE_KEY, resolveSettingsLocale, settingsText } from "../projection/settings-locale.js"; +import type { SettingsCopyKey, SettingsLocale } from "../projection/settings-locale.js"; import { WebuiZoneBoundary } from "../../../shell/WebuiZoneBoundary.js"; import { WebuiWorktreePanel } from "./SettingsModal.js"; @@ -81,7 +83,7 @@ function Icon({ name, size = 18 }: { readonly name: string; readonly size?: numb return