mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
718e80f8c5
commit
ce2c908161
@ -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(() => {})
|
||||
}
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user