Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const backgroundTerminalIds = new Set<string>();

export function markTerminalForBackground(terminalId: string): void {
backgroundTerminalIds.add(terminalId);
}

export function consumeTerminalBackgroundIntent(terminalId: string): boolean {
return backgroundTerminalIds.delete(terminalId);
}
225 changes: 174 additions & 51 deletions apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
} from "./terminal-ws-transport";

interface RegistryEntry {
terminalId: string;
instanceId: string;
runtime: TerminalRuntime | null;
transport: TerminalTransport;
linkManager: TerminalLinkManager | null;
Expand All @@ -35,22 +37,92 @@ interface RegistryEntry {

class TerminalRuntimeRegistryImpl {
private entries = new Map<string, RegistryEntry>();
private entryKeysByTerminalId = new Map<string, Set<string>>();

private getOrCreateEntry(terminalId: string): RegistryEntry {
let entry = this.entries.get(terminalId);
private getEntryKey(terminalId: string, instanceId = terminalId): string {
return `${terminalId}\u0000${instanceId}`;
}

private getOrCreateEntry(
terminalId: string,
instanceId = terminalId,
): RegistryEntry {
const key = this.getEntryKey(terminalId, instanceId);
let entry = this.entries.get(key);
if (entry) return entry;

entry = {
terminalId,
instanceId,
runtime: null,
transport: createTransport(),
linkManager: null,
pendingLinkHandlers: null,
};

this.entries.set(terminalId, entry);
this.entries.set(key, entry);
let keys = this.entryKeysByTerminalId.get(terminalId);
if (!keys) {
keys = new Set();
this.entryKeysByTerminalId.set(terminalId, keys);
}
keys.add(key);
return entry;
}

private getEntry(
terminalId: string,
instanceId?: string,
): RegistryEntry | null {
if (instanceId) {
return this.entries.get(this.getEntryKey(terminalId, instanceId)) ?? null;
}
return this.getPrimaryEntry(terminalId);
}

private getPrimaryEntry(terminalId: string): RegistryEntry | null {
const defaultEntry = this.entries.get(this.getEntryKey(terminalId));
if (defaultEntry) return defaultEntry;

const keys = this.entryKeysByTerminalId.get(terminalId);
const firstKey = keys?.values().next().value;
return firstKey ? (this.entries.get(firstKey) ?? null) : null;
}

private getEntries(terminalId: string): RegistryEntry[] {
const keys = this.entryKeysByTerminalId.get(terminalId);
if (!keys) return [];
return Array.from(keys)
.map((key) => this.entries.get(key))
.filter((entry): entry is RegistryEntry => Boolean(entry));
}

private deleteEntry(entry: RegistryEntry) {
const key = this.getEntryKey(entry.terminalId, entry.instanceId);
this.entries.delete(key);
const keys = this.entryKeysByTerminalId.get(entry.terminalId);
if (!keys) return;
keys.delete(key);
if (keys.size === 0) {
this.entryKeysByTerminalId.delete(entry.terminalId);
}
}

private serializeExistingRuntime(
terminalId: string,
excludedInstanceId: string,
): string | undefined {
for (const entry of this.getEntries(terminalId)) {
if (entry.instanceId === excludedInstanceId || !entry.runtime) continue;
try {
return entry.runtime.serializeAddon.serialize({ scrollback: 1000 });
} catch {
return undefined;
}
}
return undefined;
}

/**
* Ensure the xterm runtime exists and attach it to `container`.
* Synchronous. DOM-only — the WebSocket transport is untouched.
Expand All @@ -67,11 +139,14 @@ class TerminalRuntimeRegistryImpl {
terminalId: string,
container: HTMLDivElement,
appearance: TerminalAppearance,
instanceId = terminalId,
) {
const entry = this.getOrCreateEntry(terminalId);
const entry = this.getOrCreateEntry(terminalId, instanceId);

if (!entry.runtime) {
entry.runtime = createRuntime(terminalId, appearance);
entry.runtime = createRuntime(terminalId, appearance, {
initialBuffer: this.serializeExistingRuntime(terminalId, instanceId),
});
entry.linkManager = new TerminalLinkManager(entry.runtime.terminal);
if (entry.pendingLinkHandlers) {
entry.linkManager.setHandlers(entry.pendingLinkHandlers);
Expand All @@ -94,8 +169,8 @@ class TerminalRuntimeRegistryImpl {
*
* Idempotent: no-op if already connected/connecting to the same URL.
*/
connect(terminalId: string, wsUrl: string) {
const entry = this.entries.get(terminalId);
connect(terminalId: string, wsUrl: string, instanceId = terminalId) {
const entry = this.getEntry(terminalId, instanceId);
if (!entry?.runtime) return;
connect(entry.transport, entry.runtime.terminal, wsUrl);
}
Expand All @@ -112,8 +187,8 @@ class TerminalRuntimeRegistryImpl {
* swap), and `"closed"` (previously live and mid-auto-reconnect — swap
* the URL so the reconnect targets the new endpoint).
*/
reconnect(terminalId: string, wsUrl: string) {
const entry = this.entries.get(terminalId);
reconnect(terminalId: string, wsUrl: string, instanceId = terminalId) {
const entry = this.getEntry(terminalId, instanceId);
if (!entry?.runtime) return;
if (entry.transport.connectionState === "disconnected") return;
if (entry.transport.currentUrl === wsUrl) return;
Expand All @@ -124,8 +199,12 @@ class TerminalRuntimeRegistryImpl {
* Set link handler callbacks for a terminal. Safe to call before or after
* mount(). If the runtime already exists, link providers are re-registered.
*/
setLinkHandlers(terminalId: string, handlers: TerminalLinkHandlers) {
const entry = this.getOrCreateEntry(terminalId);
setLinkHandlers(
terminalId: string,
handlers: TerminalLinkHandlers,
instanceId = terminalId,
) {
const entry = this.getOrCreateEntry(terminalId, instanceId);
if (entry.linkManager) {
entry.linkManager.setHandlers(handlers);
} else {
Expand All @@ -138,15 +217,19 @@ class TerminalRuntimeRegistryImpl {
* transport stay alive; DOM is moved off the React-controlled tree so
* it survives the parent unmount without re-entering xterm.open().
*/
detach(terminalId: string) {
const entry = this.entries.get(terminalId);
detach(terminalId: string, instanceId = terminalId) {
const entry = this.getEntry(terminalId, instanceId);
if (!entry?.runtime) return;

detachFromContainer(entry.runtime);
}

updateAppearance(terminalId: string, appearance: TerminalAppearance) {
const entry = this.entries.get(terminalId);
updateAppearance(
terminalId: string,
appearance: TerminalAppearance,
instanceId = terminalId,
) {
const entry = this.getEntry(terminalId, instanceId);
if (!entry?.runtime) return;

const prevCols = entry.runtime.terminal.cols;
Expand All @@ -160,89 +243,129 @@ class TerminalRuntimeRegistryImpl {
}
}

dispose(terminalId: string) {
const entry = this.entries.get(terminalId);
if (!entry) return;

private disposeEntry(
entry: RegistryEntry,
options: { clearPersistedState?: boolean } = {},
) {
entry.linkManager?.dispose();

sendDispose(entry.transport);
disposeTransport(entry.transport);
if (entry.runtime) disposeRuntime(entry.runtime);
if (entry.runtime) {
disposeRuntime(entry.runtime, options);
}
this.deleteEntry(entry);
}

this.entries.delete(terminalId);
/**
* Release the renderer-side terminal runtime only. This detaches the xterm
* view and closes the WebSocket, but it does not tell host-service to kill
* the underlying PTY. Use this for pane/sidebar lifecycle cleanup.
*/
release(terminalId: string, instanceId?: string) {
const entries = instanceId
? [this.getEntry(terminalId, instanceId)].filter(
(entry): entry is RegistryEntry => Boolean(entry),
)
: this.getEntries(terminalId);
for (const entry of entries) {
this.disposeEntry(entry, { clearPersistedState: false });
}
}

getSelection(terminalId: string): string {
const entry = this.entries.get(terminalId);
/**
* Kill the host-service terminal session and remove all renderer-side state.
* This is destructive and should only be used from explicit kill actions.
*/
dispose(terminalId: string) {
for (const entry of this.getEntries(terminalId)) {
sendDispose(entry.transport);
this.disposeEntry(entry);
}
}

getSelection(terminalId: string, instanceId?: string): string {
const entry = this.getEntry(terminalId, instanceId);
return entry?.runtime?.terminal.getSelection() ?? "";
}

clear(terminalId: string): void {
const entry = this.entries.get(terminalId);
clear(terminalId: string, instanceId?: string): void {
const entry = this.getEntry(terminalId, instanceId);
entry?.runtime?.terminal.clear();
}

scrollToBottom(terminalId: string): void {
const entry = this.entries.get(terminalId);
scrollToBottom(terminalId: string, instanceId?: string): void {
const entry = this.getEntry(terminalId, instanceId);
entry?.runtime?.terminal.scrollToBottom();
}

paste(terminalId: string, text: string): void {
const entry = this.entries.get(terminalId);
paste(terminalId: string, text: string, instanceId?: string): void {
const entry = this.getEntry(terminalId, instanceId);
entry?.runtime?.terminal.paste(text);
}

/** Send raw input to the terminal via the WebSocket transport (bypasses xterm). */
writeInput(terminalId: string, data: string): void {
const entry = this.entries.get(terminalId);
writeInput(terminalId: string, data: string, instanceId?: string): void {
const entry = this.getEntry(terminalId, instanceId);
if (!entry) return;
sendInput(entry.transport, data);
}

findNext(terminalId: string, query: string): boolean {
const entry = this.entries.get(terminalId);
findNext(terminalId: string, query: string, instanceId?: string): boolean {
const entry = this.getEntry(terminalId, instanceId);
return entry?.runtime?.searchAddon?.findNext(query) ?? false;
}

findPrevious(terminalId: string, query: string): boolean {
const entry = this.entries.get(terminalId);
findPrevious(
terminalId: string,
query: string,
instanceId?: string,
): boolean {
const entry = this.getEntry(terminalId, instanceId);
return entry?.runtime?.searchAddon?.findPrevious(query) ?? false;
}

clearSearch(terminalId: string): void {
const entry = this.entries.get(terminalId);
clearSearch(terminalId: string, instanceId?: string): void {
const entry = this.getEntry(terminalId, instanceId);
entry?.runtime?.searchAddon?.clearDecorations();
}

getTerminal(terminalId: string) {
return this.entries.get(terminalId)?.runtime?.terminal ?? null;
getTerminal(terminalId: string, instanceId?: string) {
return this.getEntry(terminalId, instanceId)?.runtime?.terminal ?? null;
}

getSearchAddon(terminalId: string): SearchAddon | null {
return this.entries.get(terminalId)?.runtime?.searchAddon ?? null;
getSearchAddon(terminalId: string, instanceId?: string): SearchAddon | null {
return this.getEntry(terminalId, instanceId)?.runtime?.searchAddon ?? null;
}

getProgressAddon(terminalId: string): ProgressAddon | null {
return this.entries.get(terminalId)?.runtime?.progressAddon ?? null;
getProgressAddon(
terminalId: string,
instanceId?: string,
): ProgressAddon | null {
return (
this.getEntry(terminalId, instanceId)?.runtime?.progressAddon ?? null
);
}

getAllTerminalIds(): Set<string> {
return new Set(this.entries.keys());
return new Set(this.entryKeysByTerminalId.keys());
}

has(terminalId: string): boolean {
return this.entries.has(terminalId);
return this.entryKeysByTerminalId.has(terminalId);
}

getConnectionState(terminalId: string): ConnectionState {
getConnectionState(terminalId: string, instanceId?: string): ConnectionState {
return (
this.entries.get(terminalId)?.transport.connectionState ?? "disconnected"
this.getEntry(terminalId, instanceId)?.transport.connectionState ??
"disconnected"
);
}

onStateChange(terminalId: string, listener: () => void): () => void {
const entry = this.getOrCreateEntry(terminalId);
onStateChange(
terminalId: string,
listener: () => void,
instanceId = terminalId,
): () => void {
const entry = this.getOrCreateEntry(terminalId, instanceId);
entry.transport.stateListeners.add(listener);
return () => {
entry.transport.stateListeners.delete(listener);
Expand Down
Loading
Loading