fix(server): keep worktree terminals out of parent workspaces

This commit is contained in:
Mohamed Boudra
2026-06-12 08:13:45 +07:00
parent 62d9e656d7
commit 1b6f9300a7
3 changed files with 128 additions and 2 deletions
+6
View File
@@ -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),
});
@@ -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",
},
},
]);
});
});
@@ -65,6 +65,7 @@ export interface TerminalSessionControllerOptions {
hasBinaryChannel: () => boolean;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
sessionLogger: pino.Logger;
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
// 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<readonly string[]>;
private readonly clientSupportsWrapReflow: () => boolean;
private readonly subscribedDirectories = new Set<string>();
@@ -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<TerminalSession[]> {
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<void> {
if (!this.terminalManager) {
this.emit({