返回 DeepSeek-Reasonix
workspaceTreeMemory.ts
根目录 / desktop / frontend / src / lib / workspaceTreeMemory.ts
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
197 lines TYPESCRIPT