返回 DeepSeek-Reasonix
workspaceRefreshStore.ts
根目录 / desktop / frontend / src / lib / workspaceRefreshStore.ts
1 import { useCallback, useEffect, useSyncExternalStore } from "react";
2 import { app, onEvent } from "./bridge";
3 import { tabMetaFallbackDelay } from "./tabMetaRefresh";
4 import type { WireWorkspaceChanged, WorkspaceRevisions, WorkspaceWatchState } from "./types";
5
6 export interface WorkspaceRefreshSnapshot {
7 revisions: WorkspaceRevisions;
8 changes: WireWorkspaceChanged["changes"];
9 allPaths: boolean;
10 source: WireWorkspaceChanged["source"];
11 watchState: WorkspaceWatchState;
12 sequence: number;
13 }
14
15 const zeroRevisions = (): WorkspaceRevisions => ({ content: 0, tree: 0, workingTree: 0, gitMeta: 0, session: 0 });
16 const EMPTY_SNAPSHOT: WorkspaceRefreshSnapshot = {
17 revisions: zeroRevisions(), changes: [], allPaths: false, source: "reconcile", watchState: "unavailable", sequence: 0,
18 };
19 const emptySnapshot = (): WorkspaceRefreshSnapshot => EMPTY_SNAPSHOT;
20
21 const snapshots = new Map<string, WorkspaceRefreshSnapshot>();
22 const listeners = new Map<string, Set<() => void>>();
23 const activeScopeByTab = new Map<string, string>();
24
25 function key(tabId: string, scopeKey: string): string {
26 return `${tabId}\u0000${scopeKey}`;
27 }
28
29 function notify(tabId: string, scopeKey?: string): void {
30 const keys = scopeKey ? [key(tabId, scopeKey)] : Array.from(listeners.keys()).filter((candidate) => candidate.startsWith(`${tabId}\u0000`));
31 for (const candidate of keys) listeners.get(candidate)?.forEach((listener) => listener());
32 }
33
34 function replace(tabId: string, scopeKey: string, next: WorkspaceRefreshSnapshot): void {
35 const k = key(tabId, scopeKey);
36 snapshots.set(k, next);
37 notify(tabId, scopeKey);
38 }
39
40 function revisionsOlder(current: WorkspaceRevisions, previous: WorkspaceRevisions): boolean {
41 return current.content < previous.content || current.tree < previous.tree || current.workingTree < previous.workingTree || current.gitMeta < previous.gitMeta || current.session < previous.session;
42 }
43
44 function acceptEvent(tabId: string, event: WireWorkspaceChanged): void {
45 const scopeKey = activeScopeByTab.get(tabId);
46 if (!scopeKey) return;
47 const snapshotKey = key(tabId, scopeKey);
48 if (!listeners.has(snapshotKey)) return;
49 const previous = snapshots.get(snapshotKey) ?? emptySnapshot();
50 const current = event.revisions;
51 if (revisionsOlder(current, previous.revisions)) return;
52 const next: WorkspaceRefreshSnapshot = { ...event, sequence: previous.sequence + 1, changes: Array.isArray(event.changes) ? event.changes : [] };
53 snapshots.set(snapshotKey, next);
54 notify(tabId, scopeKey);
55 }
56
57 let stopEvents: (() => void) | null = null;
58 function ensureEvents(): void {
59 if (stopEvents) return;
60 stopEvents = onEvent((event) => {
61 if (event.kind === "workspace_changed" && event.tabId && event.workspace) {
62 acceptEvent(event.tabId, event.workspace);
63 }
64 });
65 }
66
67 async function workspaceRevisionForTab(tabId: string) {
68 const binding = app.WorkspaceRevisionForTab;
69 if (typeof binding !== "function") return undefined;
70 return binding(tabId);
71 }
72
73 type WorkspaceRevisionResult = Awaited<ReturnType<typeof workspaceRevisionForTab>>;
74
75 function applyWorkspaceReconciliation(tabId: string, scopeKey: string, result: WorkspaceRevisionResult, forceVisible: boolean): void {
76 if (!result || activeScopeByTab.get(tabId) !== scopeKey) return;
77 const snapshotKey = key(tabId, scopeKey);
78 if (!listeners.has(snapshotKey)) return;
79 const previous = snapshots.get(snapshotKey) ?? EMPTY_SNAPSHOT;
80 let revisions = result.revisions ?? zeroRevisions();
81 let watchState = result.watchState ?? "unavailable";
82 // An event may advance the store while this bridge request is in flight.
83 // Never let the older reconciliation response move a scope backwards. An
84 // explicit focus fallback still invalidates visible resources, but retains
85 // the newer event snapshot's revisions and watcher state.
86 if (revisionsOlder(revisions, previous.revisions)) {
87 if (!forceVisible) return;
88 revisions = previous.revisions;
89 watchState = previous.watchState;
90 }
91 const changed = revisionsOlder(previous.revisions, revisions);
92 if (!forceVisible && !changed && watchState === previous.watchState) return;
93 // Reconciliation is also the bounded fallback for degraded watchers and
94 // authorized external paths that are intentionally not watched. Re-issue
95 // an all-paths invalidation on explicit focus even when the hub revision is
96 // unchanged, while ordinary mount/runtime checks stay revision-driven.
97 replace(tabId, scopeKey, {
98 revisions,
99 changes: [],
100 allPaths: true,
101 source: "reconcile",
102 watchState,
103 sequence: previous.sequence + 1,
104 });
105 }
106
107 // Kept as an explicit lifecycle hook for tests and hot-reload hosts. Production
108 // keeps the subscription for the lifetime of the webview.
109 export function disposeWorkspaceRefreshStore(): void {
110 stopEvents?.();
111 stopEvents = null;
112 snapshots.clear();
113 listeners.clear();
114 activeScopeByTab.clear();
115 }
116
117 export function useWorkspaceRefresh(tabId: string, scopeKey: string, enabled: boolean): WorkspaceRefreshSnapshot {
118 const snapshotKey = key(tabId, scopeKey);
119 const subscribe = useCallback((listener: () => void) => {
120 let set = listeners.get(snapshotKey);
121 if (!set) {
122 set = new Set();
123 listeners.set(snapshotKey, set);
124 }
125 set.add(listener);
126 return () => {
127 set?.delete(listener);
128 if (set?.size === 0) {
129 listeners.delete(snapshotKey);
130 snapshots.delete(snapshotKey);
131 if (activeScopeByTab.get(tabId) === scopeKey) activeScopeByTab.delete(tabId);
132 }
133 };
134 }, [snapshotKey]);
135 const getSnapshot = useCallback(() => snapshots.get(snapshotKey) ?? emptySnapshot(), [snapshotKey]);
136 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
137
138 useEffect(() => {
139 if (!enabled) return;
140 activeScopeByTab.set(tabId, scopeKey);
141 ensureEvents();
142 let live = true;
143 workspaceRevisionForTab(tabId).then((result) => {
144 if (!live || !result) return;
145 const previous = getSnapshot();
146 const revisions = result.revisions ?? zeroRevisions();
147 if (revisionsOlder(revisions, previous.revisions)) return;
148 replace(tabId, scopeKey, {
149 revisions,
150 changes: [],
151 allPaths: true,
152 source: "reconcile",
153 watchState: result.watchState ?? "unavailable",
154 sequence: previous.sequence + 1,
155 });
156 }).catch(() => undefined);
157 return () => {
158 live = false;
159 if (activeScopeByTab.get(tabId) === scopeKey) activeScopeByTab.delete(tabId);
160 };
161 }, [enabled, scopeKey, tabId]);
162
163 return snapshot;
164 }
165
166 export function markWorkspaceRefresh(tabId: string, scopeKey: string): void {
167 const previous = snapshots.get(key(tabId, scopeKey)) ?? emptySnapshot();
168 replace(tabId, scopeKey, { ...previous, allPaths: true, source: "reconcile", sequence: previous.sequence + 1 });
169 }
170
171 export async function reconcileWorkspaceRefresh(
172 tabId: string,
173 scopeKey: string,
174 options?: { forceVisible?: boolean },
175 ): Promise<void> {
176 try {
177 activeScopeByTab.set(tabId, scopeKey);
178 const result = await workspaceRevisionForTab(tabId);
179 applyWorkspaceReconciliation(tabId, scopeKey, result, options?.forceVisible === true);
180 } catch {
181 // A transient runtime rebuild must not erase the last good snapshot.
182 }
183 }
184
185 export function startWorkspaceFocusReconciliation(
186 activeTabId: string | undefined,
187 workspaceScopeKey: string,
188 refreshTabMetas: () => unknown,
189 ): () => void {
190 let cancelled = false;
191 let timer: number | undefined;
192 let focusTimer: number | undefined;
193 const schedule = () => {
194 if (cancelled) return;
195 timer = window.setTimeout(() => {
196 void refreshTabMetas();
197 schedule();
198 }, tabMetaFallbackDelay(document.visibilityState));
199 };
200 const refreshAndSchedule = (forceVisible = false) => {
201 if (timer !== undefined) window.clearTimeout(timer);
202 timer = undefined;
203 void refreshTabMetas();
204 if (activeTabId) void reconcileWorkspaceRefresh(activeTabId, workspaceScopeKey, { forceVisible });
205 schedule();
206 };
207 const requestVisibleRefresh = () => {
208 if (cancelled || focusTimer !== undefined) return;
209 // Focus and visibility commonly fire together; collapse the pair into
210 // one bounded reconciliation for the foreground transition.
211 focusTimer = window.setTimeout(() => {
212 focusTimer = undefined;
213 refreshAndSchedule(true);
214 }, 0);
215 };
216 const onVisibilityChange = () => {
217 if (document.visibilityState === "visible") requestVisibleRefresh();
218 else {
219 if (timer !== undefined) window.clearTimeout(timer);
220 schedule();
221 }
222 };
223 const onFocus = () => {
224 if (document.visibilityState === "visible") requestVisibleRefresh();
225 };
226 refreshAndSchedule(false);
227 document.addEventListener("visibilitychange", onVisibilityChange);
228 window.addEventListener("focus", onFocus);
229 return () => {
230 cancelled = true;
231 if (timer !== undefined) window.clearTimeout(timer);
232 if (focusTimer !== undefined) window.clearTimeout(focusTimer);
233 document.removeEventListener("visibilitychange", onVisibilityChange);
234 window.removeEventListener("focus", onFocus);
235 };
236 }
237
238 export default startWorkspaceFocusReconciliation;
239
240 // Deterministic seams for the store's scope and monotonicity contracts.
241 export function resetWorkspaceRefreshStoreForTests(): void {
242 disposeWorkspaceRefreshStore();
243 }
244
245 export function activateWorkspaceRefreshScopeForTests(tabId: string, scopeKey: string): void {
246 activeScopeByTab.set(tabId, scopeKey);
247 listeners.set(key(tabId, scopeKey), new Set());
248 }
249
250 export function acceptWorkspaceRefreshForTests(tabId: string, event: WireWorkspaceChanged): void {
251 acceptEvent(tabId, event);
252 }
253
254 export function reconcileWorkspaceRefreshForTests(
255 tabId: string,
256 scopeKey: string,
257 result: NonNullable<WorkspaceRevisionResult>,
258 forceVisible = false,
259 ): void {
260 activeScopeByTab.set(tabId, scopeKey);
261 applyWorkspaceReconciliation(tabId, scopeKey, result, forceVisible);
262 }
263
264 export function workspaceRefreshSnapshotForTests(tabId: string, scopeKey: string): WorkspaceRefreshSnapshot {
265 return snapshots.get(key(tabId, scopeKey)) ?? emptySnapshot();
266 }
267
267 lines TYPESCRIPT