diff --git a/.changeset/persist-tab-navigation-memory.md b/.changeset/persist-tab-navigation-memory.md new file mode 100644 index 0000000..68e55f0 --- /dev/null +++ b/.changeset/persist-tab-navigation-memory.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Preserve machine, workspace, session, and terminal navigation memory across reloads within each browser tab. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 7317e74..158ebb7 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -12,8 +12,10 @@ import { MachineController } from "../controllers/machineController"; import { ProjectController } from "../controllers/projectController"; import { SessionController } from "../controllers/sessionController"; import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController"; -import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory"; -import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection"; +import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory"; +import { SessionStorageSessionSelectionMemory } from "../controllers/sessionSelection"; +import { SessionStorageTerminalSelectionMemory } from "../controllers/terminalSelection"; +import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { selectedMachineId } from "../controllers/types"; import { RealtimeSocket } from "../sessionSocket"; @@ -79,6 +81,7 @@ export class PiWebApp extends LitElement { () => this.state, (patch) => { this.setState(patch); }, () => { this.updateUrl(); }, + new SessionStorageSessionSelectionMemory(), ); private readonly activity = new ActivityController( () => this.state, @@ -94,6 +97,7 @@ export class PiWebApp extends LitElement { (patch) => { this.setState(patch); }, () => { this.updateUrl(); }, this.sessions, + new SessionStorageWorkspaceSelectionMemory(), ); private readonly projects = new ProjectController( () => this.state, @@ -120,8 +124,8 @@ export class PiWebApp extends LitElement { private readonly realtime = new RealtimeSocket(); private readonly machineActivitySockets = new Map(); private readonly activeTerminalIds = new Set(); - private readonly machineNavigation = new InMemoryMachineNavigationMemory(); - private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); + private readonly machineNavigation = new SessionStorageMachineNavigationMemory(); + private readonly terminalSelection = new SessionStorageTerminalSelectionMemory(); private readonly appShell = new AppShellController(this); private readonly panelCollapse = new PanelCollapseController(this); private readonly mobileNavigation = new MobileNavigationController( diff --git a/src/client/src/controllers/machineNavigationMemory.test.ts b/src/client/src/controllers/machineNavigationMemory.test.ts index c2d443a..20712fe 100644 --- a/src/client/src/controllers/machineNavigationMemory.test.ts +++ b/src/client/src/controllers/machineNavigationMemory.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { initialAppState, type AppState } from "../appState"; import type { Machine, Project, SessionInfo, Workspace } from "../api"; -import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot } from "./machineNavigationMemory"; +import type { KeyValueStorage } from "./sessionStorageMemory"; +import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory } from "./machineNavigationMemory"; describe("InMemoryMachineNavigationMemory", () => { it("remembers independent navigation snapshots per machine", () => { @@ -30,6 +31,38 @@ describe("InMemoryMachineNavigationMemory", () => { }); }); +describe("SessionStorageMachineNavigationMemory", () => { + it("persists independent navigation snapshots in per-tab storage", () => { + const storage = memoryStorage(); + const memory = new SessionStorageMachineNavigationMemory(storage); + + memory.remember({ machineId: "local", projectId: "local-project", surface: { selectedFilePath: "README.md" } }); + memory.remember({ machineId: "remote", projectId: "remote-project", workspaceId: "remote-workspace", sessionId: "remote-session", surface: {} }); + + const restored = new SessionStorageMachineNavigationMemory(storage); + + expect(restored.latest("local")?.projectId).toBe("local-project"); + expect(restored.latest("remote")?.workspaceId).toBe("remote-workspace"); + + restored.forget("local"); + + expect(new SessionStorageMachineNavigationMemory(storage).latest("local")).toBeUndefined(); + expect(new SessionStorageMachineNavigationMemory(storage).latest("remote")?.projectId).toBe("remote-project"); + }); + + it("ignores malformed stored snapshots", () => { + const storage = memoryStorage({ + "pi-web:machine-navigation:v1": JSON.stringify({ version: 1, entries: [["local", { machineId: "local", tool: "bad", surface: { selectedFilePath: "README.md" } }], ["remote", { projectId: "missing-machine", surface: {} }]] }), + }); + + const memory = new SessionStorageMachineNavigationMemory(storage); + + expect(memory.latest("local")?.tool).toBeUndefined(); + expect(memory.latest("local")?.surface.selectedFilePath).toBe("README.md"); + expect(memory.latest("remote")).toBeUndefined(); + }); +}); + describe("machineNavigationSnapshotFromState", () => { it("captures the selected machine location and workspace surface", () => { const state: AppState = { @@ -116,3 +149,12 @@ function workspace(id: string, projectId: string): Workspace { function session(id: string): SessionInfo { return { id, path: `/tmp/project/.pi/sessions/${id}`, cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" }; } + +function memoryStorage(seed: Record = {}): KeyValueStorage { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { values.set(key, value); }, + removeItem: (key) => { values.delete(key); }, + }; +} diff --git a/src/client/src/controllers/machineNavigationMemory.ts b/src/client/src/controllers/machineNavigationMemory.ts index 59abf00..86d0ade 100644 --- a/src/client/src/controllers/machineNavigationMemory.ts +++ b/src/client/src/controllers/machineNavigationMemory.ts @@ -1,6 +1,7 @@ import type { AppState } from "../appState"; import { LOCAL_MACHINE_ID } from "../machineKeys"; import type { AppRoute } from "../route"; +import { browserSessionStorage, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory"; export interface WorkspaceRouteSurface { selectedFilePath?: string | undefined; @@ -41,6 +42,29 @@ export class InMemoryMachineNavigationMemory implements MachineNavigationMemory } } +const machineNavigationStorageKey = "pi-web:machine-navigation:v1"; + +export class SessionStorageMachineNavigationMemory implements MachineNavigationMemory { + private readonly snapshotsByMachine: PersistentValueMap; + + constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) { + this.snapshotsByMachine = new PersistentValueMap(machineNavigationStorageKey, parseMachineNavigationSnapshot, storage); + } + + latest(machineId: string): MachineNavigationSnapshot | undefined { + const snapshot = this.snapshotsByMachine.get(machineId); + return snapshot?.machineId === machineId ? cloneSnapshot(snapshot) : undefined; + } + + remember(snapshot: MachineNavigationSnapshot): void { + this.snapshotsByMachine.set(snapshot.machineId, cloneSnapshot(snapshot)); + } + + forget(machineId: string): void { + this.snapshotsByMachine.delete(machineId); + } +} + export function emptyMachineNavigationSnapshot(machineId: string): MachineNavigationSnapshot { return { machineId, surface: {} }; } @@ -79,3 +103,53 @@ function cloneSnapshot(snapshot: MachineNavigationSnapshot): MachineNavigationSn surface: { ...snapshot.surface }, }; } + +function parseMachineNavigationSnapshot(value: unknown): MachineNavigationSnapshot | undefined { + if (!isRecord(value)) return undefined; + const machineId = optionalStringField(value, "machineId"); + if (machineId === undefined) return undefined; + const tool = parseQualifiedId(optionalStringField(value, "tool")); + const view = parseMainView(optionalStringField(value, "view")); + return { + machineId, + projectId: optionalStringField(value, "projectId"), + workspaceId: optionalStringField(value, "workspaceId"), + sessionId: optionalStringField(value, "sessionId"), + ...(tool === undefined ? {} : { tool }), + ...(view === undefined ? {} : { view }), + surface: parseWorkspaceRouteSurface(value["surface"]), + }; +} + +function parseWorkspaceRouteSurface(value: unknown): WorkspaceRouteSurface { + if (!isRecord(value)) return {}; + return { + selectedFilePath: optionalStringField(value, "selectedFilePath"), + selectedDiffPath: optionalStringField(value, "selectedDiffPath"), + selectedTerminalId: optionalStringField(value, "selectedTerminalId"), + }; +} + +function parseMainView(value: string | undefined): AppState["mainView"] | undefined { + if (value === "navigation" || value === "chat") return value; + return parseQualifiedId(value); +} + +type QualifiedRouteId = NonNullable; + +function parseQualifiedId(value: string | undefined): AppRoute["tool"] | undefined { + return isQualifiedId(value) ? value : undefined; +} + +function isQualifiedId(value: string | undefined): value is QualifiedRouteId { + return value !== undefined && /^[a-z][a-z0-9.-]*:[a-z][a-z0-9.-]*$/u.test(value); +} + +function optionalStringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value !== "" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/controllers/sessionSelection.test.ts b/src/client/src/controllers/sessionSelection.test.ts index 5457877..6880259 100644 --- a/src/client/src/controllers/sessionSelection.test.ts +++ b/src/client/src/controllers/sessionSelection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { SessionInfo } from "../api"; -import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse } from "./sessionSelection"; +import type { KeyValueStorage } from "./sessionStorageMemory"; +import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, SessionStorageSessionSelectionMemory, shouldDeselectAfterArchivedCollapse } from "./sessionSelection"; describe("selectPreferredSession", () => { it("prefers an explicit target session by id", () => { @@ -69,6 +70,26 @@ describe("InMemorySessionSelectionMemory", () => { }); }); +describe("SessionStorageSessionSelectionMemory", () => { + it("persists the latest selected session per workspace cwd", () => { + const storage = memoryStorage(); + const memory = new SessionStorageSessionSelectionMemory(storage); + + memory.rememberSession({ ...testSession("s1"), cwd: "local:/tmp/one" }); + memory.rememberSession({ ...testSession("s2"), cwd: "remote:/tmp/one" }); + + const restored = new SessionStorageSessionSelectionMemory(storage); + + expect(restored.latestSessionId("local:/tmp/one")).toBe("s1"); + expect(restored.latestSessionId("remote:/tmp/one")).toBe("s2"); + + restored.forgetWorkspace("local:/tmp/one"); + + expect(new SessionStorageSessionSelectionMemory(storage).latestSessionId("local:/tmp/one")).toBeUndefined(); + expect(new SessionStorageSessionSelectionMemory(storage).latestSessionId("remote:/tmp/one")).toBe("s2"); + }); +}); + describe("markSessionArchived", () => { it("marks the matching session archived without mutating the original", () => { const sessions = [testSession("s1"), testSession("s2")]; @@ -129,3 +150,12 @@ describe("selectionAfterArchivingSession", () => { function testSession(id: string): SessionInfo { return { id, path: `/tmp/project/.pi/sessions/${id}`, cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" }; } + +function memoryStorage(seed: Record = {}): KeyValueStorage { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { values.set(key, value); }, + removeItem: (key) => { values.delete(key); }, + }; +} diff --git a/src/client/src/controllers/sessionSelection.ts b/src/client/src/controllers/sessionSelection.ts index ca05f6b..b60f3bc 100644 --- a/src/client/src/controllers/sessionSelection.ts +++ b/src/client/src/controllers/sessionSelection.ts @@ -1,4 +1,5 @@ import type { SessionInfo } from "../api"; +import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory"; export interface SessionSelectionMemory { latestSessionId(cwd: string): string | undefined; @@ -22,6 +23,28 @@ export class InMemorySessionSelectionMemory implements SessionSelectionMemory { } } +const sessionSelectionStorageKey = "pi-web:session-selection:v1"; + +export class SessionStorageSessionSelectionMemory implements SessionSelectionMemory { + private readonly sessionIdsByCwd: PersistentValueMap; + + constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) { + this.sessionIdsByCwd = new PersistentValueMap(sessionSelectionStorageKey, parseStoredString, storage); + } + + latestSessionId(cwd: string): string | undefined { + return this.sessionIdsByCwd.get(cwd); + } + + rememberSession(session: SessionInfo): void { + this.sessionIdsByCwd.set(session.cwd, session.id); + } + + forgetWorkspace(cwd: string): void { + this.sessionIdsByCwd.delete(cwd); + } +} + export function selectPreferredSession(sessions: SessionInfo[], options?: { targetSessionId?: string | undefined; latestSessionId?: string | undefined }): SessionInfo | undefined { const targetSessionId = options?.targetSessionId; if (targetSessionId !== undefined && targetSessionId !== "") return sessionByIdOrPrefix(sessions, targetSessionId); diff --git a/src/client/src/controllers/sessionStorageMemory.ts b/src/client/src/controllers/sessionStorageMemory.ts new file mode 100644 index 0000000..263be55 --- /dev/null +++ b/src/client/src/controllers/sessionStorageMemory.ts @@ -0,0 +1,90 @@ +export interface KeyValueStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +interface StoredMapEnvelope { + version: 1; + entries: readonly (readonly [string, unknown])[]; +} + +export type StorageValueParser = (value: unknown) => T | undefined; + +export function browserSessionStorage(): KeyValueStorage | undefined { + try { + return typeof sessionStorage === "undefined" ? undefined : sessionStorage; + } catch { + return undefined; + } +} + +export class PersistentValueMap { + private readonly values = new Map(); + + constructor(private readonly storageKey: string, private readonly parseValue: StorageValueParser, private readonly storage = browserSessionStorage()) { + for (const [key, value] of loadEntries(storageKey, parseValue, storage)) this.values.set(key, value); + } + + get(key: string): T | undefined { + return this.values.get(key); + } + + set(key: string, value: T): void { + this.values.set(key, value); + this.save(); + } + + delete(key: string): void { + this.values.delete(key); + this.save(); + } + + entries(): [string, T][] { + return [...this.values.entries()]; + } + + private save(): void { + try { + if (this.values.size === 0) { + this.storage?.removeItem(this.storageKey); + return; + } + const envelope: StoredMapEnvelope = { version: 1, entries: [...this.values.entries()] }; + this.storage?.setItem(this.storageKey, JSON.stringify(envelope)); + } catch { + // Keep the in-memory copy even if sessionStorage is unavailable or full. + } + } +} + +export function parseStoredString(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined; +} + +function loadEntries(storageKey: string, parseValue: StorageValueParser, storage: KeyValueStorage | undefined): [string, T][] { + try { + const raw = storage?.getItem(storageKey); + if (raw === undefined || raw === null || raw === "") return []; + const value: unknown = JSON.parse(raw); + if (!isStoredMapEnvelope(value)) return []; + const entries: [string, T][] = []; + for (const entry of value.entries) { + const key = entry[0]; + const parsed = parseValue(entry[1]); + if (parsed !== undefined) entries.push([key, parsed]); + } + return entries; + } catch { + return []; + } +} + +function isStoredMapEnvelope(value: unknown): value is StoredMapEnvelope { + if (!isRecord(value) || value["version"] !== 1 || !Array.isArray(value["entries"])) return false; + return value["entries"].every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && entry[0] !== ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/controllers/terminalSelection.test.ts b/src/client/src/controllers/terminalSelection.test.ts index 1aa860a..9c053d9 100644 --- a/src/client/src/controllers/terminalSelection.test.ts +++ b/src/client/src/controllers/terminalSelection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TerminalInfo } from "../api"; -import { InMemoryTerminalSelectionMemory, selectFallbackTerminal, selectPreferredTerminal } from "./terminalSelection"; +import type { KeyValueStorage } from "./sessionStorageMemory"; +import { InMemoryTerminalSelectionMemory, selectFallbackTerminal, selectPreferredTerminal, SessionStorageTerminalSelectionMemory } from "./terminalSelection"; function terminal(id: string, exited = false): TerminalInfo { return { id, cwd: "/repo", name: id, createdAt: "now", exited }; @@ -34,4 +35,27 @@ describe("terminal selection", () => { expect(memory.latestTerminalId("/repo")).toBeUndefined(); expect(memory.latestTerminalId("/other")).toBe("t2"); }); + + it("persists terminal ids per workspace cwd", () => { + const storage = memoryStorage(); + const memory = new SessionStorageTerminalSelectionMemory(storage); + memory.rememberTerminal("local:/repo", "t1"); + memory.rememberTerminal("remote:/repo", "t2"); + + const restored = new SessionStorageTerminalSelectionMemory(storage); + + expect(restored.latestTerminalId("local:/repo")).toBe("t1"); + restored.forgetTerminal("t1"); + expect(new SessionStorageTerminalSelectionMemory(storage).latestTerminalId("local:/repo")).toBeUndefined(); + expect(new SessionStorageTerminalSelectionMemory(storage).latestTerminalId("remote:/repo")).toBe("t2"); + }); }); + +function memoryStorage(seed: Record = {}): KeyValueStorage { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { values.set(key, value); }, + removeItem: (key) => { values.delete(key); }, + }; +} diff --git a/src/client/src/controllers/terminalSelection.ts b/src/client/src/controllers/terminalSelection.ts index 3aa06cc..9aa1ee5 100644 --- a/src/client/src/controllers/terminalSelection.ts +++ b/src/client/src/controllers/terminalSelection.ts @@ -1,4 +1,5 @@ import type { TerminalInfo } from "../api"; +import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory"; export interface TerminalSelectionMemory { latestTerminalId(cwd: string): string | undefined; @@ -29,6 +30,34 @@ export class InMemoryTerminalSelectionMemory implements TerminalSelectionMemory } } +const terminalSelectionStorageKey = "pi-web:terminal-selection:v1"; + +export class SessionStorageTerminalSelectionMemory implements TerminalSelectionMemory { + private readonly terminalIdsByCwd: PersistentValueMap; + + constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) { + this.terminalIdsByCwd = new PersistentValueMap(terminalSelectionStorageKey, parseStoredString, storage); + } + + latestTerminalId(cwd: string): string | undefined { + return this.terminalIdsByCwd.get(cwd); + } + + rememberTerminal(cwd: string, terminalId: string): void { + this.terminalIdsByCwd.set(cwd, terminalId); + } + + forgetWorkspace(cwd: string): void { + this.terminalIdsByCwd.delete(cwd); + } + + forgetTerminal(terminalId: string): void { + for (const [cwd, rememberedTerminalId] of this.terminalIdsByCwd.entries()) { + if (rememberedTerminalId === terminalId) this.terminalIdsByCwd.delete(cwd); + } + } +} + export function selectPreferredTerminal(terminals: TerminalInfo[], options?: { targetTerminalId?: string | undefined; latestTerminalId?: string | undefined }): TerminalInfo | undefined { const targetTerminalId = options?.targetTerminalId; if (targetTerminalId !== undefined && targetTerminalId !== "") return terminals.find((terminal) => terminal.id === targetTerminalId); diff --git a/src/client/src/controllers/workspaceSelection.test.ts b/src/client/src/controllers/workspaceSelection.test.ts index b7419e8..477d3f9 100644 --- a/src/client/src/controllers/workspaceSelection.test.ts +++ b/src/client/src/controllers/workspaceSelection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Workspace } from "../api"; -import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace } from "./workspaceSelection"; +import type { KeyValueStorage } from "./sessionStorageMemory"; +import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, SessionStorageWorkspaceSelectionMemory } from "./workspaceSelection"; describe("selectPreferredWorkspace", () => { it("prefers an explicit target workspace", () => { @@ -45,6 +46,35 @@ describe("InMemoryWorkspaceSelectionMemory", () => { }); }); +describe("SessionStorageWorkspaceSelectionMemory", () => { + it("persists the latest selected workspace per project", () => { + const storage = memoryStorage(); + const memory = new SessionStorageWorkspaceSelectionMemory(storage); + + memory.rememberWorkspace({ ...testWorkspace("feature"), projectId: "local:p1" }); + memory.rememberWorkspace({ ...testWorkspace("other"), projectId: "remote:p1" }); + + const restored = new SessionStorageWorkspaceSelectionMemory(storage); + + expect(restored.latestWorkspaceId("local:p1")).toBe("feature"); + expect(restored.latestWorkspaceId("remote:p1")).toBe("other"); + + restored.forgetProject("local:p1"); + + expect(new SessionStorageWorkspaceSelectionMemory(storage).latestWorkspaceId("local:p1")).toBeUndefined(); + expect(new SessionStorageWorkspaceSelectionMemory(storage).latestWorkspaceId("remote:p1")).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" }; } + +function memoryStorage(seed: Record = {}): KeyValueStorage { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { values.set(key, value); }, + removeItem: (key) => { values.delete(key); }, + }; +} diff --git a/src/client/src/controllers/workspaceSelection.ts b/src/client/src/controllers/workspaceSelection.ts index ace0da1..75ceff2 100644 --- a/src/client/src/controllers/workspaceSelection.ts +++ b/src/client/src/controllers/workspaceSelection.ts @@ -1,4 +1,5 @@ import type { Workspace } from "../api"; +import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory"; export interface WorkspaceSelectionMemory { latestWorkspaceId(projectId: string): string | undefined; @@ -22,6 +23,28 @@ export class InMemoryWorkspaceSelectionMemory implements WorkspaceSelectionMemor } } +const workspaceSelectionStorageKey = "pi-web:workspace-selection:v1"; + +export class SessionStorageWorkspaceSelectionMemory implements WorkspaceSelectionMemory { + private readonly workspaceIdsByProject: PersistentValueMap; + + constructor(storage: KeyValueStorage | undefined = browserSessionStorage()) { + this.workspaceIdsByProject = new PersistentValueMap(workspaceSelectionStorageKey, parseStoredString, storage); + } + + 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);