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:
Bill committed 2026-10-08 00:11:20 +08:00
1 parent 9a46555fc1
commit 2653ab1820
9 files changed
+390 -36

No files matched your search

+57 -31
View File
@@ -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 | 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 {
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<string>()
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<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
// 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<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>(
() => ({
items: [
+12 -1
View File
@@ -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, 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
return raw.replace(/\{(\w+)\}/g, (match, name: string) =>
vars[name] === undefined ? match : String(vars[name])
+139
View File
@@ -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
}