| 1 | import { createWorkspaceTreePersistenceScheduler } from "./workspaceTreePersistence"; |
| 2 | |
| 3 | export type WorkspaceTreeWidthMode = "manual" | "even"; |
| 4 | |
| 5 | export interface WorkspaceTreeMemorySnapshot { |
| 6 | openDirs: Set<string>; |
| 7 | visitId: number; |
| 8 | selectedFilePath: string | null; |
| 9 | selectedChangePath: string | null; |
| 10 | treeWidth: number | null; |
| 11 | treeWidthMode: WorkspaceTreeWidthMode; |
| 12 | scrollTop: number; |
| 13 | dockTreeWidth: number | null; |
| 14 | dockPreviewWidth: number | null; |
| 15 | recentPaths: string[]; |
| 16 | } |
| 17 | |
| 18 | type PersistedWorkspaceState = Omit<WorkspaceTreeMemorySnapshot, "openDirs" | "visitId"> & { |
| 19 | openDirs: string[]; |
| 20 | updatedAt: number; |
| 21 | }; |
| 22 | |
| 23 | interface PersistedWorkspaceEnvelope { |
| 24 | version: 2; |
| 25 | projects: Array<{ key: string; state: PersistedWorkspaceState }>; |
| 26 | } |
| 27 | |
| 28 | const STORAGE_KEY = "reasonix.workspaceState.v2"; |
| 29 | const MAX_PERSISTED_PROJECTS = 50; |
| 30 | const MAX_RECENT_PATHS = 10; |
| 31 | const workspaceTreeMemory = new Map<string, WorkspaceTreeMemorySnapshot>(); |
| 32 | let storageHydrated = false; |
| 33 | let activeWorkspaceTreeKey = ""; |
| 34 | let workspaceTreeVisitSequence = 0; |
| 35 | |
| 36 | function defaultSnapshot(visitId = 0): WorkspaceTreeMemorySnapshot { |
| 37 | return { |
| 38 | openDirs: new Set([""]), |
| 39 | visitId, |
| 40 | selectedFilePath: null, |
| 41 | selectedChangePath: null, |
| 42 | treeWidth: null, |
| 43 | treeWidthMode: "manual", |
| 44 | scrollTop: 0, |
| 45 | dockTreeWidth: null, |
| 46 | dockPreviewWidth: null, |
| 47 | recentPaths: [], |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | function validPath(value: unknown): string | null { |
| 52 | return typeof value === "string" && value.length > 0 ? value : null; |
| 53 | } |
| 54 | |
| 55 | function hydrateWorkspaceTreeMemory(): void { |
| 56 | if (storageHydrated) return; |
| 57 | storageHydrated = true; |
| 58 | if (typeof localStorage === "undefined") return; |
| 59 | try { |
| 60 | const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as Partial<PersistedWorkspaceEnvelope> | null; |
| 61 | if (!parsed || parsed.version !== 2 || !Array.isArray(parsed.projects)) return; |
| 62 | for (const project of parsed.projects) { |
| 63 | if (!project || typeof project.key !== "string" || !project.state || typeof project.state !== "object") continue; |
| 64 | const state = project.state as Partial<PersistedWorkspaceState>; |
| 65 | const openDirs = Array.isArray(state.openDirs) |
| 66 | ? state.openDirs.filter((path): path is string => typeof path === "string") |
| 67 | : [""]; |
| 68 | workspaceTreeMemory.set(project.key, { |
| 69 | openDirs: new Set(openDirs.length > 0 ? openDirs : [""]), |
| 70 | visitId: 0, |
| 71 | selectedFilePath: validPath(state.selectedFilePath), |
| 72 | selectedChangePath: validPath(state.selectedChangePath), |
| 73 | treeWidth: typeof state.treeWidth === "number" && Number.isFinite(state.treeWidth) && state.treeWidth > 0 |
| 74 | ? state.treeWidth |
| 75 | : null, |
| 76 | treeWidthMode: state.treeWidthMode === "even" ? "even" : "manual", |
| 77 | scrollTop: typeof state.scrollTop === "number" && Number.isFinite(state.scrollTop) && state.scrollTop >= 0 |
| 78 | ? state.scrollTop |
| 79 | : 0, |
| 80 | dockTreeWidth: typeof state.dockTreeWidth === "number" && Number.isFinite(state.dockTreeWidth) && state.dockTreeWidth > 0 |
| 81 | ? state.dockTreeWidth |
| 82 | : null, |
| 83 | dockPreviewWidth: typeof state.dockPreviewWidth === "number" && Number.isFinite(state.dockPreviewWidth) && state.dockPreviewWidth > 0 |
| 84 | ? state.dockPreviewWidth |
| 85 | : null, |
| 86 | recentPaths: Array.isArray(state.recentPaths) |
| 87 | ? state.recentPaths.filter((path): path is string => typeof path === "string").slice(0, MAX_RECENT_PATHS) |
| 88 | : [], |
| 89 | }); |
| 90 | } |
| 91 | } catch { |
| 92 | // Corrupt or newer storage must never prevent the workspace from opening. |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | function persistWorkspaceTreeMemory(recentKey: string): void { |
| 97 | if (typeof localStorage === "undefined") return; |
| 98 | try { |
| 99 | const now = Date.now(); |
| 100 | const entries = Array.from(workspaceTreeMemory.entries()) |
| 101 | .map(([key, snapshot]) => ({ |
| 102 | key, |
| 103 | state: { |
| 104 | openDirs: Array.from(snapshot.openDirs), |
| 105 | selectedFilePath: snapshot.selectedFilePath, |
| 106 | selectedChangePath: snapshot.selectedChangePath, |
| 107 | treeWidth: snapshot.treeWidth, |
| 108 | treeWidthMode: snapshot.treeWidthMode, |
| 109 | scrollTop: snapshot.scrollTop, |
| 110 | dockTreeWidth: snapshot.dockTreeWidth, |
| 111 | dockPreviewWidth: snapshot.dockPreviewWidth, |
| 112 | recentPaths: snapshot.recentPaths, |
| 113 | updatedAt: key === recentKey ? now : 0, |
| 114 | }, |
| 115 | })) |
| 116 | .sort((left, right) => (right.key === recentKey ? 1 : 0) - (left.key === recentKey ? 1 : 0)) |
| 117 | .slice(0, MAX_PERSISTED_PROJECTS); |
| 118 | const envelope: PersistedWorkspaceEnvelope = { version: 2, projects: entries }; |
| 119 | localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope)); |
| 120 | } catch { |
| 121 | // localStorage can be disabled or full; the in-memory state still works. |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | const deferredScrollPersistence = createWorkspaceTreePersistenceScheduler(persistWorkspaceTreeMemory); |
| 126 | |
| 127 | function cloneSnapshot(snapshot: WorkspaceTreeMemorySnapshot): WorkspaceTreeMemorySnapshot { |
| 128 | return { ...snapshot, openDirs: new Set(snapshot.openDirs) }; |
| 129 | } |
| 130 | |
| 131 | export function workspaceTreeVisitId(memoryKey: string): number { |
| 132 | if (activeWorkspaceTreeKey !== memoryKey) { |
| 133 | activeWorkspaceTreeKey = memoryKey; |
| 134 | workspaceTreeVisitSequence += 1; |
| 135 | } |
| 136 | return workspaceTreeVisitSequence; |
| 137 | } |
| 138 | |
| 139 | export function readWorkspaceTreeMemory(memoryKey: string): WorkspaceTreeMemorySnapshot | null { |
| 140 | hydrateWorkspaceTreeMemory(); |
| 141 | const snapshot = workspaceTreeMemory.get(memoryKey); |
| 142 | return snapshot ? cloneSnapshot(snapshot) : null; |
| 143 | } |
| 144 | |
| 145 | export function rememberWorkspaceTreeState( |
| 146 | memoryKey: string, |
| 147 | patch: Partial<Omit<WorkspaceTreeMemorySnapshot, "openDirs">> & { openDirs?: ReadonlySet<string> }, |
| 148 | ): void { |
| 149 | hydrateWorkspaceTreeMemory(); |
| 150 | const current = workspaceTreeMemory.get(memoryKey) ?? defaultSnapshot(); |
| 151 | workspaceTreeMemory.set(memoryKey, { |
| 152 | ...current, |
| 153 | ...patch, |
| 154 | openDirs: patch.openDirs ? new Set(patch.openDirs) : new Set(current.openDirs), |
| 155 | }); |
| 156 | // An immediate state write already includes the latest in-memory scroll |
| 157 | // position, so retire any trailing scroll write instead of duplicating it. |
| 158 | deferredScrollPersistence.cancel(); |
| 159 | persistWorkspaceTreeMemory(memoryKey); |
| 160 | } |
| 161 | |
| 162 | export function rememberWorkspaceTreeScroll(memoryKey: string, scrollTop: number): void { |
| 163 | hydrateWorkspaceTreeMemory(); |
| 164 | const current = workspaceTreeMemory.get(memoryKey) ?? defaultSnapshot(); |
| 165 | workspaceTreeMemory.set(memoryKey, { |
| 166 | ...current, |
| 167 | openDirs: new Set(current.openDirs), |
| 168 | scrollTop: Number.isFinite(scrollTop) && scrollTop >= 0 ? scrollTop : current.scrollTop, |
| 169 | }); |
| 170 | deferredScrollPersistence.schedule(memoryKey); |
| 171 | } |
| 172 | |
| 173 | export function flushWorkspaceTreeMemory(): void { |
| 174 | deferredScrollPersistence.flush(); |
| 175 | } |
| 176 | |
| 177 | export function rememberWorkspaceTreeOpenDirs(memoryKey: string, openDirs: ReadonlySet<string>, visitId: number): void { |
| 178 | rememberWorkspaceTreeState(memoryKey, { openDirs, visitId }); |
| 179 | } |
| 180 | |
| 181 | export function touchWorkspaceTreeVisit(memoryKey: string, visitId: number): void { |
| 182 | rememberWorkspaceTreeState(memoryKey, { visitId }); |
| 183 | } |
| 184 | |
| 185 | export function resetWorkspaceTreeMemoryForTests(): void { |
| 186 | deferredScrollPersistence.cancel(); |
| 187 | workspaceTreeMemory.clear(); |
| 188 | storageHydrated = false; |
| 189 | activeWorkspaceTreeKey = ""; |
| 190 | workspaceTreeVisitSequence = 0; |
| 191 | try { |
| 192 | if (typeof localStorage !== "undefined") localStorage.removeItem(STORAGE_KEY); |
| 193 | } catch { |
| 194 | // Ignore storage cleanup failures in test environments. |
| 195 | } |
| 196 | } |
| 197 |