返回 DeepSeek-Reasonix
projectTreeArchive.ts
根目录 / desktop / frontend / src / lib / projectTreeArchive.ts
1 import { useCallback, useRef, useState } from "react";
2 import { app } from "./bridge";
3 import { invalidateProjectTreeTopicLoads, projectTreeFolderKeyForSession, projectTreeFolderKeyForTopic } from "./projectTreeTopic";
4 import type { ToastContextValue } from "./toast";
5 import type { ProjectNode } from "./types";
6 import { sessionLifecycleFences } from "./sessionLifecycleFences";
7 import { projectSessionIdentity } from "./projectSessionIdentity";
8
9 export { projectTreeWithoutTopics } from "./projectTreeTopic";
10
11 type TopicPageState = { itemKeys?: string[]; nextCursor?: string; loading: boolean; initialized?: boolean; error?: string };
12
13 export type ProjectTreeRefreshOptions = {
14 reloadTopicKeys?: string[];
15 reloadAllTopics?: boolean;
16 onReloadStarted?: () => void;
17 };
18
19 export type ProjectTreeRefresh = (options?: ProjectTreeRefreshOptions) => Promise<void>;
20
21 export async function reloadProjectTreeTopics(
22 projects: ProjectNode[],
23 options: ProjectTreeRefreshOptions | undefined,
24 load: (project: ProjectNode) => Promise<void>,
25 ): Promise<void> {
26 const keys = new Set(options?.reloadTopicKeys ?? []);
27 const targets = projects.filter((project) => options?.reloadAllTopics || keys.has(project.key));
28 const pendingLoads = targets.map(load);
29 if (pendingLoads.length > 0) options?.onReloadStarted?.();
30 await Promise.all(pendingLoads);
31 }
32
33 export function enqueueProjectTreeArchive(previous: Promise<void>, work: () => Promise<void>): Promise<void> {
34 return previous.catch(() => undefined).then(work);
35 }
36
37 export function projectTreeTopicArchiveTargetKey(
38 scope: "global" | "project",
39 workspaceRoot: string,
40 topicId: string,
41 ): string {
42 return JSON.stringify(["topic", scope, workspaceRoot.trim(), topicId.trim()]);
43 }
44
45 export function projectTreeSessionArchiveTargetKey(sessionPath: string): string {
46 return JSON.stringify(["session", sessionPath.trim()]);
47 }
48
49 export async function runProjectTreeArchiveJob({
50 archive,
51 commit,
52 reload,
53 finishPending,
54 recover,
55 }: {
56 archive: () => Promise<void>;
57 commit: () => void;
58 reload: () => Promise<void>;
59 finishPending: () => void;
60 recover: (error: unknown) => Promise<void>;
61 }): Promise<boolean> {
62 try {
63 await archive();
64 } catch (error) {
65 // Failed mutations must become visible to the recovery reload.
66 finishPending();
67 await recover(error);
68 return false;
69 }
70 try {
71 // A tombstone is a post-commit stale-response fence, not an optimistic
72 // archive. Installing it only after backend success keeps rejected topics
73 // visible throughout the mutation and its recovery reload.
74 commit();
75 // Keep the visible pending state active until the canonical folder page
76 // has landed. The caller may release its stale-response tombstone once
77 // that reload has acquired a newer request generation.
78 await reload();
79 return true;
80 } finally {
81 finishPending();
82 }
83 }
84
85 export function projectTreeTrashingTopics(previous: Set<string>, topicId: string, trashing: boolean): Set<string> {
86 const id = topicId.trim();
87 if (!id || previous.has(id) === trashing) return previous;
88 const next = new Set(previous);
89 if (trashing) next.add(id);
90 else next.delete(id);
91 return next;
92 }
93
94 export async function archiveProjectTreeSession({
95 sessionPath,
96 archiveTarget,
97 refresh,
98 topicsChanged,
99 showError,
100 }: {
101 sessionPath: string;
102 archiveTarget: (selector: { sessionPath: string }) => Promise<unknown>;
103 refresh: () => Promise<void>;
104 topicsChanged?: () => Promise<void> | void;
105 showError: (error: unknown) => void;
106 }): Promise<boolean> {
107 try {
108 await archiveTarget({ sessionPath });
109 await refresh();
110 await Promise.resolve(topicsChanged?.()).catch(() => undefined);
111 return true;
112 } catch (error) {
113 showError(error);
114 await refresh().catch(() => undefined);
115 return false;
116 }
117 }
118
119 export function useProjectTreeArchiveState() {
120 const topicsRef = useRef<Set<string>>(new Set());
121 const tombstonesRef = useRef<Set<string>>(new Set());
122 const [topics, setTopics] = useState<Set<string>>(new Set());
123 const begin = useCallback((topicId: string) => {
124 const id = topicId.trim();
125 if (!id || topicsRef.current.has(id)) return false;
126 topicsRef.current = projectTreeTrashingTopics(topicsRef.current, id, true);
127 setTopics(topicsRef.current);
128 return true;
129 }, []);
130 const commit = useCallback((topicId: string) => {
131 tombstonesRef.current = projectTreeTrashingTopics(tombstonesRef.current, topicId, true);
132 }, []);
133 const end = useCallback((topicId: string) => {
134 topicsRef.current = projectTreeTrashingTopics(topicsRef.current, topicId, false);
135 tombstonesRef.current = projectTreeTrashingTopics(tombstonesRef.current, topicId, false);
136 setTopics(topicsRef.current);
137 }, []);
138 const releaseTombstone = useCallback((topicId: string) => {
139 tombstonesRef.current = projectTreeTrashingTopics(tombstonesRef.current, topicId, false);
140 }, []);
141 const currentTombstones = useCallback((): ReadonlySet<string> => new Set([...tombstonesRef.current, ...sessionLifecycleFences.keys()]), []);
142 return {
143 trashingTopics: topics,
144 beginTrashingTopic: begin,
145 commitArchiveTombstone: commit,
146 endTrashingTopic: end,
147 releaseArchiveTombstone: releaseTombstone,
148 currentArchiveTombstones: currentTombstones,
149 };
150 }
151
152 export function useProjectTreeArchiveController({
153 treeRef,
154 topicLoadSeqRef,
155 topicLoadPendingRef,
156 topicPageStateRef,
157 updateTopicPageState,
158 refreshRef,
159 optimisticallyRemoveTopic,
160 optimisticallyRemoveSession,
161 closeMenu,
162 onTopicsChanged,
163 showToast,
164 sessionErrorMessage,
165 }: {
166 treeRef: { current: ProjectNode[] };
167 topicLoadSeqRef: { current: Record<string, number> };
168 topicLoadPendingRef: { current: Record<string, number> };
169 topicPageStateRef: { current: Record<string, TopicPageState> };
170 updateTopicPageState: (key: string, next: TopicPageState) => void;
171 refreshRef: { current: ProjectTreeRefresh };
172 optimisticallyRemoveTopic: (topicId: string) => void;
173 optimisticallyRemoveSession: (node: ProjectNode) => void;
174 closeMenu: () => void;
175 onTopicsChanged?: () => Promise<void> | void;
176 showToast: ToastContextValue["showToast"];
177 sessionErrorMessage?: (error: unknown) => string;
178 }) {
179 const {
180 trashingTopics,
181 beginTrashingTopic,
182 commitArchiveTombstone,
183 endTrashingTopic,
184 releaseArchiveTombstone,
185 currentArchiveTombstones,
186 } = useProjectTreeArchiveState();
187 const sessionTrashingRef = useRef<Set<string>>(new Set());
188 const [trashingSessions, setTrashingSessions] = useState<Set<string>>(new Set());
189 const archiveQueueRef = useRef<Promise<void>>(Promise.resolve());
190
191 const trashTopic = useCallback(async (topicId: string) => {
192 if (!beginTrashingTopic(topicId)) return;
193 const folderKey = projectTreeFolderKeyForTopic(treeRef.current, topicId);
194 const reloadOptions: ProjectTreeRefreshOptions = {
195 reloadTopicKeys: folderKey ? [folderKey] : undefined,
196 reloadAllTopics: !folderKey,
197 onReloadStarted: () => releaseArchiveTombstone(topicId),
198 };
199 const invalidatedKeys = folderKey
200 ? [folderKey]
201 : treeRef.current.filter((node) => node.kind === "project" || node.kind === "global_folder").map((node) => node.key);
202 closeMenu();
203
204 const queued = enqueueProjectTreeArchive(archiveQueueRef.current, async () => {
205 await runProjectTreeArchiveJob({
206 archive: () => app.TrashTopic(topicId),
207 commit: () => {
208 commitArchiveTombstone(topicId);
209 // Fence every load that captured the catalog before backend commit,
210 // then remove the topic while the tombstone covers newer arrivals.
211 invalidateProjectTreeTopicLoads(topicLoadSeqRef.current, invalidatedKeys);
212 for (const folderKey of invalidatedKeys) {
213 const prefix = `${folderKey}\u001f`;
214 for (const key of Object.keys(topicLoadPendingRef.current)) {
215 if (key === folderKey || key.startsWith(prefix)) delete topicLoadPendingRef.current[key];
216 }
217 for (const [key, state] of Object.entries(topicPageStateRef.current)) {
218 if (key !== folderKey && !key.startsWith(prefix)) continue;
219 updateTopicPageState(key, { ...state, nextCursor: undefined, loading: false, initialized: false, error: undefined });
220 }
221 }
222 optimisticallyRemoveTopic(topicId);
223 },
224 reload: async () => {
225 await refreshRef.current(reloadOptions);
226 await Promise.resolve(onTopicsChanged?.()).catch(() => undefined);
227 },
228 finishPending: () => endTrashingTopic(topicId),
229 recover: async (err) => {
230 showToast(err instanceof Error ? err.message : String(err), "error");
231 await refreshRef.current(reloadOptions);
232 },
233 });
234 });
235 archiveQueueRef.current = queued;
236 await queued;
237 }, [beginTrashingTopic, closeMenu, commitArchiveTombstone, endTrashingTopic, onTopicsChanged, optimisticallyRemoveTopic, refreshRef, releaseArchiveTombstone, showToast, topicLoadPendingRef, topicLoadSeqRef, topicPageStateRef, treeRef, updateTopicPageState]);
238
239 const trashSession = useCallback(async (target: ProjectNode) => {
240 const sessionPath = (target.sessionPath ?? "").trim();
241 const targetKey = projectSessionIdentity(target);
242 if (!sessionPath || sessionTrashingRef.current.has(targetKey)) return;
243 const folderKey = projectTreeFolderKeyForSession(treeRef.current, sessionPath);
244 const reloadOptions: ProjectTreeRefreshOptions = {
245 reloadTopicKeys: folderKey ? [folderKey] : undefined,
246 reloadAllTopics: !folderKey,
247 };
248 sessionTrashingRef.current = projectTreeTrashingTopics(sessionTrashingRef.current, targetKey, true);
249 setTrashingSessions(sessionTrashingRef.current);
250 closeMenu();
251
252 const queued = enqueueProjectTreeArchive(archiveQueueRef.current, async () => {
253 await runProjectTreeArchiveJob({
254 archive: async () => {
255 const receipt = await app.ArchiveSessionTarget({ ref: target.session, source: target.source, sessionPath });
256 if (receipt.committed) sessionLifecycleFences.archive(target, receipt);
257 },
258 commit: () => {
259 const invalidatedKeys = folderKey ? [folderKey] : treeRef.current.filter((node) => node.kind === "project" || node.kind === "global_folder").map((node) => node.key);
260 invalidateProjectTreeTopicLoads(topicLoadSeqRef.current, invalidatedKeys);
261 optimisticallyRemoveSession(target);
262 },
263 reload: async () => {
264 await refreshRef.current(reloadOptions);
265 await Promise.resolve(onTopicsChanged?.()).catch(() => undefined);
266 },
267 finishPending: () => {
268 sessionTrashingRef.current = projectTreeTrashingTopics(sessionTrashingRef.current, targetKey, false);
269 setTrashingSessions(sessionTrashingRef.current);
270 },
271 recover: async (err) => {
272 showToast(sessionErrorMessage?.(err) ?? (err instanceof Error ? err.message : String(err)), "error");
273 await refreshRef.current(reloadOptions);
274 },
275 });
276 });
277 archiveQueueRef.current = queued;
278 await queued;
279 }, [closeMenu, commitArchiveTombstone, onTopicsChanged, optimisticallyRemoveSession, refreshRef, releaseArchiveTombstone, sessionErrorMessage, showToast, topicLoadSeqRef, treeRef]);
280
281 return { trashingTopics, trashingSessions, currentArchiveTombstones, trashTopic, trashSession };
282 }
283
283 lines TYPESCRIPT