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
@@ -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({