fix: allocate terminal titles by lowest free number instead of a counter

Closing 终端 6/7 then opening a new one produced 终端 8 rather than
refilling the gap. nextTerminalTitle now scans live panel titles and takes
the smallest unused N, which also subsumes the restore-time counter
seeding (syncTitleSeq is gone) — duplicate retitling after a restore fills
the lowest free number too.
This commit is contained in:
Bill committed 2026-09-14 22:24:36 +08:00
1 parent 03aa79a691
commit cbf4c224fb
1 file changed
+22 -30
+22 -30
View File
@@ -61,26 +61,20 @@ const TERMINAL_COMPONENT = 'terminal'
const TERMINAL_TAB_COMPONENT = 'terminal-tab' const TERMINAL_TAB_COMPONENT = 'terminal-tab'
/** /**
* Per-workspace monotonic title counter for "终端 N". Lives on `window` so * "终端 N" titles fill the lowest free number among live panels: closing a
* dev hot-reloads (which reset module state) don't restart the numbering and * pane frees its number for the next new terminal, and a session restore can
* produce duplicate tab titles. * no longer leave the numbering behind (the old in-memory counter restarted
* at zero on launch and produced duplicate titles).
*/ */
const titleSeqHolder = window as unknown as { __otTitleSeq?: number } function nextTerminalTitle(existing: Iterable<string | undefined>): string {
function nextTerminalTitle(): string { const used = new Set<number>()
titleSeqHolder.__otTitleSeq = (titleSeqHolder.__otTitleSeq ?? 0) + 1 for (const title of existing) {
return `终端 ${titleSeqHolder.__otTitleSeq}`
}
/**
* Lift the counter past every "终端 N" currently on screen. The counter lives
* only in memory, so after a session restore / template apply it is behind the
* restored titles and the next new terminal duplicates one (`终端 2` twice).
*/
function syncTitleSeq(titles: Iterable<string | undefined>): void {
for (const title of titles) {
const n = title?.match(/^终端 (\d+)$/)?.[1] const n = title?.match(/^终端 (\d+)$/)?.[1]
if (n) titleSeqHolder.__otTitleSeq = Math.max(titleSeqHolder.__otTitleSeq ?? 0, Number(n)) if (n) used.add(Number(n))
} }
let n = 1
while (used.has(n)) n++
return `终端 ${n}`
} }
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined { function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
@@ -200,7 +194,7 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
id: panelId(), id: panelId(),
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(containerApi.panels.map((p) => p.title)),
params: { sessionId: result.id, sessionKind: 'local' }, params: { sessionId: result.id, sessionKind: 'local' },
position: { referenceGroup: group.id, direction: 'within' } position: { referenceGroup: group.id, direction: 'within' }
}) })
@@ -630,7 +624,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
id: panelId(), id: panelId(),
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(current.panels.map((p) => p.title)),
params: { sessionId, sessionKind: 'local' }, params: { sessionId, sessionKind: 'local' },
position: position:
reference && direction reference && direction
@@ -873,17 +867,18 @@ 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)
const allPanels = [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])] // Retitle duplicates left over from the old counter bug (the snapshot it
syncTitleSeq(allPanels.map((p) => p.title)) // wrote keeps colliding titles forever otherwise): the first panel keeps
// Retitle duplicates left over from the counter bug (the snapshot the bug // the title, later ones get the lowest free number.
// wrote keeps colliding titles forever otherwise): first panel keeps the
// title, later ones get a fresh number past the restored maximum.
const seenTitles = new Set<string>() const seenTitles = new Set<string>()
for (const p of allPanels) { for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
const t = p.title ?? '' const t = p.title ?? ''
if (!t) continue if (!t) continue
if (seenTitles.has(t)) p.setTitle(nextTerminalTitle()) if (seenTitles.has(t)) {
else seenTitles.add(t) const fresh = nextTerminalTitle(seenTitles)
p.setTitle(fresh)
seenTitles.add(fresh)
} else seenTitles.add(t)
} }
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
useWorkspaceModeStore.getState().setMode(snapshot.mode) useWorkspaceModeStore.getState().setMode(snapshot.mode)
@@ -932,9 +927,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
// Fresh sessions for every restored terminal panel in both workspaces. // Fresh sessions for every restored terminal panel in both workspaces.
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)
syncTitleSeq(
[...(terminalApiRef.current?.panels ?? []), ...(sshApiRef.current?.panels ?? [])].map((p) => p.title)
)
// 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)