diff --git a/desktop/src/lib/terminalRuntime.ts b/desktop/src/lib/terminalRuntime.ts index 81063a56..9ce89b26 100644 --- a/desktop/src/lib/terminalRuntime.ts +++ b/desktop/src/lib/terminalRuntime.ts @@ -16,6 +16,8 @@ export type TerminalRuntime = { nativeSessionId: number | null unlisteners: Array<() => void> dataDisposable: IDisposable | null + startPromise: Promise | 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(() => {}) } diff --git a/desktop/src/main.test.tsx b/desktop/src/main.test.tsx index 9798b264..15633805 100644 --- a/desktop/src/main.test.tsx +++ b/desktop/src/main.test.tsx @@ -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', () => ({ diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 75bb7252..555887b1 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -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' diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index 0a528a94..d96566f6 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -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( + <> + + + , + ) + + 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 diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index f15a8829..6e04e53e 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -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({