Archived
fix: persist navigation memory per browser tab
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Preserve machine, workspace, session, and terminal navigation memory across reloads within each browser tab.
|
||||
@@ -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<string, RealtimeSocket>();
|
||||
private readonly activeTerminalIds = new Set<string>();
|
||||
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(
|
||||
|
||||
@@ -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<string, string> = {}): 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); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<MachineNavigationSnapshot>;
|
||||
|
||||
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<AppRoute["tool"]>;
|
||||
|
||||
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<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value !== "" ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {}): 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); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<T> = (value: unknown) => T | undefined;
|
||||
|
||||
export function browserSessionStorage(): KeyValueStorage | undefined {
|
||||
try {
|
||||
return typeof sessionStorage === "undefined" ? undefined : sessionStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class PersistentValueMap<T> {
|
||||
private readonly values = new Map<string, T>();
|
||||
|
||||
constructor(private readonly storageKey: string, private readonly parseValue: StorageValueParser<T>, 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<T>(storageKey: string, parseValue: StorageValueParser<T>, 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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -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<string, string> = {}): 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); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<string, string> = {}): 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); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user