| 1 | import * as projectMemory from "./workspaceTreeMemory"; |
| 2 | import { createWorkspaceTreePersistenceScheduler } from "./workspaceTreePersistence"; |
| 3 | import type { WorkspaceTreeMemorySnapshot } from "./workspaceTreeMemory"; |
| 4 | |
| 5 | interface ViewState { |
| 6 | openDirs: string[]; |
| 7 | selectedFilePath: string | null; |
| 8 | selectedChangePath: string | null; |
| 9 | openTabs: string[]; |
| 10 | filter: string; |
| 11 | scrollTop: number; |
| 12 | treeWidth: number | null; |
| 13 | treeWidthMode: "manual" | "even"; |
| 14 | } |
| 15 | interface ProjectViews { |
| 16 | seeded: boolean; |
| 17 | views: Record<string, ViewState>; |
| 18 | } |
| 19 | type Snapshot = WorkspaceTreeMemorySnapshot & { openTabs?: string[]; filter?: string }; |
| 20 | const STORAGE_KEY = "reasonix.workspaceViews.v1"; |
| 21 | const PREFIX = "dock-view:"; |
| 22 | const projects = new Map<string, ProjectViews>(); |
| 23 | let hydrated = false; |
| 24 | let writable = true; |
| 25 | |
| 26 | export function workspaceViewMemoryKey(project: string, tab: string, seedLegacy = false): string { |
| 27 | return PREFIX + JSON.stringify([project, tab, seedLegacy]); |
| 28 | } |
| 29 | |
| 30 | function identity(key: string): [string, string, boolean] | null { |
| 31 | if (!key.startsWith(PREFIX)) return null; |
| 32 | try { |
| 33 | const value = JSON.parse(key.slice(PREFIX.length)); |
| 34 | return Array.isArray(value) && typeof value[0] === "string" && typeof value[1] === "string" |
| 35 | ? [value[0], value[1], value[2] === true] : null; |
| 36 | } catch { return null; } |
| 37 | } |
| 38 | |
| 39 | function sanitize(value: Partial<ViewState> = {}): ViewState { |
| 40 | const paths = (items: unknown): string[] => Array.isArray(items) |
| 41 | ? items.filter((path): path is string => typeof path === "string") : []; |
| 42 | return { |
| 43 | openDirs: paths(value.openDirs).length ? paths(value.openDirs) : [""], |
| 44 | selectedFilePath: typeof value.selectedFilePath === "string" ? value.selectedFilePath : null, |
| 45 | selectedChangePath: typeof value.selectedChangePath === "string" ? value.selectedChangePath : null, |
| 46 | openTabs: [...new Set(paths(value.openTabs))].slice(-5), |
| 47 | filter: typeof value.filter === "string" ? value.filter : "", |
| 48 | scrollTop: Number.isFinite(value.scrollTop) && value.scrollTop! >= 0 ? value.scrollTop! : 0, |
| 49 | treeWidth: Number.isFinite(value.treeWidth) && value.treeWidth! > 0 ? value.treeWidth! : null, |
| 50 | treeWidthMode: value.treeWidthMode === "even" ? "even" : "manual", |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | function hydrate() { |
| 55 | if (hydrated) return; |
| 56 | hydrated = true; |
| 57 | try { |
| 58 | const data = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); |
| 59 | if (!data) return; |
| 60 | if (data.version !== 1) { writable = false; return; } |
| 61 | for (const [key, value] of Object.entries(data.projects ?? {})) { |
| 62 | const project = value as ProjectViews; |
| 63 | if (!project || typeof project.views !== "object" || !project.views) continue; |
| 64 | projects.set(key, { seeded: project.seeded === true, views: Object.fromEntries( |
| 65 | Object.entries(project.views).filter(([, view]) => view && typeof view === "object") |
| 66 | .map(([id, view]) => [id, sanitize(view)]), |
| 67 | ) }); |
| 68 | } |
| 69 | } catch { /* Storage is optional. */ } |
| 70 | } |
| 71 | |
| 72 | function persist() { |
| 73 | if (!writable) return; |
| 74 | try { |
| 75 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, projects: Object.fromEntries(projects) })); |
| 76 | } catch { /* Keep the session's in-memory state when storage is unavailable. */ } |
| 77 | } |
| 78 | const deferred = createWorkspaceTreePersistenceScheduler(persist); |
| 79 | |
| 80 | function resolve(key: string) { |
| 81 | const id = identity(key); |
| 82 | if (!id) return null; |
| 83 | hydrate(); |
| 84 | const [projectKey, tab, seedLegacy] = id; |
| 85 | const project = projects.get(projectKey) ?? { seeded: false, views: Object.create(null) as Record<string, ViewState> }; |
| 86 | const legacy = projectMemory.readWorkspaceTreeMemory(projectKey); |
| 87 | if (!Object.prototype.hasOwnProperty.call(project.views, tab)) { |
| 88 | const seed = !project.seeded && seedLegacy ? legacy : null; |
| 89 | project.views[tab] = sanitize(seed ? { |
| 90 | ...seed, openDirs: [...seed.openDirs], |
| 91 | openTabs: seed.selectedFilePath ? [seed.selectedFilePath] : [], |
| 92 | } : {}); |
| 93 | if (seedLegacy) project.seeded = true; |
| 94 | projects.set(projectKey, project); |
| 95 | persist(); |
| 96 | } |
| 97 | return { projectKey, project, tab, legacy }; |
| 98 | } |
| 99 | |
| 100 | export function readWorkspaceTreeMemory(key: string): Snapshot | null { |
| 101 | const view = resolve(key); |
| 102 | if (!view) return projectMemory.readWorkspaceTreeMemory(key); |
| 103 | const state = view.project.views[view.tab]; |
| 104 | return { |
| 105 | ...state, openDirs: new Set(state.openDirs), openTabs: [...state.openTabs], visitId: 0, |
| 106 | recentPaths: view.legacy?.recentPaths ?? [], |
| 107 | dockTreeWidth: view.legacy?.dockTreeWidth ?? null, |
| 108 | dockPreviewWidth: view.legacy?.dockPreviewWidth ?? null, |
| 109 | }; |
| 110 | } |
| 111 | |
| 112 | export function rememberWorkspaceTreeState( |
| 113 | key: string, patch: Partial<Omit<Snapshot, "openDirs">> & { openDirs?: ReadonlySet<string> }, |
| 114 | ) { |
| 115 | const view = resolve(key); |
| 116 | if (!view) { projectMemory.rememberWorkspaceTreeState(key, patch); return; } |
| 117 | const { recentPaths, dockTreeWidth, dockPreviewWidth, visitId: _visitId, ...browse } = patch; |
| 118 | const projectPatch = { |
| 119 | ...(recentPaths !== undefined ? { recentPaths } : {}), |
| 120 | ...(dockTreeWidth !== undefined ? { dockTreeWidth } : {}), |
| 121 | ...(dockPreviewWidth !== undefined ? { dockPreviewWidth } : {}), |
| 122 | }; |
| 123 | if (Object.keys(projectPatch).length) projectMemory.rememberWorkspaceTreeState(view.projectKey, projectPatch); |
| 124 | if (Object.keys(browse).length) { |
| 125 | view.project.views[view.tab] = sanitize({ |
| 126 | ...view.project.views[view.tab], ...browse, |
| 127 | openDirs: browse.openDirs ? [...browse.openDirs] : view.project.views[view.tab].openDirs, |
| 128 | }); |
| 129 | deferred.cancel(); |
| 130 | persist(); |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | export function rememberWorkspaceTreeScroll(key: string, scrollTop: number) { |
| 135 | const view = resolve(key); |
| 136 | if (!view) { projectMemory.rememberWorkspaceTreeScroll(key, scrollTop); return; } |
| 137 | if (!Number.isFinite(scrollTop) || scrollTop < 0) return; |
| 138 | view.project.views[view.tab] = { ...view.project.views[view.tab], scrollTop }; |
| 139 | deferred.schedule(key); |
| 140 | } |
| 141 | export function flushWorkspaceTreeMemory() { deferred.flush(); projectMemory.flushWorkspaceTreeMemory(); } |
| 142 | export function workspaceTreeVisitId(key: string) { return identity(key) ? 0 : projectMemory.workspaceTreeVisitId(key); } |
| 143 | export function touchWorkspaceTreeVisit(key: string, visitId: number) { |
| 144 | if (!identity(key)) projectMemory.touchWorkspaceTreeVisit(key, visitId); |
| 145 | } |
| 146 | export function rememberWorkspaceTreeOpenDirs(key: string, openDirs: ReadonlySet<string>, visitId: number) { |
| 147 | rememberWorkspaceTreeState(key, { openDirs, visitId }); |
| 148 | } |
| 149 | |
| 150 | export function reloadWorkspaceViewMemoryForTests() { |
| 151 | deferred.flush(); |
| 152 | projects.clear(); |
| 153 | hydrated = false; |
| 154 | writable = true; |
| 155 | } |
| 156 |