diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index acafe7471..b7eee2636 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -909,6 +909,12 @@ export class Session { hasBinaryChannel: () => this.onBinaryMessage !== null, isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, + listTerminalWorkspaceRoots: async () => { + const workspaces = await this.workspaceRegistry.list(); + return workspaces + .filter((workspace) => !workspace.archivedAt) + .map((workspace) => workspace.cwd); + }, clientSupportsWrapReflow: () => this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot), }); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index b22288337..ccca2f7e2 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -319,4 +319,71 @@ describe("terminal-session-controller subdirectory aggregation", () => { }, ]); }); + + test("keeps nested workspace terminals out of the parent workspace terminal list", async () => { + const rootCwd = "/work/repo"; + const worktreeCwd = "/work/repo/.dev/paseo-home/worktrees/hash/feature-a"; + const rootTerminal = listSession({ id: "root-term", name: "Terminal 1", cwd: rootCwd }); + const worktreeTerminal = listSession({ + id: "worktree-term", + name: "Feature", + cwd: worktreeCwd, + }); + const terminalManager: TerminalManager = { + getTerminals: vi.fn(async (cwd: string) => + cwd === rootCwd ? [rootTerminal, worktreeTerminal] : [worktreeTerminal], + ), + createTerminal: vi.fn(), + registerCwdEnv: vi.fn(), + getTerminal: vi.fn(), + getTerminalState: vi.fn(), + setTerminalTitle: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + listDirectories: vi.fn(() => [rootCwd, worktreeCwd]), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn(() => vi.fn()), + }; + const outboundMessages: SessionOutboundMessage[] = []; + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + listTerminalWorkspaceRoots: async () => [rootCwd, worktreeCwd], + }); + + await controller.dispatch({ + type: "list_terminals_request", + cwd: rootCwd, + requestId: "req-root", + }); + await controller.dispatch({ + type: "list_terminals_request", + cwd: worktreeCwd, + requestId: "req-worktree", + }); + + expect(outboundMessages).toEqual([ + { + type: "list_terminals_response", + payload: { + cwd: rootCwd, + terminals: [{ id: "root-term", name: "Terminal 1" }], + requestId: "req-root", + }, + }, + { + type: "list_terminals_response", + payload: { + cwd: worktreeCwd, + terminals: [{ id: "worktree-term", name: "Feature" }], + requestId: "req-worktree", + }, + }, + ]); + }); }); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 678b2c6c2..88f876232 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -65,6 +65,7 @@ export interface TerminalSessionControllerOptions { hasBinaryChannel: () => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; + listTerminalWorkspaceRoots?: () => Promise; // Whether the connected client can reflow restored snapshots. When true the // daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them // so old (strict-schema) clients still parse the snapshot. @@ -108,6 +109,7 @@ export class TerminalSessionController { private readonly hasBinaryChannel: () => boolean; private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; private readonly sessionLogger: pino.Logger; + private readonly listTerminalWorkspaceRoots: () => Promise; private readonly clientSupportsWrapReflow: () => boolean; private readonly subscribedDirectories = new Set(); @@ -124,6 +126,7 @@ export class TerminalSessionController { this.hasBinaryChannel = options.hasBinaryChannel; this.isPathWithinRoot = options.isPathWithinRoot; this.sessionLogger = options.sessionLogger; + this.listTerminalWorkspaceRoots = options.listTerminalWorkspaceRoots ?? (async () => []); this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false); } @@ -327,7 +330,7 @@ export class TerminalSessionController { return; } try { - const terminals = await this.terminalManager.getTerminals(cwd); + const terminals = await this.getTerminalsForWorkspaceRoot(cwd); for (const terminal of terminals) { this.ensureExitSubscription(terminal); } @@ -359,7 +362,7 @@ export class TerminalSessionController { try { const terminals = typeof msg.cwd === "string" - ? await this.terminalManager.getTerminals(msg.cwd) + ? await this.getTerminalsForWorkspaceRoot(msg.cwd) : await this.getAllTerminalSessions(); for (const terminal of terminals) { this.ensureExitSubscription(terminal); @@ -397,6 +400,56 @@ export class TerminalSessionController { return terminalsByDirectory.flat(); } + private async getTerminalsForWorkspaceRoot(cwd: string): Promise { + if (!this.terminalManager) { + return []; + } + + const terminals = await this.terminalManager.getTerminals(cwd); + const workspaceRoots = await this.listTerminalWorkspaceRoots(); + if (workspaceRoots.length === 0) { + return terminals; + } + + return terminals.filter((terminal) => + this.terminalBelongsToRoot(cwd, terminal.cwd, workspaceRoots), + ); + } + + private terminalBelongsToRoot( + rootCwd: string, + terminalCwd: string, + workspaceRoots: readonly string[], + ): boolean { + const ownerRoot = this.resolveTerminalOwnerRoot(terminalCwd, workspaceRoots); + if (!ownerRoot) { + return this.isPathWithinRoot(rootCwd, terminalCwd); + } + return this.isSamePath(rootCwd, ownerRoot); + } + + private resolveTerminalOwnerRoot( + terminalCwd: string, + workspaceRoots: readonly string[], + ): string | null { + let ownerRoot: string | null = null; + for (const workspaceRoot of workspaceRoots) { + if (!this.isPathWithinRoot(workspaceRoot, terminalCwd)) { + continue; + } + if (!ownerRoot || workspaceRoot.length > ownerRoot.length) { + ownerRoot = workspaceRoot; + } + } + return ownerRoot; + } + + private isSamePath(firstPath: string, secondPath: string): boolean { + return ( + this.isPathWithinRoot(firstPath, secondPath) && this.isPathWithinRoot(secondPath, firstPath) + ); + } + private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise { if (!this.terminalManager) { this.emit({