cc-haha/desktop/src/lib/terminalRuntime.ts
程序员阿江(Relakkes) ce2c908161 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
2026-07-03 18:28:39 +08:00

118 lines
3.0 KiB
TypeScript

import type { Terminal as XTermTerminal, IDisposable } from '@xterm/xterm'
import type { FitAddon as XTermFitAddon } from '@xterm/addon-fit'
import { terminalApi } from '../api/terminal'
export type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable'
export type TerminalShellInfo = {
shell: string
cwd: string
}
export type TerminalRuntime = {
id: string
terminal: XTermTerminal | null
fit: XTermFitAddon | null
nativeSessionId: number | null
unlisteners: Array<() => void>
dataDisposable: IDisposable | null
startPromise: Promise<void> | null
startToken: number
status: TerminalStatus
error: string | null
shellInfo: TerminalShellInfo | null
listeners: Set<() => void>
}
const runtimes = new Map<string, TerminalRuntime>()
let localRuntimeCounter = 0
export function createLocalTerminalRuntimeId() {
localRuntimeCounter += 1
return `local-terminal-${localRuntimeCounter}`
}
export function getTerminalRuntime(id: string, initialStatus: TerminalStatus): TerminalRuntime {
const existing = runtimes.get(id)
if (existing) return existing
const runtime: TerminalRuntime = {
id,
terminal: null,
fit: null,
nativeSessionId: null,
unlisteners: [],
dataDisposable: null,
startPromise: null,
startToken: 0,
status: initialStatus,
error: null,
shellInfo: null,
listeners: new Set(),
}
runtimes.set(id, runtime)
return runtime
}
export function updateTerminalRuntime(
runtime: TerminalRuntime,
patch: Partial<Pick<TerminalRuntime, 'terminal' | 'fit' | 'nativeSessionId' | 'status' | 'error' | 'shellInfo'>>,
) {
Object.assign(runtime, patch)
notifyTerminalRuntime(runtime)
}
export function notifyTerminalRuntime(runtime: TerminalRuntime) {
runtime.listeners.forEach((listener) => listener())
}
export function subscribeTerminalRuntime(runtime: TerminalRuntime, listener: () => void) {
runtime.listeners.add(listener)
return () => {
runtime.listeners.delete(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
const element = terminal.element
if (element) {
if (element.parentElement !== host) {
host.replaceChildren(element)
}
} else {
host.innerHTML = ''
terminal.open(host)
}
runtime.fit?.fit()
}
export function destroyTerminalRuntime(id: string) {
const runtime = runtimes.get(id)
if (!runtime) return
runtimes.delete(id)
const sessionId = runtime.nativeSessionId
runtime.nativeSessionId = null
runtime.startToken += 1
runtime.startPromise = null
if (sessionId) {
void terminalApi.kill(sessionId).catch(() => {})
}
runtime.dataDisposable?.dispose()
runtime.dataDisposable = null
runtime.unlisteners.forEach((unlisten) => unlisten())
runtime.unlisteners = []
runtime.terminal?.dispose()
runtime.terminal = null
runtime.fit = null
runtime.listeners.clear()
}