feat: stream worktree setup progress and terminal subscriptions

This commit is contained in:
Mohamed Boudra
2026-02-16 08:09:27 +07:00
parent b980abb7af
commit 037a6a15a4
29 changed files with 1449 additions and 102 deletions
@@ -201,4 +201,52 @@ describe("TerminalManager", () => {
expect(manager.getTerminal(homeId)).toBeUndefined();
});
});
describe("subscribeTerminalsChanged", () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalNames: string[] }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalNames: input.terminals.map((terminal) => terminal.name),
});
});
await manager.getTerminals("/tmp");
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1"],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1", "Dev Server"],
});
unsubscribe();
});
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalCount: input.terminals.length,
});
});
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalCount: 0,
});
unsubscribe();
});
});
});
@@ -1,5 +1,18 @@
import { createTerminal, type TerminalSession } from "./terminal.js";
export interface TerminalListItem {
id: string;
name: string;
cwd: string;
}
export interface TerminalsChangedEvent {
cwd: string;
terminals: TerminalListItem[];
}
export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
export interface TerminalManager {
getTerminals(cwd: string): Promise<TerminalSession[]>;
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
@@ -7,12 +20,14 @@ export interface TerminalManager {
killTerminal(id: string): void;
listDirectories(): string[];
killAll(): void;
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
}
export function createTerminalManager(): TerminalManager {
const terminalsByCwd = new Map<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => void>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
@@ -48,6 +63,8 @@ export function createTerminalManager(): TerminalManager {
if (options.kill) {
session.kill();
}
emitTerminalsChanged({ cwd: session.cwd });
}
function registerSession(session: TerminalSession): TerminalSession {
@@ -59,6 +76,36 @@ export function createTerminalManager(): TerminalManager {
return session;
}
function toTerminalListItem(input: { session: TerminalSession }): TerminalListItem {
return {
id: input.session.id,
name: input.session.name,
cwd: input.session.cwd,
};
}
function emitTerminalsChanged(input: { cwd: string }): void {
if (terminalsChangedListeners.size === 0) {
return;
}
const terminals = (terminalsByCwd.get(input.cwd) ?? []).map((session) =>
toTerminalListItem({ session })
);
const event: TerminalsChangedEvent = {
cwd: input.cwd,
terminals,
};
for (const listener of terminalsChangedListeners) {
try {
listener(event);
} catch {
// no-op
}
}
}
return {
async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd);
@@ -70,6 +117,7 @@ export function createTerminalManager(): TerminalManager {
);
terminals = [session];
terminalsByCwd.set(cwd, terminals);
emitTerminalsChanged({ cwd });
}
return terminals;
},
@@ -88,6 +136,7 @@ export function createTerminalManager(): TerminalManager {
terminals.push(session);
terminalsByCwd.set(options.cwd, terminals);
emitTerminalsChanged({ cwd: options.cwd });
return session;
},
@@ -109,5 +158,12 @@ export function createTerminalManager(): TerminalManager {
removeSessionById(id, { kill: true });
}
},
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
terminalsChangedListeners.add(listener);
return () => {
terminalsChangedListeners.delete(listener);
};
},
};
}