diff --git a/src/client/types.ts b/src/client/types.ts index 076ced3..d8040c2 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,6 +1,7 @@ export interface Workspace { workspace_id: string; label?: string; + path?: string; cwd?: string; worktree?: WorktreeProvenance; tab_count?: number; diff --git a/src/sessionizer/sessionizer.test.ts b/src/sessionizer/sessionizer.test.ts index 39417d8..0aaf80c 100644 --- a/src/sessionizer/sessionizer.test.ts +++ b/src/sessionizer/sessionizer.test.ts @@ -131,6 +131,129 @@ describe("runSessionizer", () => { expect(focus).toHaveBeenCalledWith("ws-project"); }); + it("focuses instead of duplicating when the picked project already has a workspace", async () => { + const create = mock(async (_options: unknown) => testWorkspace()); + const focus = mock(async () => {}); + const log = mock(() => {}); + const open = testWorkspace({ + path: "/projects/fieldnotes", + workspace_id: "ws-open", + }); + + await runSessionizer({ + workspaces: { + list: mock(async () => [open]), + create, + focus, + }, + tabs: testTabs(), + panes: testPanes(), + config: testConfig(), + pickRows: mock( + async (_rows: readonly string[], options?: { prompt?: string }) => { + if (options?.prompt === "Switch session (Esc for new): ") { + return null; + } + + return ["/projects/fieldnotes"]; + } + ), + listProjects: mock(() => ["/projects/fieldnotes"]), + createLayout: mock(async (workspace: Workspace) => workspace), + logger: { log, error: mock(() => {}) }, + exit: (code) => { + throw new Error(`unexpected exit ${code}`); + }, + }); + + expect(create).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalledWith("ws-open"); + expect(log).toHaveBeenCalledWith( + "✓ focused existing workspace for '/projects/fieldnotes' (ws-open)" + ); + }); + + it("falls back to the legacy cwd field when matching existing workspaces", async () => { + const create = mock(async (_options: unknown) => testWorkspace()); + const focus = mock(async () => {}); + const open = testWorkspace({ + cwd: "/projects/fieldnotes", + workspace_id: "ws-legacy", + }); + + await runSessionizer({ + workspaces: { + list: mock(async () => [open]), + create, + focus, + }, + tabs: testTabs(), + panes: testPanes(), + config: testConfig(), + pickRows: mock( + async (_rows: readonly string[], options?: { prompt?: string }) => { + if (options?.prompt === "Switch session (Esc for new): ") { + return null; + } + + return ["/projects/fieldnotes"]; + } + ), + listProjects: mock(() => ["/projects/fieldnotes"]), + createLayout: mock(async (workspace: Workspace) => workspace), + logger: { log: mock(() => {}), error: mock(() => {}) }, + exit: (code) => { + throw new Error(`unexpected exit ${code}`); + }, + }); + + expect(create).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalledWith("ws-legacy"); + }); + + it("still creates a workspace when only a worktree workspace matches the project path", async () => { + const workspace = testWorkspace({ + cwd: "/projects/fieldnotes", + workspace_id: "ws-new", + }); + const create = mock(async () => workspace); + const focus = mock(async () => {}); + const worktreeMatch = testWorkspace({ + cwd: "/projects/fieldnotes", + workspace_id: "ws-worktree", + worktree: { branch: "main" }, + }); + + await runSessionizer({ + workspaces: { + list: mock(async () => [worktreeMatch]), + create, + focus, + }, + tabs: testTabs(), + panes: testPanes(), + config: testConfig(), + pickRows: mock( + async (_rows: readonly string[], options?: { prompt?: string }) => { + if (options?.prompt === "Switch session (Esc for new): ") { + return null; + } + + return ["/projects/fieldnotes"]; + } + ), + listProjects: mock(() => ["/projects/fieldnotes"]), + createLayout: mock(async (created: Workspace) => created), + logger: { log: mock(() => {}), error: mock(() => {}) }, + exit: (code) => { + throw new Error(`unexpected exit ${code}`); + }, + }); + + expect(create).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledWith("ws-new"); + }); + it("exits with an error when no projects are found", async () => { const error = mock(() => {}); diff --git a/src/sessionizer/sessionizer.ts b/src/sessionizer/sessionizer.ts index 3f30f7b..8c3900f 100644 --- a/src/sessionizer/sessionizer.ts +++ b/src/sessionizer/sessionizer.ts @@ -2,6 +2,7 @@ import { basename } from "node:path"; import { listProjects, + normalizePath, sanitizeName, type ProjectDiscoveryOptions, } from "../discovery/discovery.ts"; @@ -87,17 +88,15 @@ export async function runSessionizer( ): Promise { const { workspaces, tabs, panes, config } = runtime; - const existing = await runtime.pickRows( - (await workspaces.list()).map(workspaceRow), - { - prompt: "Switch session (Esc for new): ", - header: "↑↓ navigate, Enter select, Esc → new project", - delimiter: WORKSPACE_ROW_DELIMITER, - withNth: "2", - preview: WORKSPACE_PREVIEW, - previewWindow: "right:50%", - } - ); + const allWorkspaces = await workspaces.list(); + const existing = await runtime.pickRows(allWorkspaces.map(workspaceRow), { + prompt: "Switch session (Esc for new): ", + header: "↑↓ navigate, Enter select, Esc → new project", + delimiter: WORKSPACE_ROW_DELIMITER, + withNth: "2", + preview: WORKSPACE_PREVIEW, + previewWindow: "right:50%", + }); if (existing && existing.length > 0) { await workspaces.focus(extractWorkspaceId(existing[0]!)); @@ -120,6 +119,15 @@ export async function runSessionizer( if (!selected || selected.length === 0) return; const project = selected[0]!; + const openWorkspace = findProjectWorkspace(allWorkspaces, project); + if (openWorkspace) { + await workspaces.focus(openWorkspace.workspace_id); + runtime.logger.log( + `✓ focused existing workspace for '${project}' (${openWorkspace.workspace_id})` + ); + return; + } + const projectName = project.split("/").pop() ?? project; const label = sanitizeName(projectName); const workspace = await workspaces.create({ @@ -137,6 +145,18 @@ export async function runSessionizer( ); } +function findProjectWorkspace( + workspaces: readonly Workspace[], + projectPath: string +): Workspace | undefined { + const normalizedProject = normalizePath(projectPath); + return workspaces.find( + (workspace) => + !workspace.worktree && + normalizePath(workspace.path ?? workspace.cwd) === normalizedProject + ); +} + function workspaceName(workspace: Workspace): string { const path = workspacePath(workspace); if (path) { @@ -165,6 +185,7 @@ function workspaceSummary(workspace: Workspace): string { function workspacePath(workspace: Workspace): string | undefined { return ( + workspace.path ?? workspace.cwd ?? workspace.worktree?.checkout_path ?? workspace.worktree?.repo_root ??