diff --git a/.gitignore b/.gitignore index 0951cdd..48a6303 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ tests/.ipc.cjs tests/.ipc-channels.cjs tests/.log-sanitizer.cjs tests/.reserved-accelerators.cjs +tests/.terminal-title.cjs release/ # stray local test artifacts diff --git a/AGENTS.md b/AGENTS.md index e8ccbfd..c7a098e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - 开发:`npm run dev`(主进程改动不热重建,需重启) - dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置 - 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`) -- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 17 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) +- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 18 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) - 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边 - 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入) -- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 17 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap) +- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 18 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap) - GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布 - 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist` @@ -70,6 +70,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - `src/shared/reservedAccelerators.ts`:设置页录制器与主进程 `applyGlobalShortcut` **共用同一张保留键表**(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致 - SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项 - `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动 +- 终端标签自动标题(「终端 N」)是**存储的显示文本**(进布局模板/会话快照/广播注册),`src/shared/terminalTitle.ts` 负责两个方向:反解用**全部 4 语言**的 pattern(任何语言生成的都能认出编号),渲染用当前语言;语言切换/快照恢复/模板应用时 `retitleAutoTitles` 原地重渲染(SSH 面板标题是用户起的连接名,永不动)。需要指定语言渲染时用 i18n 的 `tFor(lang, key, vars)` ## 锁屏 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b4e50cd..1f92c8c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -146,7 +146,7 @@ - [x] 归档脚本入 `scripts/`,`KEEP_TAG` 改必传参数 - [x] `isPanicLockChord` 改判 `input.code`(物理键位,Dvorak/非拉丁布局正确) - [x] P3:设置页「更新通道」死控件移除、layoutMenu/IconRail memo + onOpenSettings useCallback、TerminalHandle 死 API 移除 -- [ ] 面板标题存已翻译字符串(切语言错位)——** deferred**:牵涉 5 个消费点 + 旧持久化数据兼容,另开一轮处理 +- [x] 面板标题存已翻译字符串(切语言错位)——✅ 已修:不改持久化格式,改多语言反解 + 语言切换/快照恢复/模板应用三处原地重渲染(`src/shared/terminalTitle.ts`,18 号测试 terminal-title) ## 工程约定(贯穿各阶段) diff --git a/package.json b/package.json index 8fed144..16c2e08 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "pretest": "npm run typecheck", - "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs", + "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs", "predist": "npm test && npm install --package-lock-only", "dist": "electron-vite build && electron-builder --win msi nsis", "dist:dir": "electron-vite build && electron-builder --win --dir" diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index d6693ae..453c350 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -28,6 +28,7 @@ import 'dockview-react/dist/styles/dockview.css' import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections' import { t } from '@shared/i18n' +import { nextTerminalTitle, retitleAutoTitles } from '@shared/terminalTitle' import { ConnectionSidebar } from '../connections/ConnectionSidebar' import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar' import { ConnectFlow } from './ConnectFlow' @@ -129,31 +130,6 @@ function restoreLayoutSnapshot(api: DockviewApi | undefined, snapshot: Serialize } } -/** - * "终端 N" titles fill the lowest free number among live panels: closing a - * pane frees its number for the next new terminal, and a session restore can - * no longer leave the numbering behind (the old in-memory counter restarted - * at zero on launch and produced duplicate titles). - */ -function nextTerminalTitle(existing: Iterable): string { - const used = new Set() - // The pattern is translated, so the number is read back through that same - // pattern (the `{n}` placeholder marks the digits) instead of a fixed one. - const [head, tail] = t('workspace.tab.terminalTitle', { n: '\u0000' }).split('\u0000') - const matcher = new RegExp(`^${escapeRegExp(head)}(\\d+)${escapeRegExp(tail ?? '')}$`) - for (const title of existing) { - const n = title?.match(matcher)?.[1] - if (n) used.add(Number(n)) - } - let n = 1 - while (used.has(n)) n++ - return t('workspace.tab.terminalTitle', { n }) -} - -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined { return (panel?.params as TerminalParams | undefined)?.sessionId } @@ -457,6 +433,32 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX setModeCounts((prev) => (prev.terminal === t && prev.ssh === s ? prev : { terminal: t, ssh: s })) }, []) + /** + * Re-render every auto-numbered tab title in the active language, keeping its + * number. Idempotent — a title already in the active language is left alone. + * + * Titles are stored display text (they ride along in layout templates, the + * session snapshot and the broadcast registry) and outlive a language switch, + * so without this a pane keeps the wording of the language it was opened in. + * + * SSH panes are excluded: their title is the connection name the user typed, + * and a connection named "Terminal 3" must not be renamed behind their back. + * + * `setTitle` fires dockview's title-change event, which is all the rest needs: + * `TerminalPanel` re-registers the broadcast title, the tab component + * re-renders, the sidebar list refreshes and the snapshot save is scheduled + * (a panel title change forwards into the dockview's layout-change event). + */ + const retitleAutoPanels = useCallback((): void => { + retitleAutoTitles( + [terminalApiRef.current, sshApiRef.current].flatMap((api) => + (api?.panels ?? []).filter( + (panel) => (panel.params as TerminalParams | undefined)?.sessionKind !== 'ssh' + ) + ) + ) + }, []) + /** Focus a local terminal panel from the sidebar (terminal dockview). */ const onFocusPanel = useCallback((panelId: string): void => { const panel = terminalApiRef.current?.getPanel(panelId) @@ -996,6 +998,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const renames = new Map([...terminalLayout.renames, ...sshLayout.renames]) await rebindSessionPanels(terminal, snapshot, true, renames) await rebindSessionPanels(ssh, snapshot, true, renames) + // A snapshot is written in whatever language was active at the time, so + // render its auto titles in the current language before anything compares + // them: the dedup below then sees "终端 3" and "Terminal 3" as the one + // collision they are instead of letting both through. + retitleAutoPanels() // Retitle duplicates left over from the old counter bug (the snapshot it // wrote keeps colliding titles forever otherwise): the first panel keeps // the title, later ones get the lowest free number. @@ -1006,13 +1013,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const seenTitles = new Set() for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) { if ((p.params as TerminalParams | undefined)?.sessionKind === 'ssh') continue - const t = p.title ?? '' - if (!t) continue - if (seenTitles.has(t)) { + const title = p.title ?? '' + if (!title) continue + if (seenTitles.has(title)) { const fresh = nextTerminalTitle(seenTitles) p.setTitle(fresh) seenTitles.add(fresh) - } else seenTitles.add(t) + } else seenTitles.add(title) } lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current useWorkspaceModeStore.getState().setMode(snapshot.mode) @@ -1020,7 +1027,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX recomputeLocalPanels() return true }, - [rebindSessionPanels, recountAll, recomputeLocalPanels] + [rebindSessionPanels, recountAll, recomputeLocalPanels, retitleAutoPanels] ) const handleApplyTemplate = useCallback( @@ -1119,6 +1126,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current) if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current) + // A template carries the tab titles it was saved with, which may be in + // another language (or written by a build whose language the user has + // since switched away from). + retitleAutoPanels() + // Old sessions are unreachable after the layout swap — kill them. for (const sid of previousSessions) killSession(sid) recountAll() @@ -1126,7 +1138,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const mode = useWorkspaceModeStore.getState().mode activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel }, - [killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels] + [killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels, retitleAutoPanels] ) const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise => { @@ -1156,6 +1168,20 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX // are translated at build time, so `language` has to be a dependency or a // language switch would leave stale menu text behind. const language = useSettingsStore((s) => s.settings.system.language) + + // Auto tab titles are stored display text, so they have to be re-rendered on + // a language switch — the rest of the UI re-renders itself. Skipped on mount + // (a boot pass has not restored any panel yet, and a restored title is + // retitled by the pass itself) and on every settings echo that leaves the + // language untouched, which is the common case. + const lastLanguageRef = useRef(undefined) + useEffect(() => { + if (lastLanguageRef.current === language) return + const changed = lastLanguageRef.current !== undefined + lastLanguageRef.current = language + if (changed) retitleAutoPanels() + }, [language, retitleAutoPanels]) + const layoutMenu = useMemo( () => ({ items: [ diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts index 5cb361f..5482097 100644 --- a/src/shared/i18n/index.ts +++ b/src/shared/i18n/index.ts @@ -90,7 +90,18 @@ export function onLanguageChange(cb: (lang: Language) => void): () => void { /** Translate `key`; `{name}` placeholders are filled from `vars`. */ export function t(key: string, vars?: Record): string { - const raw = lookup(DICTS[current], key) ?? lookup(DICTS[DEFAULT_LANGUAGE], key) ?? key + return tFor(current, key, vars) +} + +/** + * Translate `key` as if `lang` were the active language, with the same + * fallback chain as `t`. Needed where a string has to be produced for a + * language other than the one being rendered: stored display text (terminal + * tab titles) is re-rendered on a language switch, and the language that text + * was written in has to be readable back out of it. + */ +export function tFor(lang: Language, key: string, vars?: Record): string { + const raw = lookup(DICTS[lang], key) ?? lookup(DICTS[DEFAULT_LANGUAGE], key) ?? key if (!vars) return raw return raw.replace(/\{(\w+)\}/g, (match, name: string) => vars[name] === undefined ? match : String(vars[name]) diff --git a/src/shared/terminalTitle.ts b/src/shared/terminalTitle.ts new file mode 100644 index 0000000..b0568a0 --- /dev/null +++ b/src/shared/terminalTitle.ts @@ -0,0 +1,139 @@ +/** + * Auto-generated local-terminal tab titles ("终端 3", "Terminal 3", …). + * + * A tab title is stored DISPLAY text, not data: dockview serializes it into + * layout templates, the workspace writes it into the session snapshot, and the + * broadcast registry keeps it. It used to be produced from the pattern of the + * *active* language and read back through that same pattern, so a title written + * before a language switch was neither countable (numbering restarted and + * produced duplicates) nor re-renderable (it stayed in the old language). + * + * The two directions are therefore asymmetric: + * - resolution runs over EVERY language's pattern, so an auto title is + * recognized no matter which language wrote it; + * - rendering uses the active language, so a switch re-renders it in place. + * + * A title matching no pattern was typed by the user and is never touched. + * + * Pure module (no Electron, no React, no browser surface) so plain Node can + * table-test it; the same shape as `reservedAccelerators.ts`. + */ + +import { LANGUAGES, t, tFor } from './i18n' + +const TITLE_KEY = 'workspace.tab.terminalTitle' + +/** Sentinel for "the number goes here", so the rendered pattern can be split + * into its literal head and tail. Cannot occur in a dictionary. */ +const MARK = '\u0000' + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Split a rendered pattern into the text before and after the number. + * `undefined` when the dictionary has no `{n}` at all (a missing key falls back + * to the key itself) or more than one — such a language cannot title a pane. + */ +function splitPattern(rendered: string): { head: string; tail: string } | undefined { + const parts = rendered.split(MARK) + if (parts.length !== 2) return undefined + return { head: parts[0], tail: parts[1] } +} + +/** + * Every language's pattern, compiled once: the dictionaries are static for the + * process's lifetime. Duplicates are dropped — a dictionary missing the key + * falls back to zh-CN and would otherwise be matched twice. + */ +const PATTERNS: RegExp[] = (() => { + const out: RegExp[] = [] + const seen = new Set() + for (const { value } of LANGUAGES) { + const parts = splitPattern(tFor(value, TITLE_KEY, { n: MARK })) + if (!parts) continue + const key = `${parts.head}\u0001${parts.tail}` + if (seen.has(key)) continue + seen.add(key) + // Both literals are anchored and matched exactly: "终端 3 号" and + // "终端机 3" are user titles, not auto ones. The digits exclude a leading + // zero — the app renders `String(n)` — so "Terminal 007" stays a user + // title instead of being renamed to "Terminal 7". + out.push(new RegExp(`^${escapeRegExp(parts.head)}([1-9]\\d*)${escapeRegExp(parts.tail)}$`)) + } + return out +})() + +/** + * The number an auto title carries, in whichever language wrote it, or + * `undefined` when the title is not an auto title. + */ +export function autoTerminalTitleNumber(title: string): number | undefined { + if (!title) return undefined + for (const matcher of PATTERNS) { + const found = matcher.exec(title) + if (found) return Number(found[1]) + } + return undefined +} + +/** The auto title for `n` in the active language. */ +export function renderTerminalTitle(n: number): string { + return t(TITLE_KEY, { n }) +} + +/** + * The auto title for the lowest free number among `existing`, in the active + * language. Numbers occupied in ANY language count as used, so a pane that + * still carries an old-language title keeps its number reserved. + * + * Closing a pane frees its number for the next new terminal, and a session + * restore can no longer leave the numbering behind (the old in-memory counter + * restarted at zero on launch and produced duplicate titles). + */ +export function nextTerminalTitle(existing: Iterable): string { + const used = new Set() + for (const title of existing) { + if (!title) continue + const n = autoTerminalTitleNumber(title) + if (n !== undefined) used.add(n) + } + let n = 1 + while (used.has(n)) n++ + return renderTerminalTitle(n) +} + +/** Just enough of a dockview panel for {@link retitleAutoTitles} — the real + * `IDockviewPanel` satisfies this structurally, so no adapter is needed. */ +export interface RetitlePanel { + readonly title: string | undefined + setTitle(title: string): void +} + +/** + * Re-render `panels`' auto titles in the active language, keeping each number; + * returns how many were rewritten. + * + * Called on a language switch (the rest of the UI re-renders itself) and after + * a layout/snapshot restore (which brings back titles stored under some older + * language). A panel whose title matches no language's pattern was named by the + * user and is left exactly as it is; one whose title already reads correctly is + * skipped, so this is safe to run on every settings echo. + * + * The caller decides which panels are eligible: an SSH pane is titled with the + * connection name the user typed, so a connection called "Terminal 3" must not + * be renamed behind their back. + */ +export function retitleAutoTitles(panels: Iterable): number { + let changed = 0 + for (const panel of panels) { + const n = autoTerminalTitleNumber(panel.title ?? '') + if (n === undefined) continue + const next = renderTerminalTitle(n) + if (next === panel.title) continue + panel.setTitle(next) + changed++ + } + return changed +} diff --git a/tests/build-bundles.cjs b/tests/build-bundles.cjs index bdb3193..6f4c977 100644 --- a/tests/build-bundles.cjs +++ b/tests/build-bundles.cjs @@ -46,6 +46,9 @@ const BUNDLES = [ // Reserved-accelerator table shared by the settings recorder and main's // registration guard: pure, table-tested. { entry: 'src/shared/reservedAccelerators.ts', out: 'tests/.reserved-accelerators.cjs' }, + // Auto terminal-tab titles: the pattern table is shared by the renderer's + // numbering and its language-switch retitle pass. Pure + dictionary-only. + { entry: 'tests/terminal-title.mjs', out: 'tests/.terminal-title.cjs' }, // Update service: feed probe/fallback. `electron-updater` is aliased to a // stub (the real package boots Electron), and the shell half (tray.ts) pulls // a `?asset` import, hence LOADERS. diff --git a/tests/terminal-title.mjs b/tests/terminal-title.mjs new file mode 100644 index 0000000..c491dcd --- /dev/null +++ b/tests/terminal-title.mjs @@ -0,0 +1,173 @@ +/** + * Auto terminal-title self-test (terminal-title.mjs). + * + * A local terminal's tab title is stored DISPLAY text — it rides along in + * layout templates, the session snapshot and the broadcast registry — so it + * outlives the language it was written in. `src/shared/terminalTitle.ts` is the + * one place that reads the number back out of such a title and that renders a + * fresh one, and both directions have to agree with all four dictionaries: + * + * - a title written in ANY language resolves to its number, so a workspace + * created in 简体中文 keeps counting correctly after the user switches to + * 日本語 (and the numbers fill the lowest free gap); + * - rendering follows the ACTIVE language, which is what makes a language + * switch re-render "终端 3" as "Terminal 3"; + * - a title that matches no language's pattern is the user's own and must + * never be resolved (a false positive would rename it on a language switch). + * + * The expected patterns are written out here rather than read from the module + * under test, so a dictionary that drifts from both would fail instead of + * agreeing with itself. + * + * Build: node tests/build-bundles.cjs + * Run: node tests/.terminal-title.cjs (must exit 0) + */ +import { + autoTerminalTitleNumber, + nextTerminalTitle, + renderTerminalTitle, + retitleAutoTitles +} from '../src/shared/terminalTitle.ts' +import { setLanguage, t } from '../src/shared/i18n/index.ts' + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 +} + +const LANGUAGES = ['zh-CN', 'zh-TW', 'en', 'ja'] + +const PATTERN = { + 'zh-CN': '终端 {n}', + 'zh-TW': '終端機 {n}', + en: 'Terminal {n}', + ja: 'ターミナル {n}' +} + +const title = (lang, n) => PATTERN[lang].replace('{n}', String(n)) + +console.log('the four dictionaries still carry the patterns this table is built from') +for (const lang of LANGUAGES) { + setLanguage(lang) + ok( + t('workspace.tab.terminalTitle', { n: 7 }) === title(lang, 7), + `${lang}: ${JSON.stringify(title(lang, 7))}` + ) +} + +console.log('rendering follows the ACTIVE language') +for (const lang of LANGUAGES) { + setLanguage(lang) + ok(renderTerminalTitle(3) === title(lang, 3), `${lang}: renderTerminalTitle(3) = ${JSON.stringify(title(lang, 3))}`) + setLanguage(lang) + ok(nextTerminalTitle([]) === title(lang, 1), `${lang}: an empty workspace starts at ${JSON.stringify(title(lang, 1))}`) +} + +console.log('every writer/reader language pair resolves') +for (const writer of LANGUAGES) { + for (const reader of LANGUAGES) { + setLanguage(reader) + const written = title(writer, 12) + ok( + autoTerminalTitleNumber(written) === 12, + `${writer} title resolves while the UI is ${reader}: ${JSON.stringify(written)}` + ) + } +} + +console.log('numbering is language-independent, so a switch keeps the count') +setLanguage('en') +ok( + nextTerminalTitle(['终端 1', '終端機 2', 'Terminal 3', 'ターミナル 4']) === 'Terminal 5', + 'one title per language still occupies its own number' +) +ok(nextTerminalTitle(['终端 3']) === 'Terminal 1', 'the lowest free number wins, not the highest used') +ok(nextTerminalTitle(['终端 1', 'Terminal 2']) === 'Terminal 3', 'the first gap is filled') +ok(nextTerminalTitle(['终端 2', 'Terminal 3']) === 'Terminal 1', 'a hole below the used range is filled') +ok(nextTerminalTitle(['Terminal 5']) === 'Terminal 1', 'a lone high number does not push the counter up') +setLanguage('ja') +ok(nextTerminalTitle(['终端 3']) === 'ターミナル 1', 'numbering renders in the language that is active now') + +console.log('user-authored titles are never resolved') +const notAuto = [ + // The match is anchored and both literals must be exact. + '终端机 3', // 简体 "machine": one character away from 繁體 "終端機" + '終端機3', // missing the space + '终端 3 号', + '终端 3', // double space + ' 终端 3', // leading space + '终端 3 ', // trailing space + 'My Terminal 4', + 'Terminal', + 'Terminal ', + 'Terminal #3', + 'x Terminal 3', + 'Terminal 3x', + 'ターミナル 3 番', + 'Terminal 007', // the app never renders leading zeros + '终端 -3', + '终端 3.5', + '', + '終端機 ٣' // Arabic-Indic digit +] +for (const s of notAuto) { + const n = autoTerminalTitleNumber(s) + ok(n === undefined, `not auto: ${JSON.stringify(s)} (got ${String(n)})`) +} + +console.log('degenerate input neither throws nor resolves') +for (const s of [undefined, null, 'Terminal 3'.repeat(400)]) { + let threw = false + let n + try { + n = autoTerminalTitleNumber(s) + } catch { + threw = true + } + ok(!threw && n === undefined, `safe on ${JSON.stringify(s)?.slice(0, 24) ?? String(s)}`) +} + +console.log('a language switch retitles exactly the auto titles') +/** Minimal stand-in for a dockview panel, so the retitle pass runs unchanged. */ +const fakePanel = (title) => { + const panel = { title, written: [] } + panel.setTitle = (next) => { + panel.written.push(next) + panel.title = next + } + return panel +} +const runRetitle = (lang, titles) => { + setLanguage(lang) + const panels = titles.map(fakePanel) + const changed = retitleAutoTitles(panels) + return { changed, titles: panels.map((p) => p.title), writes: panels.map((p) => p.written.length) } +} + +ok( + JSON.stringify(runRetitle('en', ['终端 1', '終端機 2', 'Terminal 3', 'ターミナル 4'])) === + JSON.stringify({ changed: 3, titles: ['Terminal 1', 'Terminal 2', 'Terminal 3', 'Terminal 4'], writes: [1, 1, 0, 1] }), + 'each old-language title is rewritten; one already in the active language is not' +) +ok( + JSON.stringify(runRetitle('ja', ['终端 3', 'My Terminal 4', 'x Terminal 5', 'Terminal'])) === + JSON.stringify({ changed: 1, titles: ['ターミナル 3', 'My Terminal 4', 'x Terminal 5', 'Terminal'], writes: [1, 0, 0, 0] }), + 'user titles survive a language switch untouched' +) +ok( + JSON.stringify(runRetitle('zh-TW', ['Terminal 7'])) === + JSON.stringify({ changed: 1, titles: ['終端機 7'], writes: [1] }), + 'the number is preserved across the switch' +) +ok(runRetitle('en', []).changed === 0, 'an empty panel list is a no-op') +ok( + runRetitle('en', ['终端 1', 'Terminal 1']).changed === 1, + 'two spellings of number 1 are re-rendered to one title without renumbering' +) + +if (failed > 0) { + console.error(`\n[terminal-title] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log('\n[terminal-title] ALL CHECKS PASSED')