返回 DeepSeek-Reasonix
projectTreeArchive.ts
根目录 / desktop / frontend / src / lib / projectTreeArchive.ts
1 import { useCallback, useRef, useState } from "react";
2 import { app } from "./bridge";
3 import { 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 import { useT } from "./i18n";
9 import type { TopicRemovalInspection, TopicRemovalRequest } from "../generated/desktopContract.generated";
10
11 export { projectTreeWithoutTopics } from "./projectTreeTopic";
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 | boolean>;
57 commit: () => void;
58 reload: () => Promise<void>;
59 finishPending: () => void;
60 recover: (error: unknown) => Promise<void>;
61 }): Promise<boolean> {
62 try {
63 if (await archive() === false) { finishPending(); return false; }
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 invalidateProjectTopicLists,
155 refreshRef,
156 optimisticallyRemoveTopic,
157 optimisticallyRemoveSession,
158 closeMenu,
159 onTopicsChanged,
160 showToast,
161 sessionErrorMessage,
162 }: {
163 treeRef: { current: ProjectNode[] };
164 invalidateProjectTopicLists: (projectKey: string) => void;
165 refreshRef: { current: ProjectTreeRefresh };
166 optimisticallyRemoveTopic: (topicId: string) => void;
167 optimisticallyRemoveSession: (node: ProjectNode) => void;
168 closeMenu: () => void;
169 onTopicsChanged?: () => Promise<void> | void;
170 showToast: ToastContextValue["showToast"];
171 sessionErrorMessage?: (error: unknown) => string;
172 }) {
173 const t = useT();
174 const [topicRemovalInspections, setTopicRemovalInspections] = useState<Record<string, TopicRemovalInspection>>({});
175 const topicRemovalRequests = useRef(new Map<string, TopicRemovalRequest>());
176 const inspectTopicRemoval = useCallback(async (topicId: string) => {
177 const view = await app.InspectTopicRemoval({ workspaceId: "", topicId });
178 setTopicRemovalInspections(previous => ({ ...previous, [topicId]: view }));
179 return view;
180 }, []);
181 const {
182 trashingTopics,
183 beginTrashingTopic,
184 commitArchiveTombstone,
185 endTrashingTopic,
186 releaseArchiveTombstone,
187 currentArchiveTombstones,
188 } = useProjectTreeArchiveState();
189 const sessionTrashingRef = useRef<Set<string>>(new Set());
190 const [trashingSessions, setTrashingSessions] = useState<Set<string>>(new Set());
191 const archiveQueueRef = useRef<Promise<void>>(Promise.resolve());
192
193 const trashTopic = useCallback(async (topicId: string) => {
194 if (!beginTrashingTopic(topicId)) return;
195 const folderKey = projectTreeFolderKeyForTopic(treeRef.current, topicId);
196 const reloadOptions: ProjectTreeRefreshOptions = {
197 reloadTopicKeys: folderKey ? [folderKey] : undefined,
198 reloadAllTopics: !folderKey,
199 onReloadStarted: () => releaseArchiveTombstone(topicId),
200 };
201 const invalidatedKeys = folderKey
202 ? [folderKey]
203 : treeRef.current.filter((node) => node.kind === "project" || node.kind === "global_folder").map((node) => node.key);
204 closeMenu();
205
206 const queued = enqueueProjectTreeArchive(archiveQueueRef.current, async () => {
207 await runProjectTreeArchiveJob({
208 archive: async () => (await import("./topicRemovalCommand")).removeProjectTopic(topicId, inspectTopicRemoval, topicRemovalRequests.current, t),
209 commit: () => {
210 commitArchiveTombstone(topicId);
211 // Retire requests and their loading/cursor state together before
212 // the post-commit page replaces the visible list.
213 invalidatedKeys.forEach(invalidateProjectTopicLists);
214 optimisticallyRemoveTopic(topicId);
215 },
216 reload: async () => {
217 await refreshRef.current(reloadOptions);
218 await Promise.resolve(onTopicsChanged?.()).catch(() => undefined);
219 },
220 finishPending: () => endTrashingTopic(topicId),
221 recover: async (err) => {
222 showToast(err instanceof Error ? err.message : String(err), "error");
223 await refreshRef.current(reloadOptions);
224 },
225 });
226 });
227 archiveQueueRef.current = queued;
228 await queued;
229 }, [beginTrashingTopic, closeMenu, commitArchiveTombstone, endTrashingTopic, inspectTopicRemoval, invalidateProjectTopicLists, onTopicsChanged, optimisticallyRemoveTopic, refreshRef, releaseArchiveTombstone, showToast, t, treeRef]);
230
231 const trashSession = useCallback(async (target: ProjectNode) => {
232 const sessionPath = (target.sessionPath ?? "").trim();
233 const targetKey = projectSessionIdentity(target);
234 if (!sessionPath || sessionTrashingRef.current.has(targetKey)) return;
235 const folderKey = projectTreeFolderKeyForSession(treeRef.current, sessionPath);
236 const reloadOptions: ProjectTreeRefreshOptions = {
237 reloadTopicKeys: folderKey ? [folderKey] : undefined,
238 reloadAllTopics: !folderKey,
239 };
240 sessionTrashingRef.current = projectTreeTrashingTopics(sessionTrashingRef.current, targetKey, true);
241 setTrashingSessions(sessionTrashingRef.current);
242 closeMenu();
243
244 const queued = enqueueProjectTreeArchive(archiveQueueRef.current, async () => {
245 await runProjectTreeArchiveJob({
246 archive: async () => {
247 const receipt = await app.ArchiveSessionTarget({ ref: target.session, source: target.source, sessionPath });
248 if (receipt.committed) sessionLifecycleFences.archive(target, receipt);
249 if (receipt.committed && receipt.outcome === "archived_copy") showToast(t("projectTree.archivedCopy"), "info");
250 if (receipt.committed && receipt.outcome === "already_removed") showToast(t("projectTree.alreadyRemovedSource"), "info");
251 },
252 commit: () => {
253 const invalidatedKeys = folderKey ? [folderKey] : treeRef.current.filter((node) => node.kind === "project" || node.kind === "global_folder").map((node) => node.key);
254 invalidatedKeys.forEach(invalidateProjectTopicLists);
255 optimisticallyRemoveSession(target);
256 },
257 reload: async () => {
258 await refreshRef.current(reloadOptions);
259 await Promise.resolve(onTopicsChanged?.()).catch(() => undefined);
260 },
261 finishPending: () => {
262 sessionTrashingRef.current = projectTreeTrashingTopics(sessionTrashingRef.current, targetKey, false);
263 setTrashingSessions(sessionTrashingRef.current);
264 },
265 recover: async (err) => {
266 showToast(sessionErrorMessage?.(err) ?? (err instanceof Error ? err.message : String(err)), "error");
267 await refreshRef.current(reloadOptions);
268 },
269 });
270 });
271 archiveQueueRef.current = queued;
272 await queued;
273 }, [closeMenu, invalidateProjectTopicLists, onTopicsChanged, optimisticallyRemoveSession, refreshRef, sessionErrorMessage, showToast, t, treeRef]);
274
275 return { trashingTopics, trashingSessions, currentArchiveTombstones, trashTopic, trashSession, inspectTopicRemoval, topicRemovalInspections };
276 }
277
277 lines TYPESCRIPT