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:
Mohamed Boudra
2026-03-02 15:59:13 +07:00
parent c8cef9bec8
commit 223c8d7d0b
19 changed files with 1971 additions and 285 deletions
@@ -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 {