diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index c5b8dae..a8a3961 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -29,6 +29,7 @@ export class ProjectController { async closeProject(projectId: string) { try { await api.closeProject(projectId); + this.workspaces.forgetProject(projectId); const state = this.getState(); this.setState({ projects: state.projects.filter((p) => p.id !== projectId) }); if (state.selectedProject?.id === projectId) this.workspaces.clearSelection(); diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 7b7feee..b20c483 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -1,6 +1,7 @@ import { api, type Project, type Workspace } from "../api"; import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types"; import type { SessionController } from "./sessionController"; +import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection"; export class WorkspaceController { constructor( @@ -8,6 +9,7 @@ export class WorkspaceController { private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly sessions: SessionController, + private readonly workspaceSelection: WorkspaceSelectionMemory = new InMemoryWorkspaceSelectionMemory(), ) {} clearSelection(options?: { updateUrl?: boolean | undefined }) { @@ -16,13 +18,17 @@ export class WorkspaceController { if (options?.updateUrl !== false) this.updateUrl(); } + forgetProject(projectId: string): void { + this.workspaceSelection.forgetProject(projectId); + } + async selectProject(project: Project, target?: RouteTarget) { this.sessions.clearActiveSession(); this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); try { const workspaces = await api.workspaces(project.id); this.setState({ workspaces }); - const workspace = target?.workspaceId !== undefined && target.workspaceId !== "" ? workspaces.find((w) => w.id === target.workspaceId) : workspaces[0]; + const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) }); if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); else if (target?.updateUrl !== false) this.updateUrl(); } catch (error) { @@ -31,6 +37,7 @@ export class WorkspaceController { } async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) { + this.workspaceSelection.rememberWorkspace(workspace); this.sessions.clearActiveSession(); this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" }); try { diff --git a/src/client/src/controllers/workspaceSelection.test.ts b/src/client/src/controllers/workspaceSelection.test.ts new file mode 100644 index 0000000..b7419e8 --- /dev/null +++ b/src/client/src/controllers/workspaceSelection.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { Workspace } from "../api"; +import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace } from "./workspaceSelection"; + +describe("selectPreferredWorkspace", () => { + it("prefers an explicit target workspace", () => { + const workspaces = [testWorkspace("main"), testWorkspace("feature")]; + + expect(selectPreferredWorkspace(workspaces, { targetWorkspaceId: "feature", latestWorkspaceId: "main" })?.id).toBe("feature"); + }); + + it("remembers the latest selected workspace when no explicit target is provided", () => { + const workspaces = [testWorkspace("main"), testWorkspace("feature")]; + + expect(selectPreferredWorkspace(workspaces, { latestWorkspaceId: "feature" })?.id).toBe("feature"); + }); + + it("falls back to the first workspace when the remembered workspace no longer exists", () => { + const workspaces = [testWorkspace("main"), testWorkspace("feature")]; + + expect(selectPreferredWorkspace(workspaces, { latestWorkspaceId: "old" })?.id).toBe("main"); + }); + + it("preserves explicit invalid target behavior", () => { + const workspaces = [testWorkspace("main"), testWorkspace("feature")]; + + expect(selectPreferredWorkspace(workspaces, { targetWorkspaceId: "old", latestWorkspaceId: "feature" })).toBeUndefined(); + }); +}); + +describe("InMemoryWorkspaceSelectionMemory", () => { + it("remembers and forgets the latest selected workspace per project", () => { + const memory = new InMemoryWorkspaceSelectionMemory(); + + memory.rememberWorkspace({ ...testWorkspace("feature"), projectId: "p1" }); + memory.rememberWorkspace({ ...testWorkspace("other"), projectId: "p2" }); + + expect(memory.latestWorkspaceId("p1")).toBe("feature"); + expect(memory.latestWorkspaceId("p2")).toBe("other"); + + memory.forgetProject("p1"); + + expect(memory.latestWorkspaceId("p1")).toBeUndefined(); + expect(memory.latestWorkspaceId("p2")).toBe("other"); + }); +}); + +function testWorkspace(id: string): Workspace { + return { id, projectId: "project", path: `/tmp/project/${id}`, label: id, isMain: id === "main", isGitRepo: true, isGitWorktree: id !== "main" }; +} diff --git a/src/client/src/controllers/workspaceSelection.ts b/src/client/src/controllers/workspaceSelection.ts new file mode 100644 index 0000000..ace0da1 --- /dev/null +++ b/src/client/src/controllers/workspaceSelection.ts @@ -0,0 +1,33 @@ +import type { Workspace } from "../api"; + +export interface WorkspaceSelectionMemory { + latestWorkspaceId(projectId: string): string | undefined; + rememberWorkspace(workspace: Workspace): void; + forgetProject(projectId: string): void; +} + +export class InMemoryWorkspaceSelectionMemory implements WorkspaceSelectionMemory { + private readonly workspaceIdsByProject = new Map(); + + latestWorkspaceId(projectId: string): string | undefined { + return this.workspaceIdsByProject.get(projectId); + } + + rememberWorkspace(workspace: Workspace): void { + this.workspaceIdsByProject.set(workspace.projectId, workspace.id); + } + + forgetProject(projectId: string): void { + this.workspaceIdsByProject.delete(projectId); + } +} + +export function selectPreferredWorkspace(workspaces: Workspace[], options?: { targetWorkspaceId?: string | undefined; latestWorkspaceId?: string | undefined }): Workspace | undefined { + const targetWorkspaceId = options?.targetWorkspaceId; + if (targetWorkspaceId !== undefined && targetWorkspaceId !== "") return workspaces.find((workspace) => workspace.id === targetWorkspaceId); + + const latestWorkspaceId = options?.latestWorkspaceId; + if (latestWorkspaceId !== undefined && latestWorkspaceId !== "") return workspaces.find((workspace) => workspace.id === latestWorkspaceId) ?? workspaces[0]; + + return workspaces[0]; +}