feat(i18n): tab auto titles follow the UI language
Titles are stored display text (layout templates, session snapshot, broadcast registry), so a pane used to keep the wording of the language it was opened in, and nextTerminalTitle could not even parse the number back out of another language's pattern (numbering restarted, duplicates). New pure module src/shared/terminalTitle.ts: resolution tries all four languages' patterns, rendering uses the active one; i18n gains tFor(lang) for off-language rendering. Workspace retitles auto-numbered local tabs in place on language switch, snapshot restore and template apply; SSH panels (user-typed connection names) are never touched. dockview's title-change event propagates the new title to the broadcast registry and the snapshot save with no extra wiring. New test terminal-title.mjs (16 writer/reader language pairs, non-auto title boundaries, gap filling); offline suite grows 17 -> 18.
This commit is contained in:
1 parent
9a46555fc1
commit
2653ab1820
9 files changed
+390
-36
No files matched your search
@@ -46,6 +46,7 @@ tests/.ipc.cjs
|
|||||||
tests/.ipc-channels.cjs
|
tests/.ipc-channels.cjs
|
||||||
tests/.log-sanitizer.cjs
|
tests/.log-sanitizer.cjs
|
||||||
tests/.reserved-accelerators.cjs
|
tests/.reserved-accelerators.cjs
|
||||||
|
tests/.terminal-title.cjs
|
||||||
release/
|
release/
|
||||||
|
|
||||||
# stray local test artifacts
|
# stray local test artifacts
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
|||||||
- 开发:`npm run dev`(主进程改动不热重建,需重启)
|
- 开发:`npm run dev`(主进程改动不热重建,需重启)
|
||||||
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(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 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,加新依赖时别放错边
|
- 依赖分类规则:**只有 `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;更新通道无计数接口不计入)
|
- 下载量统计:`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`,只做验证,不打包安装器、不发布
|
- 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`
|
- 国内网络需镜像:`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),两处分表曾漂移出洞,加新全局快捷键时两边自动一致
|
- `src/shared/reservedAccelerators.ts`:设置页录制器与主进程 `applyGlobalShortcut` **共用同一张保留键表**(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致
|
||||||
- SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项
|
- SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项
|
||||||
- `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动
|
- `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动
|
||||||
|
- 终端标签自动标题(「终端 N」)是**存储的显示文本**(进布局模板/会话快照/广播注册),`src/shared/terminalTitle.ts` 负责两个方向:反解用**全部 4 语言**的 pattern(任何语言生成的都能认出编号),渲染用当前语言;语言切换/快照恢复/模板应用时 `retitleAutoTitles` 原地重渲染(SSH 面板标题是用户起的连接名,永不动)。需要指定语言渲染时用 i18n 的 `tFor(lang, key, vars)`
|
||||||
|
|
||||||
## 锁屏
|
## 锁屏
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -146,7 +146,7 @@
|
|||||||
- [x] 归档脚本入 `scripts/`,`KEEP_TAG` 改必传参数
|
- [x] 归档脚本入 `scripts/`,`KEEP_TAG` 改必传参数
|
||||||
- [x] `isPanicLockChord` 改判 `input.code`(物理键位,Dvorak/非拉丁布局正确)
|
- [x] `isPanicLockChord` 改判 `input.code`(物理键位,Dvorak/非拉丁布局正确)
|
||||||
- [x] P3:设置页「更新通道」死控件移除、layoutMenu/IconRail memo + onOpenSettings useCallback、TerminalHandle 死 API 移除
|
- [x] P3:设置页「更新通道」死控件移除、layoutMenu/IconRail memo + onOpenSettings useCallback、TerminalHandle 死 API 移除
|
||||||
- [ ] 面板标题存已翻译字符串(切语言错位)——** deferred**:牵涉 5 个消费点 + 旧持久化数据兼容,另开一轮处理
|
- [x] 面板标题存已翻译字符串(切语言错位)——✅ 已修:不改持久化格式,改多语言反解 + 语言切换/快照恢复/模板应用三处原地重渲染(`src/shared/terminalTitle.ts`,18 号测试 terminal-title)
|
||||||
|
|
||||||
## 工程约定(贯穿各阶段)
|
## 工程约定(贯穿各阶段)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
"pretest": "npm run typecheck",
|
"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",
|
"predist": "npm test && npm install --package-lock-only",
|
||||||
"dist": "electron-vite build && electron-builder --win msi nsis",
|
"dist": "electron-vite build && electron-builder --win msi nsis",
|
||||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import 'dockview-react/dist/styles/dockview.css'
|
|||||||
|
|
||||||
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections'
|
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections'
|
||||||
import { t } from '@shared/i18n'
|
import { t } from '@shared/i18n'
|
||||||
|
import { nextTerminalTitle, retitleAutoTitles } from '@shared/terminalTitle'
|
||||||
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
|
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
|
||||||
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
|
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
|
||||||
import { ConnectFlow } from './ConnectFlow'
|
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 | undefined>): string {
|
|
||||||
const used = new Set<number>()
|
|
||||||
// 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 {
|
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
|
||||||
return (panel?.params as TerminalParams | undefined)?.sessionId
|
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 }))
|
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). */
|
/** Focus a local terminal panel from the sidebar (terminal dockview). */
|
||||||
const onFocusPanel = useCallback((panelId: string): void => {
|
const onFocusPanel = useCallback((panelId: string): void => {
|
||||||
const panel = terminalApiRef.current?.getPanel(panelId)
|
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])
|
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
|
||||||
await rebindSessionPanels(terminal, snapshot, true, renames)
|
await rebindSessionPanels(terminal, snapshot, true, renames)
|
||||||
await rebindSessionPanels(ssh, 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
|
// Retitle duplicates left over from the old counter bug (the snapshot it
|
||||||
// wrote keeps colliding titles forever otherwise): the first panel keeps
|
// wrote keeps colliding titles forever otherwise): the first panel keeps
|
||||||
// the title, later ones get the lowest free number.
|
// 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<string>()
|
const seenTitles = new Set<string>()
|
||||||
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
|
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
|
||||||
if ((p.params as TerminalParams | undefined)?.sessionKind === 'ssh') continue
|
if ((p.params as TerminalParams | undefined)?.sessionKind === 'ssh') continue
|
||||||
const t = p.title ?? ''
|
const title = p.title ?? ''
|
||||||
if (!t) continue
|
if (!title) continue
|
||||||
if (seenTitles.has(t)) {
|
if (seenTitles.has(title)) {
|
||||||
const fresh = nextTerminalTitle(seenTitles)
|
const fresh = nextTerminalTitle(seenTitles)
|
||||||
p.setTitle(fresh)
|
p.setTitle(fresh)
|
||||||
seenTitles.add(fresh)
|
seenTitles.add(fresh)
|
||||||
} else seenTitles.add(t)
|
} else seenTitles.add(title)
|
||||||
}
|
}
|
||||||
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
|
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
|
||||||
useWorkspaceModeStore.getState().setMode(snapshot.mode)
|
useWorkspaceModeStore.getState().setMode(snapshot.mode)
|
||||||
@@ -1020,7 +1027,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
recomputeLocalPanels()
|
recomputeLocalPanels()
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[rebindSessionPanels, recountAll, recomputeLocalPanels]
|
[rebindSessionPanels, recountAll, recomputeLocalPanels, retitleAutoPanels]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleApplyTemplate = useCallback(
|
const handleApplyTemplate = useCallback(
|
||||||
@@ -1119,6 +1126,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||||
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.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.
|
// Old sessions are unreachable after the layout swap — kill them.
|
||||||
for (const sid of previousSessions) killSession(sid)
|
for (const sid of previousSessions) killSession(sid)
|
||||||
recountAll()
|
recountAll()
|
||||||
@@ -1126,7 +1138,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
const mode = useWorkspaceModeStore.getState().mode
|
const mode = useWorkspaceModeStore.getState().mode
|
||||||
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
|
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<void> => {
|
const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise<void> => {
|
||||||
@@ -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
|
// are translated at build time, so `language` has to be a dependency or a
|
||||||
// language switch would leave stale menu text behind.
|
// language switch would leave stale menu text behind.
|
||||||
const language = useSettingsStore((s) => s.settings.system.language)
|
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<string | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastLanguageRef.current === language) return
|
||||||
|
const changed = lastLanguageRef.current !== undefined
|
||||||
|
lastLanguageRef.current = language
|
||||||
|
if (changed) retitleAutoPanels()
|
||||||
|
}, [language, retitleAutoPanels])
|
||||||
|
|
||||||
const layoutMenu = useMemo<MenuProps>(
|
const layoutMenu = useMemo<MenuProps>(
|
||||||
() => ({
|
() => ({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -90,7 +90,18 @@ export function onLanguageChange(cb: (lang: Language) => void): () => void {
|
|||||||
|
|
||||||
/** Translate `key`; `{name}` placeholders are filled from `vars`. */
|
/** Translate `key`; `{name}` placeholders are filled from `vars`. */
|
||||||
export function t(key: string, vars?: Record<string, string | number>): string {
|
export function t(key: string, vars?: Record<string, string | number>): 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, string | number>): string {
|
||||||
|
const raw = lookup(DICTS[lang], key) ?? lookup(DICTS[DEFAULT_LANGUAGE], key) ?? key
|
||||||
if (!vars) return raw
|
if (!vars) return raw
|
||||||
return raw.replace(/\{(\w+)\}/g, (match, name: string) =>
|
return raw.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||||
vars[name] === undefined ? match : String(vars[name])
|
vars[name] === undefined ? match : String(vars[name])
|
||||||
|
|||||||
@@ -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<string>()
|
||||||
|
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 | undefined>): string {
|
||||||
|
const used = new Set<number>()
|
||||||
|
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<RetitlePanel>): 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
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ const BUNDLES = [
|
|||||||
// Reserved-accelerator table shared by the settings recorder and main's
|
// Reserved-accelerator table shared by the settings recorder and main's
|
||||||
// registration guard: pure, table-tested.
|
// registration guard: pure, table-tested.
|
||||||
{ entry: 'src/shared/reservedAccelerators.ts', out: 'tests/.reserved-accelerators.cjs' },
|
{ 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
|
// Update service: feed probe/fallback. `electron-updater` is aliased to a
|
||||||
// stub (the real package boots Electron), and the shell half (tray.ts) pulls
|
// stub (the real package boots Electron), and the shell half (tray.ts) pulls
|
||||||
// a `?asset` import, hence LOADERS.
|
// a `?asset` import, hence LOADERS.
|
||||||
|
|||||||
@@ -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')
|
||||||
Reference in new issue
Block a user