fix(desktop): prevent duplicate terminal lines (#964)

Ensure terminal runtimes are started through a single in-flight start, invalidate stale async starts on destroy, and load xterm base styles before app globals while hiding helper/accessibility layers in the terminal host.

Tested: cd desktop && bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 18:28:16 +08:00
parent 718e80f8c5
commit ce2c908161
8 changed files with 263 additions and 101 deletions

View File

@ -16,6 +16,8 @@ export type TerminalRuntime = {
nativeSessionId: number | null
unlisteners: Array<() => void>
dataDisposable: IDisposable | null
startPromise: Promise<void> | null
startToken: number
status: TerminalStatus
error: string | null
shellInfo: TerminalShellInfo | null
@ -41,6 +43,8 @@ export function getTerminalRuntime(id: string, initialStatus: TerminalStatus): T
nativeSessionId: null,
unlisteners: [],
dataDisposable: null,
startPromise: null,
startToken: 0,
status: initialStatus,
error: null,
shellInfo: null,
@ -69,6 +73,10 @@ export function subscribeTerminalRuntime(runtime: TerminalRuntime, listener: ()
}
}
export function isTerminalRuntimeCurrent(runtime: TerminalRuntime) {
return runtimes.get(runtime.id) === runtime
}
export function attachTerminalRuntime(runtime: TerminalRuntime, host: HTMLElement) {
const terminal = runtime.terminal
if (!terminal) return
@ -92,6 +100,8 @@ export function destroyTerminalRuntime(id: string) {
const sessionId = runtime.nativeSessionId
runtime.nativeSessionId = null
runtime.startToken += 1
runtime.startPromise = null
if (sessionId) {
void terminalApi.kill(sessionId).catch(() => {})
}

View File

@ -11,6 +11,7 @@ vi.mock('./lib/persistenceMigrations', () => ({
runDesktopPersistenceMigrations: mocks.runDesktopPersistenceMigrations,
}))
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
vi.mock('./theme/globals.css', () => ({}))
vi.mock('./App', () => ({

View File

@ -1,5 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@xterm/xterm/css/xterm.css'
import './theme/globals.css'
import { initializeAppZoom } from './lib/appZoom'
import { initializeTouchH5 } from './lib/touchH5'

View File

@ -9,6 +9,7 @@ const terminalMocks = vi.hoisted(() => {
const terminalInstance = {
cols: 80,
rows: 24,
element: null as HTMLElement | null,
loadAddon: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
@ -84,6 +85,7 @@ describe('TerminalSettings', () => {
terminalMocks.getBashPath.mockReset()
terminalMocks.setBashPath.mockReset()
terminalMocks.terminalInstance.loadAddon.mockClear()
terminalMocks.terminalInstance.element = null
terminalMocks.terminalInstance.open.mockClear()
terminalMocks.terminalInstance.dispose.mockClear()
terminalMocks.terminalInstance.onData.mockClear()
@ -139,6 +141,32 @@ describe('TerminalSettings', () => {
expect(terminalMocks.fitInstance.fit).toHaveBeenCalled()
})
it('does not start duplicate xterm surfaces for one runtime', async () => {
terminalMocks.available = true
terminalMocks.terminalInstance.open.mockImplementation((host: HTMLElement) => {
const element = document.createElement('div')
element.className = 'xterm'
terminalMocks.terminalInstance.element = element
host.appendChild(element)
})
render(
<>
<TerminalSettings runtimeId="shared-terminal-runtime" preserveOnUnmount />
<TerminalSettings runtimeId="shared-terminal-runtime" preserveOnUnmount testId="settings-terminal-host-secondary" />
</>,
)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
await vi.dynamicImportSettled()
expect(terminalMocks.spawn).toHaveBeenCalledTimes(1)
expect(terminalMocks.terminalInstance.open).toHaveBeenCalledTimes(1)
expect(document.body.querySelectorAll('.settings-terminal-host .xterm')).toHaveLength(1)
destroyTerminalRuntime('shared-terminal-runtime')
})
it('uses one compact toolbar instead of a nested terminal title bar', async () => {
terminalMocks.available = true

View File

@ -13,6 +13,7 @@ import {
createLocalTerminalRuntimeId,
destroyTerminalRuntime,
getTerminalRuntime,
isTerminalRuntimeCurrent,
subscribeTerminalRuntime,
updateTerminalRuntime,
type TerminalRuntime,
@ -150,122 +151,186 @@ export function TerminalSettings({
}
}, [runtime])
const startTerminal = useCallback(async () => {
const startTerminal = useCallback(() => {
if (!terminalApi.isAvailable()) {
updateTerminalRuntime(runtime, { status: 'unavailable' })
return
return Promise.resolve()
}
if (runtime.startPromise) {
const host = hostRef.current
void runtime.startPromise.then(() => {
if (!host || !isTerminalRuntimeCurrent(runtime) || !runtime.terminal) return
attachTerminalRuntime(runtime, host)
resizeSession()
})
return runtime.startPromise
}
const host = hostRef.current
if (!host) return
if (!host) return Promise.resolve()
updateTerminalRuntime(runtime, { error: null, status: 'starting', shellInfo: null })
const startToken = runtime.startToken + 1
runtime.startToken = startToken
const isCurrentStart = () => isTerminalRuntimeCurrent(runtime) && runtime.startToken === startToken
const existing = runtime.nativeSessionId
if (existing) {
await terminalApi.kill(existing).catch(() => {})
runtime.nativeSessionId = null
}
runtime.dataDisposable?.dispose()
runtime.dataDisposable = null
runtime.unlisteners.forEach((unlisten) => unlisten())
runtime.unlisteners = []
const startPromise = Promise.resolve().then(async () => {
if (!isCurrentStart()) return
updateTerminalRuntime(runtime, { error: null, status: 'starting', shellInfo: null })
runtime.terminal?.dispose()
runtime.terminal = null
runtime.fit = null
host.innerHTML = ''
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
])
const terminal = new Terminal({
cursorBlink: true,
convertEol: false,
fontFamily: "var(--font-mono), 'SFMono-Regular', Consolas, monospace",
fontSize: 12,
lineHeight: 1.25,
scrollback: 4000,
theme: {
background: '#121212',
foreground: '#d7d2d0',
cursor: '#ffb59f',
selectionBackground: '#5f4a40',
black: '#1f1f1f',
red: '#ff6d67',
green: '#7ef18a',
yellow: '#f8c55f',
blue: '#77a8ff',
magenta: '#d699ff',
cyan: '#61d6d6',
white: '#d7d2d0',
brightBlack: '#8f8683',
brightRed: '#ff8a85',
brightGreen: '#9ff7a7',
brightYellow: '#ffdd7a',
brightBlue: '#a6c5ff',
brightMagenta: '#e3b8ff',
brightCyan: '#8ceeee',
brightWhite: '#ffffff',
},
})
const fit = new FitAddon()
terminal.loadAddon(fit)
terminal.open(host)
updateTerminalRuntime(runtime, { terminal, fit })
fit.fit()
const outputUnlisten = await terminalApi.onOutput((payload) => {
if (payload.session_id === runtime.nativeSessionId) {
terminal.write(payload.data)
const existing = runtime.nativeSessionId
if (existing) {
await terminalApi.kill(existing).catch(() => {})
if (!isCurrentStart()) return
runtime.nativeSessionId = null
}
})
const exitUnlisten = await terminalApi.onExit((payload) => {
if (payload.session_id !== runtime.nativeSessionId) return
updateTerminalRuntime(runtime, { status: 'exited' })
const signal = payload.signal ? `, ${payload.signal}` : ''
terminal.writeln(`\r\n[process exited: ${payload.code}${signal}]`)
updateTerminalRuntime(runtime, { nativeSessionId: null })
})
runtime.unlisteners = [outputUnlisten, exitUnlisten]
runtime.dataDisposable?.dispose()
runtime.dataDisposable = null
runtime.unlisteners.forEach((unlisten) => unlisten())
runtime.unlisteners = []
runtime.dataDisposable = terminal.onData((data) => {
const sessionId = runtime.nativeSessionId
if (sessionId) {
void terminalApi.write(sessionId, data).catch((err) => {
runtime.terminal?.dispose()
runtime.terminal = null
runtime.fit = null
host.innerHTML = ''
let TerminalModule: typeof import('@xterm/xterm')
let FitAddonModule: typeof import('@xterm/addon-fit')
try {
[TerminalModule, FitAddonModule] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
])
} catch (err) {
if (isCurrentStart()) {
updateTerminalRuntime(runtime, {
error: err instanceof Error ? err.message : String(err),
status: 'error',
})
}
return
}
if (!isCurrentStart()) return
let terminal: import('@xterm/xterm').Terminal | null = null
let fit: import('@xterm/addon-fit').FitAddon | null = null
let outputUnlisten: (() => void) | null = null
let exitUnlisten: (() => void) | null = null
try {
terminal = new TerminalModule.Terminal({
cursorBlink: true,
convertEol: false,
fontFamily: "var(--font-mono), 'SFMono-Regular', Consolas, monospace",
fontSize: 12,
lineHeight: 1.25,
scrollback: 4000,
theme: {
background: '#121212',
foreground: '#d7d2d0',
cursor: '#ffb59f',
selectionBackground: '#5f4a40',
black: '#1f1f1f',
red: '#ff6d67',
green: '#7ef18a',
yellow: '#f8c55f',
blue: '#77a8ff',
magenta: '#d699ff',
cyan: '#61d6d6',
white: '#d7d2d0',
brightBlack: '#8f8683',
brightRed: '#ff8a85',
brightGreen: '#9ff7a7',
brightYellow: '#ffdd7a',
brightBlue: '#a6c5ff',
brightMagenta: '#e3b8ff',
brightCyan: '#8ceeee',
brightWhite: '#ffffff',
},
})
fit = new FitAddonModule.FitAddon()
const activeTerminal = terminal
const activeFit = fit
activeTerminal.loadAddon(activeFit)
activeTerminal.open(host)
if (!isCurrentStart()) {
activeTerminal.dispose()
return
}
updateTerminalRuntime(runtime, { terminal: activeTerminal, fit: activeFit })
activeFit.fit()
outputUnlisten = await terminalApi.onOutput((payload) => {
if (payload.session_id === runtime.nativeSessionId) {
activeTerminal.write(payload.data)
}
})
exitUnlisten = await terminalApi.onExit((payload) => {
if (payload.session_id !== runtime.nativeSessionId) return
updateTerminalRuntime(runtime, { status: 'exited' })
const signal = payload.signal ? `, ${payload.signal}` : ''
activeTerminal.writeln(`\r\n[process exited: ${payload.code}${signal}]`)
updateTerminalRuntime(runtime, { nativeSessionId: null })
})
if (!isCurrentStart()) {
outputUnlisten()
exitUnlisten()
activeTerminal.dispose()
return
}
runtime.unlisteners = [outputUnlisten, exitUnlisten]
runtime.dataDisposable = terminal.onData((data) => {
const sessionId = runtime.nativeSessionId
if (sessionId) {
void terminalApi.write(sessionId, data).catch((err) => {
updateTerminalRuntime(runtime, {
error: err instanceof Error ? err.message : String(err),
status: 'error',
})
})
}
})
const result = await terminalApi.spawn({
cols: activeTerminal.cols,
rows: activeTerminal.rows,
...(cwd ? { cwd } : {}),
})
if (!isCurrentStart()) {
await terminalApi.kill(result.session_id).catch(() => {})
outputUnlisten()
exitUnlisten()
activeTerminal.dispose()
return
}
updateTerminalRuntime(runtime, {
nativeSessionId: result.session_id,
shellInfo: { shell: result.shell, cwd: result.cwd },
status: 'running',
})
resizeSession()
} catch (err) {
outputUnlisten?.()
exitUnlisten?.()
terminal?.dispose()
if (isCurrentStart()) {
updateTerminalRuntime(runtime, {
terminal: null,
fit: null,
error: err instanceof Error ? err.message : String(err),
status: 'error',
})
}
}
})
try {
const result = await terminalApi.spawn({
cols: terminal.cols,
rows: terminal.rows,
...(cwd ? { cwd } : {}),
})
updateTerminalRuntime(runtime, {
nativeSessionId: result.session_id,
shellInfo: { shell: result.shell, cwd: result.cwd },
status: 'running',
})
resizeSession()
} catch (err) {
outputUnlisten()
exitUnlisten()
terminal.dispose()
updateTerminalRuntime(runtime, {
terminal: null,
fit: null,
error: err instanceof Error ? err.message : String(err),
status: 'error',
})
}
runtime.startPromise = startPromise
void startPromise.finally(() => {
if (runtime.startPromise === startPromise) {
runtime.startPromise = null
}
}).catch(() => {})
return startPromise
}, [cwd, resizeSession, runtime])
useEffect(() => {
@ -275,6 +340,12 @@ export function TerminalSettings({
attachTerminalRuntime(runtime, hostRef.current)
}
resizeSession()
} else if (runtime.startPromise) {
void runtime.startPromise.then(() => {
if (!hostRef.current || !isTerminalRuntimeCurrent(runtime) || !runtime.terminal) return
attachTerminalRuntime(runtime, hostRef.current)
resizeSession()
})
} else {
void startTerminal()
}
@ -423,7 +494,8 @@ export function TerminalSettings({
<button
type="button"
onClick={() => void startTerminal()}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--color-text-primary)] px-2.5 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
disabled={status === 'starting'}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--color-text-primary)] px-2.5 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
{t('settings.terminal.restart')}

View File

@ -1,5 +1,4 @@
@import "tailwindcss";
@import "@xterm/xterm/css/xterm.css";
@plugin "@tailwindcss/typography";
/* ─── Self-hosted fonts (no Google CDN dependency) ────────────── */
@ -130,6 +129,36 @@
outline: none;
}
.settings-terminal-host .xterm-accessibility:not(.debug),
.settings-terminal-host .xterm-message {
position: absolute;
inset: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.settings-terminal-host .xterm-accessibility-tree {
color: transparent;
pointer-events: none;
}
.settings-terminal-host .xterm-helper-textarea {
position: absolute;
top: 0;
left: -9999em;
z-index: -5;
width: 0;
height: 0;
margin: 0;
border: 0;
padding: 0;
opacity: 0;
overflow: hidden;
resize: none;
white-space: nowrap;
}
.settings-checkbox-input {
position: absolute;
inset: 0;

View File

@ -131,4 +131,14 @@ describe('desktop theme tokens', () => {
expect(css).toContain('background: var(--color-surface-hover);')
expect(css).toContain('--line-numbers-foreground: var(--color-text-tertiary);')
})
it('keeps xterm helper and accessibility layers from rendering duplicate terminal text', () => {
expect(css).toContain('.settings-terminal-host .xterm-accessibility:not(.debug),')
expect(css).toContain('.settings-terminal-host .xterm-message')
expect(css).toContain('color: transparent;')
expect(css).toContain('pointer-events: none;')
expect(css).toContain('.settings-terminal-host .xterm-helper-textarea')
expect(css).toContain('left: -9999em;')
expect(css).toContain('overflow: hidden;')
})
})

View File

@ -19,4 +19,15 @@ describe('desktop build compatibility', () => {
expect(css).toContain('--color-text-secondary-a72')
expect(css).toContain('--color-outline-a92')
})
it('loads xterm base styles before app globals in the desktop entry', () => {
const main = readFileSync(join(desktopRoot, 'src', 'main.tsx'), 'utf8')
const xtermImport = main.indexOf("import '@xterm/xterm/css/xterm.css'")
const globalsImport = main.indexOf("import './theme/globals.css'")
expect(xtermImport).toBeGreaterThanOrEqual(0)
expect(globalsImport).toBeGreaterThanOrEqual(0)
expect(xtermImport).toBeLessThan(globalsImport)
})
})