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 { ProjectController } from "../controllers/projectController";
|
||||||
import { SessionController } from "../controllers/sessionController";
|
import { SessionController } from "../controllers/sessionController";
|
||||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||||
import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
|
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
|
||||||
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
|
import { SessionStorageSessionSelectionMemory } from "../controllers/sessionSelection";
|
||||||
|
import { SessionStorageTerminalSelectionMemory } from "../controllers/terminalSelection";
|
||||||
|
import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection";
|
||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
import { selectedMachineId } from "../controllers/types";
|
import { selectedMachineId } from "../controllers/types";
|
||||||
import { RealtimeSocket } from "../sessionSocket";
|
import { RealtimeSocket } from "../sessionSocket";
|
||||||
@@ -79,6 +81,7 @@ export class PiWebApp extends LitElement {
|
|||||||
() => this.state,
|
() => this.state,
|
||||||
(patch) => { this.setState(patch); },
|
(patch) => { this.setState(patch); },
|
||||||
() => { this.updateUrl(); },
|
() => { this.updateUrl(); },
|
||||||
|
new SessionStorageSessionSelectionMemory(),
|
||||||
);
|
);
|
||||||
private readonly activity = new ActivityController(
|
private readonly activity = new ActivityController(
|
||||||
() => this.state,
|
() => this.state,
|
||||||
@@ -94,6 +97,7 @@ export class PiWebApp extends LitElement {
|
|||||||
(patch) => { this.setState(patch); },
|
(patch) => { this.setState(patch); },
|
||||||
() => { this.updateUrl(); },
|
() => { this.updateUrl(); },
|
||||||
this.sessions,
|
this.sessions,
|
||||||
|
new SessionStorageWorkspaceSelectionMemory(),
|
||||||
);
|
);
|
||||||
private readonly projects = new ProjectController(
|
private readonly projects = new ProjectController(
|
||||||
() => this.state,
|
() => this.state,
|
||||||
@@ -120,8 +124,8 @@ export class PiWebApp extends LitElement {
|
|||||||
private readonly realtime = new RealtimeSocket();
|
private readonly realtime = new RealtimeSocket();
|
||||||
private readonly machineActivitySockets = new Map<string, RealtimeSocket>();
|
private readonly machineActivitySockets = new Map<string, RealtimeSocket>();
|
||||||
private readonly activeTerminalIds = new Set<string>();
|
private readonly activeTerminalIds = new Set<string>();
|
||||||
private readonly machineNavigation = new InMemoryMachineNavigationMemory();
|
private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
|
||||||
private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
|
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
|
||||||
private readonly appShell = new AppShellController(this);
|
private readonly appShell = new AppShellController(this);
|
||||||
private readonly panelCollapse = new PanelCollapseController(this);
|
private readonly panelCollapse = new PanelCollapseController(this);
|
||||||
private readonly mobileNavigation = new MobileNavigationController(
|
private readonly mobileNavigation = new MobileNavigationController(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import type { Machine, Project, SessionInfo, Workspace } from "../api";
|
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", () => {
|
describe("InMemoryMachineNavigationMemory", () => {
|
||||||
it("remembers independent navigation snapshots per machine", () => {
|
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", () => {
|
describe("machineNavigationSnapshotFromState", () => {
|
||||||
it("captures the selected machine location and workspace surface", () => {
|
it("captures the selected machine location and workspace surface", () => {
|
||||||
const state: AppState = {
|
const state: AppState = {
|
||||||
@@ -116,3 +149,12 @@ function workspace(id: string, projectId: string): Workspace {
|
|||||||
function session(id: string): SessionInfo {
|
function session(id: string): SessionInfo {
|
||||||
return { id, path: `/tmp/project/.pi/sessions/${id}`, cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
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 type { AppState } from "../appState";
|
||||||
import { LOCAL_MACHINE_ID } from "../machineKeys";
|
import { LOCAL_MACHINE_ID } from "../machineKeys";
|
||||||
import type { AppRoute } from "../route";
|
import type { AppRoute } from "../route";
|
||||||
|
import { browserSessionStorage, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
|
||||||
|
|
||||||
export interface WorkspaceRouteSurface {
|
export interface WorkspaceRouteSurface {
|
||||||
selectedFilePath?: string | undefined;
|
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 {
|
export function emptyMachineNavigationSnapshot(machineId: string): MachineNavigationSnapshot {
|
||||||
return { machineId, surface: {} };
|
return { machineId, surface: {} };
|
||||||
}
|
}
|
||||||
@@ -79,3 +103,53 @@ function cloneSnapshot(snapshot: MachineNavigationSnapshot): MachineNavigationSn
|
|||||||
surface: { ...snapshot.surface },
|
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 { describe, expect, it } from "vitest";
|
||||||
import type { SessionInfo } from "../api";
|
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", () => {
|
describe("selectPreferredSession", () => {
|
||||||
it("prefers an explicit target session by id", () => {
|
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", () => {
|
describe("markSessionArchived", () => {
|
||||||
it("marks the matching session archived without mutating the original", () => {
|
it("marks the matching session archived without mutating the original", () => {
|
||||||
const sessions = [testSession("s1"), testSession("s2")];
|
const sessions = [testSession("s1"), testSession("s2")];
|
||||||
@@ -129,3 +150,12 @@ describe("selectionAfterArchivingSession", () => {
|
|||||||
function testSession(id: string): SessionInfo {
|
function testSession(id: string): SessionInfo {
|
||||||
return { id, path: `/tmp/project/.pi/sessions/${id}`, cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
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 type { SessionInfo } from "../api";
|
||||||
|
import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
|
||||||
|
|
||||||
export interface SessionSelectionMemory {
|
export interface SessionSelectionMemory {
|
||||||
latestSessionId(cwd: string): string | undefined;
|
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 {
|
export function selectPreferredSession(sessions: SessionInfo[], options?: { targetSessionId?: string | undefined; latestSessionId?: string | undefined }): SessionInfo | undefined {
|
||||||
const targetSessionId = options?.targetSessionId;
|
const targetSessionId = options?.targetSessionId;
|
||||||
if (targetSessionId !== undefined && targetSessionId !== "") return sessionByIdOrPrefix(sessions, 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 { describe, expect, it } from "vitest";
|
||||||
import type { TerminalInfo } from "../api";
|
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 {
|
function terminal(id: string, exited = false): TerminalInfo {
|
||||||
return { id, cwd: "/repo", name: id, createdAt: "now", exited };
|
return { id, cwd: "/repo", name: id, createdAt: "now", exited };
|
||||||
@@ -34,4 +35,27 @@ describe("terminal selection", () => {
|
|||||||
expect(memory.latestTerminalId("/repo")).toBeUndefined();
|
expect(memory.latestTerminalId("/repo")).toBeUndefined();
|
||||||
expect(memory.latestTerminalId("/other")).toBe("t2");
|
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 type { TerminalInfo } from "../api";
|
||||||
|
import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
|
||||||
|
|
||||||
export interface TerminalSelectionMemory {
|
export interface TerminalSelectionMemory {
|
||||||
latestTerminalId(cwd: string): string | undefined;
|
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 {
|
export function selectPreferredTerminal(terminals: TerminalInfo[], options?: { targetTerminalId?: string | undefined; latestTerminalId?: string | undefined }): TerminalInfo | undefined {
|
||||||
const targetTerminalId = options?.targetTerminalId;
|
const targetTerminalId = options?.targetTerminalId;
|
||||||
if (targetTerminalId !== undefined && targetTerminalId !== "") return terminals.find((terminal) => terminal.id === targetTerminalId);
|
if (targetTerminalId !== undefined && targetTerminalId !== "") return terminals.find((terminal) => terminal.id === targetTerminalId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Workspace } from "../api";
|
import type { Workspace } from "../api";
|
||||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace } from "./workspaceSelection";
|
import type { KeyValueStorage } from "./sessionStorageMemory";
|
||||||
|
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, SessionStorageWorkspaceSelectionMemory } from "./workspaceSelection";
|
||||||
|
|
||||||
describe("selectPreferredWorkspace", () => {
|
describe("selectPreferredWorkspace", () => {
|
||||||
it("prefers an explicit target workspace", () => {
|
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 {
|
function testWorkspace(id: string): Workspace {
|
||||||
return { id, projectId: "project", path: `/tmp/project/${id}`, label: id, isMain: id === "main", isGitRepo: true, isGitWorktree: id !== "main" };
|
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 type { Workspace } from "../api";
|
||||||
|
import { browserSessionStorage, parseStoredString, PersistentValueMap, type KeyValueStorage } from "./sessionStorageMemory";
|
||||||
|
|
||||||
export interface WorkspaceSelectionMemory {
|
export interface WorkspaceSelectionMemory {
|
||||||
latestWorkspaceId(projectId: string): string | undefined;
|
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 {
|
export function selectPreferredWorkspace(workspaces: Workspace[], options?: { targetWorkspaceId?: string | undefined; latestWorkspaceId?: string | undefined }): Workspace | undefined {
|
||||||
const targetWorkspaceId = options?.targetWorkspaceId;
|
const targetWorkspaceId = options?.targetWorkspaceId;
|
||||||
if (targetWorkspaceId !== undefined && targetWorkspaceId !== "") return workspaces.find((workspace) => workspace.id === targetWorkspaceId);
|
if (targetWorkspaceId !== undefined && targetWorkspaceId !== "") return workspaces.find((workspace) => workspace.id === targetWorkspaceId);
|
||||||
|
|||||||
Reference in New Issue
Block a user