fix(server): keep worktree terminals out of parent workspaces
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user