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,
|
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
||||||
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
|
listTerminalWorkspaceRoots: async () => {
|
||||||
|
const workspaces = await this.workspaceRegistry.list();
|
||||||
|
return workspaces
|
||||||
|
.filter((workspace) => !workspace.archivedAt)
|
||||||
|
.map((workspace) => workspace.cwd);
|
||||||
|
},
|
||||||
clientSupportsWrapReflow: () =>
|
clientSupportsWrapReflow: () =>
|
||||||
this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
|
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;
|
hasBinaryChannel: () => boolean;
|
||||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||||
sessionLogger: pino.Logger;
|
sessionLogger: pino.Logger;
|
||||||
|
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
|
||||||
// Whether the connected client can reflow restored snapshots. When true the
|
// Whether the connected client can reflow restored snapshots. When true the
|
||||||
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
|
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
|
||||||
// so old (strict-schema) clients still parse the snapshot.
|
// so old (strict-schema) clients still parse the snapshot.
|
||||||
@@ -108,6 +109,7 @@ export class TerminalSessionController {
|
|||||||
private readonly hasBinaryChannel: () => boolean;
|
private readonly hasBinaryChannel: () => boolean;
|
||||||
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||||
private readonly sessionLogger: pino.Logger;
|
private readonly sessionLogger: pino.Logger;
|
||||||
|
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
|
||||||
private readonly clientSupportsWrapReflow: () => boolean;
|
private readonly clientSupportsWrapReflow: () => boolean;
|
||||||
|
|
||||||
private readonly subscribedDirectories = new Set<string>();
|
private readonly subscribedDirectories = new Set<string>();
|
||||||
@@ -124,6 +126,7 @@ export class TerminalSessionController {
|
|||||||
this.hasBinaryChannel = options.hasBinaryChannel;
|
this.hasBinaryChannel = options.hasBinaryChannel;
|
||||||
this.isPathWithinRoot = options.isPathWithinRoot;
|
this.isPathWithinRoot = options.isPathWithinRoot;
|
||||||
this.sessionLogger = options.sessionLogger;
|
this.sessionLogger = options.sessionLogger;
|
||||||
|
this.listTerminalWorkspaceRoots = options.listTerminalWorkspaceRoots ?? (async () => []);
|
||||||
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
|
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +330,7 @@ export class TerminalSessionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const terminals = await this.terminalManager.getTerminals(cwd);
|
const terminals = await this.getTerminalsForWorkspaceRoot(cwd);
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
this.ensureExitSubscription(terminal);
|
this.ensureExitSubscription(terminal);
|
||||||
}
|
}
|
||||||
@@ -359,7 +362,7 @@ export class TerminalSessionController {
|
|||||||
try {
|
try {
|
||||||
const terminals =
|
const terminals =
|
||||||
typeof msg.cwd === "string"
|
typeof msg.cwd === "string"
|
||||||
? await this.terminalManager.getTerminals(msg.cwd)
|
? await this.getTerminalsForWorkspaceRoot(msg.cwd)
|
||||||
: await this.getAllTerminalSessions();
|
: await this.getAllTerminalSessions();
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
this.ensureExitSubscription(terminal);
|
this.ensureExitSubscription(terminal);
|
||||||
@@ -397,6 +400,56 @@ export class TerminalSessionController {
|
|||||||
return terminalsByDirectory.flat();
|
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> {
|
private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise<void> {
|
||||||
if (!this.terminalManager) {
|
if (!this.terminalManager) {
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
Reference in New Issue
Block a user