feat: sortable workspace tabs, tab context menus, and terminal auto-create removal
- Add drag-and-drop tab reordering with persistent tab order - Add right-click context menu with copy agent ID, copy resume command, close to right - Switch workspace route encoding from percent-encoding to base64url - Remove terminal auto-creation on directory listing (explicit create only) - Add swipe gestures on terminal emulator for mobile sidebar navigation - Highlight active workspace row in sidebar
This commit is contained in:
@@ -56,16 +56,14 @@ const shouldRun = !process.env.CI;
|
||||
}, 60000);
|
||||
|
||||
test(
|
||||
"lists terminals for a directory (auto-creates first)",
|
||||
"lists terminals for a directory (empty when none exist)",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
const result = await ctx.client.listTerminals(cwd);
|
||||
|
||||
expect(result.cwd).toBe(cwd);
|
||||
expect(result.terminals).toHaveLength(1);
|
||||
expect(result.terminals[0].name).toBe("Terminal 1");
|
||||
expect(result.terminals[0].id).toBeTruthy();
|
||||
expect(result.terminals).toHaveLength(0);
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
@@ -73,14 +71,11 @@ const shouldRun = !process.env.CI;
|
||||
);
|
||||
|
||||
test(
|
||||
"creates additional terminal with custom name",
|
||||
"creates terminal with custom name",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// First call auto-creates Terminal 1
|
||||
await ctx.client.listTerminals(cwd);
|
||||
|
||||
// Create a second terminal with custom name
|
||||
// Create a terminal with custom name
|
||||
const result = await ctx.client.createTerminal(cwd, "Dev Server");
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
@@ -88,9 +83,9 @@ const shouldRun = !process.env.CI;
|
||||
expect(result.terminal!.name).toBe("Dev Server");
|
||||
expect(result.terminal!.cwd).toBe(cwd);
|
||||
|
||||
// Verify list now shows two terminals
|
||||
// Verify list now shows one terminal
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
expect(list.terminals).toHaveLength(2);
|
||||
expect(list.terminals).toHaveLength(1);
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
@@ -101,7 +96,6 @@ const shouldRun = !process.env.CI;
|
||||
"emits terminals_changed for subscribed cwd when terminals are created",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
await ctx.client.listTerminals(cwd);
|
||||
|
||||
const snapshots: Array<{ cwd: string; names: string[] }> = [];
|
||||
const unsubscribe = ctx.client.on("terminals_changed", (message) => {
|
||||
@@ -138,9 +132,8 @@ const shouldRun = !process.env.CI;
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Get terminal (auto-creates)
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
// Subscribe to terminal
|
||||
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
|
||||
@@ -166,9 +159,8 @@ const shouldRun = !process.env.CI;
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Get terminal
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
// Subscribe to terminal
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
@@ -251,9 +243,8 @@ const shouldRun = !process.env.CI;
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Get terminal
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
// Subscribe to terminal
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
@@ -318,8 +309,8 @@ const shouldRun = !process.env.CI;
|
||||
"streams terminal output over binary mux and supports modifier keys",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
||||
expect(attach.error).toBeNull();
|
||||
@@ -357,8 +348,8 @@ const shouldRun = !process.env.CI;
|
||||
"emits terminal_stream_exit and removes terminal when shell exits",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
||||
expect(attach.error).toBeNull();
|
||||
@@ -395,8 +386,8 @@ const shouldRun = !process.env.CI;
|
||||
"replays detached terminal output from resume offset (scrollback continuity)",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
||||
expect(attach.error).toBeNull();
|
||||
@@ -448,8 +439,8 @@ const shouldRun = !process.env.CI;
|
||||
"applies stream backpressure window until client ack advances",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${ctx.daemon.port}/ws`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -646,8 +637,8 @@ const shouldRun = !process.env.CI;
|
||||
"measures local terminal round-trip latency via daemon client stream",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
const terminalId = list.terminals[0].id;
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
const attach = await ctx.client.attachTerminalStream(terminalId);
|
||||
expect(attach.error).toBeNull();
|
||||
|
||||
@@ -6428,19 +6428,12 @@ export class Session {
|
||||
return
|
||||
}
|
||||
|
||||
const hadDirectoryBeforeSubscribe = this.terminalManager.listDirectories().includes(cwd)
|
||||
|
||||
try {
|
||||
const terminals = await this.terminalManager.getTerminals(cwd)
|
||||
for (const terminal of terminals) {
|
||||
this.ensureTerminalExitSubscription(terminal)
|
||||
}
|
||||
|
||||
// New directories auto-create Terminal 1, which already emits through
|
||||
// terminal-manager change listeners.
|
||||
if (!hadDirectoryBeforeSubscribe) {
|
||||
return
|
||||
}
|
||||
if (!this.subscribedTerminalDirectories.has(cwd)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,22 +50,22 @@ describe("TerminalManager", () => {
|
||||
});
|
||||
|
||||
describe("getTerminals", () => {
|
||||
it("auto-creates first terminal for new cwd", async () => {
|
||||
it("returns empty list for new cwd", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
|
||||
expect(terminals.length).toBe(1);
|
||||
expect(terminals[0].name).toBe("Terminal 1");
|
||||
expect(terminals[0].cwd).toBe("/tmp");
|
||||
expect(terminals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns existing terminals on subsequent calls", async () => {
|
||||
manager = createTerminalManager();
|
||||
const created = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const first = await manager.getTerminals("/tmp");
|
||||
const second = await manager.getTerminals("/tmp");
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first.length).toBe(1);
|
||||
expect(first[0].id).toBe(created.id);
|
||||
expect(second.length).toBe(1);
|
||||
});
|
||||
|
||||
it("throws for relative paths", async () => {
|
||||
@@ -75,8 +75,8 @@ describe("TerminalManager", () => {
|
||||
|
||||
it("creates separate terminals for different cwds", async () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
||||
const homeTerminals = await manager.getTerminals("/home");
|
||||
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
|
||||
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
|
||||
|
||||
expect(tmpTerminals.length).toBe(1);
|
||||
expect(homeTerminals.length).toBe(1);
|
||||
@@ -87,7 +87,7 @@ describe("TerminalManager", () => {
|
||||
describe("createTerminal", () => {
|
||||
it("creates additional terminal with auto-incrementing name", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.getTerminals("/tmp");
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||
|
||||
expect(second.name).toBe("Terminal 2");
|
||||
@@ -175,10 +175,10 @@ describe("TerminalManager", () => {
|
||||
describe("getTerminal", () => {
|
||||
it("returns terminal by id", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const found = manager.getTerminal(terminals[0].id);
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const found = manager.getTerminal(session.id);
|
||||
|
||||
expect(found).toBe(terminals[0]);
|
||||
expect(found).toBe(session);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown id", () => {
|
||||
@@ -192,28 +192,27 @@ describe("TerminalManager", () => {
|
||||
describe("killTerminal", () => {
|
||||
it("removes terminal from manager", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const id = terminals[0].id;
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const id = session.id;
|
||||
|
||||
manager.killTerminal(id);
|
||||
|
||||
expect(manager.getTerminal(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps cwd entry when last terminal is killed (but does not auto-recreate)", async () => {
|
||||
it("removes cwd entry when last terminal is killed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const created = await manager.createTerminal({ cwd: "/tmp" });
|
||||
manager.killTerminal(created.id);
|
||||
|
||||
manager.killTerminal(terminals[0].id);
|
||||
|
||||
expect(manager.listDirectories()).toContain("/tmp");
|
||||
const remaining = await manager.getTerminals("/tmp");
|
||||
expect(remaining).toHaveLength(0);
|
||||
expect(manager.listDirectories()).not.toContain("/tmp");
|
||||
});
|
||||
|
||||
it("keeps cwd entry when other terminals remain", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.getTerminals("/tmp");
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
@@ -232,9 +231,9 @@ describe("TerminalManager", () => {
|
||||
|
||||
it("auto-removes terminal when shell exits", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const exitedId = terminals[0].id;
|
||||
terminals[0].kill();
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const exitedId = session.id;
|
||||
session.kill();
|
||||
|
||||
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
||||
|
||||
@@ -251,10 +250,10 @@ describe("TerminalManager", () => {
|
||||
expect(manager.listDirectories()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all cwds that have ever had terminals", async () => {
|
||||
it("returns all cwds with active terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.getTerminals("/tmp");
|
||||
await manager.getTerminals("/home");
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
await manager.createTerminal({ cwd: "/home" });
|
||||
|
||||
const dirs = manager.listDirectories();
|
||||
expect(dirs).toContain("/tmp");
|
||||
@@ -266,10 +265,10 @@ describe("TerminalManager", () => {
|
||||
describe("killAll", () => {
|
||||
it("kills all terminals and clears state", async () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
||||
const homeTerminals = await manager.getTerminals("/home");
|
||||
const tmpId = tmpTerminals[0].id;
|
||||
const homeId = homeTerminals[0].id;
|
||||
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
|
||||
const homeSession = await manager.createTerminal({ cwd: "/home" });
|
||||
const tmpId = tmpSession.id;
|
||||
const homeId = homeSession.id;
|
||||
|
||||
manager.killAll();
|
||||
|
||||
@@ -290,7 +289,7 @@ describe("TerminalManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await manager.getTerminals("/tmp");
|
||||
await manager.createTerminal({ cwd: "/tmp" });
|
||||
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
@@ -315,8 +314,8 @@ describe("TerminalManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
manager.killTerminal(terminals[0].id);
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
manager.killTerminal(session.id);
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
|
||||
@@ -35,7 +35,6 @@ export function createTerminalManager(): TerminalManager {
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
const knownDirectories = new Set<string>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
@@ -135,28 +134,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||
assertAbsolutePath(cwd);
|
||||
|
||||
const terminals = terminalsByCwd.get(cwd);
|
||||
if (terminals && terminals.length > 0) {
|
||||
return terminals;
|
||||
}
|
||||
|
||||
if (!knownDirectories.has(cwd)) {
|
||||
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
|
||||
const session = registerSession(
|
||||
await createTerminal({
|
||||
cwd,
|
||||
name: "Terminal 1",
|
||||
...(inheritedEnv ? { env: inheritedEnv } : {}),
|
||||
})
|
||||
);
|
||||
const created = [session];
|
||||
terminalsByCwd.set(cwd, created);
|
||||
knownDirectories.add(cwd);
|
||||
emitTerminalsChanged({ cwd });
|
||||
return created;
|
||||
}
|
||||
|
||||
return [];
|
||||
return terminalsByCwd.get(cwd) ?? [];
|
||||
},
|
||||
|
||||
async createTerminal(options: {
|
||||
@@ -166,7 +144,6 @@ export function createTerminalManager(): TerminalManager {
|
||||
}): Promise<TerminalSession> {
|
||||
assertAbsolutePath(options.cwd);
|
||||
|
||||
knownDirectories.add(options.cwd);
|
||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
||||
@@ -203,14 +180,13 @@ export function createTerminalManager(): TerminalManager {
|
||||
},
|
||||
|
||||
listDirectories(): string[] {
|
||||
return Array.from(knownDirectories);
|
||||
return Array.from(terminalsByCwd.keys());
|
||||
},
|
||||
|
||||
killAll(): void {
|
||||
for (const id of Array.from(terminalsById.keys())) {
|
||||
removeSessionById(id, { kill: true });
|
||||
}
|
||||
knownDirectories.clear();
|
||||
},
|
||||
|
||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
||||
|
||||
Reference in New Issue
Block a user