feat: update sidebar agent workflow and host filter UI
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
timeoutMs: number,
|
||||
intervalMs = 25
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||
}
|
||||
|
||||
describe("TerminalManager", () => {
|
||||
let manager: TerminalManager;
|
||||
|
||||
@@ -136,6 +151,21 @@ describe("TerminalManager", () => {
|
||||
manager = createTerminalManager();
|
||||
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
|
||||
});
|
||||
|
||||
it("auto-removes terminal when shell exits", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const exitedId = terminals[0].id;
|
||||
terminals[0].send({ type: "input", data: "\u0004" });
|
||||
|
||||
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
||||
|
||||
expect(manager.getTerminal(exitedId)).toBeUndefined();
|
||||
|
||||
const remaining = await manager.getTerminals("/tmp");
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).not.toBe(exitedId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listDirectories", () => {
|
||||
@@ -161,12 +191,14 @@ describe("TerminalManager", () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
||||
const homeTerminals = await manager.getTerminals("/home");
|
||||
const tmpId = tmpTerminals[0].id;
|
||||
const homeId = homeTerminals[0].id;
|
||||
|
||||
manager.killAll();
|
||||
|
||||
expect(manager.listDirectories()).toEqual([]);
|
||||
expect(manager.getTerminal(tmpTerminals[0].id)).toBeUndefined();
|
||||
expect(manager.getTerminal(homeTerminals[0].id)).toBeUndefined();
|
||||
expect(manager.getTerminal(tmpId)).toBeUndefined();
|
||||
expect(manager.getTerminal(homeId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TerminalManager {
|
||||
export function createTerminalManager(): TerminalManager {
|
||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
@@ -19,16 +20,56 @@ export function createTerminalManager(): TerminalManager {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSessionById(id: string, options: { kill: boolean }): void {
|
||||
const session = terminalsById.get(id);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeExit = terminalExitUnsubscribeById.get(id);
|
||||
if (unsubscribeExit) {
|
||||
unsubscribeExit();
|
||||
terminalExitUnsubscribeById.delete(id);
|
||||
}
|
||||
|
||||
terminalsById.delete(id);
|
||||
|
||||
const terminals = terminalsByCwd.get(session.cwd);
|
||||
if (terminals) {
|
||||
const index = terminals.findIndex((terminal) => terminal.id === id);
|
||||
if (index !== -1) {
|
||||
terminals.splice(index, 1);
|
||||
}
|
||||
if (terminals.length === 0) {
|
||||
terminalsByCwd.delete(session.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.kill) {
|
||||
session.kill();
|
||||
}
|
||||
}
|
||||
|
||||
function registerSession(session: TerminalSession): TerminalSession {
|
||||
terminalsById.set(session.id, session);
|
||||
const unsubscribeExit = session.onExit(() => {
|
||||
removeSessionById(session.id, { kill: false });
|
||||
});
|
||||
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
|
||||
return session;
|
||||
}
|
||||
|
||||
return {
|
||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||
assertAbsolutePath(cwd);
|
||||
|
||||
let terminals = terminalsByCwd.get(cwd);
|
||||
if (!terminals || terminals.length === 0) {
|
||||
const session = await createTerminal({ cwd, name: "Terminal 1" });
|
||||
const session = registerSession(
|
||||
await createTerminal({ cwd, name: "Terminal 1" })
|
||||
);
|
||||
terminals = [session];
|
||||
terminalsByCwd.set(cwd, terminals);
|
||||
terminalsById.set(session.id, session);
|
||||
}
|
||||
return terminals;
|
||||
},
|
||||
@@ -38,14 +79,15 @@ export function createTerminalManager(): TerminalManager {
|
||||
|
||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||
const session = await createTerminal({
|
||||
cwd: options.cwd,
|
||||
name: options.name ?? defaultName,
|
||||
});
|
||||
const session = registerSession(
|
||||
await createTerminal({
|
||||
cwd: options.cwd,
|
||||
name: options.name ?? defaultName,
|
||||
})
|
||||
);
|
||||
|
||||
terminals.push(session);
|
||||
terminalsByCwd.set(options.cwd, terminals);
|
||||
terminalsById.set(session.id, session);
|
||||
|
||||
return session;
|
||||
},
|
||||
@@ -55,22 +97,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
},
|
||||
|
||||
killTerminal(id: string): void {
|
||||
const session = terminalsById.get(id);
|
||||
if (!session) return;
|
||||
|
||||
session.kill();
|
||||
terminalsById.delete(id);
|
||||
|
||||
const terminals = terminalsByCwd.get(session.cwd);
|
||||
if (terminals) {
|
||||
const index = terminals.indexOf(session);
|
||||
if (index !== -1) {
|
||||
terminals.splice(index, 1);
|
||||
}
|
||||
if (terminals.length === 0) {
|
||||
terminalsByCwd.delete(session.cwd);
|
||||
}
|
||||
}
|
||||
removeSessionById(id, { kill: true });
|
||||
},
|
||||
|
||||
listDirectories(): string[] {
|
||||
@@ -78,11 +105,9 @@ export function createTerminalManager(): TerminalManager {
|
||||
},
|
||||
|
||||
killAll(): void {
|
||||
for (const session of terminalsById.values()) {
|
||||
session.kill();
|
||||
for (const id of Array.from(terminalsById.keys())) {
|
||||
removeSessionById(id, { kill: true });
|
||||
}
|
||||
terminalsByCwd.clear();
|
||||
terminalsById.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface TerminalSession {
|
||||
cwd: string;
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||
onExit(listener: () => void): () => void;
|
||||
subscribeRaw(
|
||||
listener: (chunk: TerminalRawChunk) => void,
|
||||
options?: { fromOffset?: number }
|
||||
@@ -190,6 +191,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
const id = randomUUID();
|
||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
|
||||
const exitListeners = new Set<() => void>();
|
||||
const rawOutputChunks: Array<{
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
@@ -199,6 +201,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
let rawBufferBytes = 0;
|
||||
let outputOffset = 0;
|
||||
let killed = false;
|
||||
let disposed = false;
|
||||
let exitEmitted = false;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
const terminal = new Terminal({
|
||||
@@ -221,6 +225,32 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
},
|
||||
});
|
||||
|
||||
function emitExit(): void {
|
||||
if (exitEmitted) {
|
||||
return;
|
||||
}
|
||||
exitEmitted = true;
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
exitListeners.clear();
|
||||
}
|
||||
|
||||
function disposeResources(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
terminal.dispose();
|
||||
listeners.clear();
|
||||
rawListeners.clear();
|
||||
exitListeners.clear();
|
||||
}
|
||||
|
||||
function appendRawOutputChunk(data: string): void {
|
||||
const chunkBytes = Buffer.byteLength(data);
|
||||
if (chunkBytes <= 0) {
|
||||
@@ -292,6 +322,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
|
||||
ptyProcess.onExit(() => {
|
||||
killed = true;
|
||||
emitExit();
|
||||
disposeResources();
|
||||
});
|
||||
|
||||
function getState(): TerminalState {
|
||||
@@ -340,6 +372,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
function onExit(listener: () => void): () => void {
|
||||
if (killed) {
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
exitListeners.add(listener);
|
||||
return () => {
|
||||
exitListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeRaw(
|
||||
listener: (chunk: TerminalRawChunk) => void,
|
||||
options?: { fromOffset?: number }
|
||||
@@ -380,11 +430,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
}
|
||||
|
||||
function kill(): void {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
ptyProcess.kill();
|
||||
terminal.dispose();
|
||||
rawListeners.clear();
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
ptyProcess.kill();
|
||||
emitExit();
|
||||
}
|
||||
disposeResources();
|
||||
}
|
||||
|
||||
// Small delay to let shell initialize
|
||||
@@ -396,6 +447,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
cwd,
|
||||
send,
|
||||
subscribe,
|
||||
onExit,
|
||||
subscribeRaw,
|
||||
getOutputOffset,
|
||||
getState,
|
||||
|
||||
Reference in New Issue
Block a user