返回 DeepSeek-Reasonix
HistoricalRecoveryList.tsx
根目录 / desktop / frontend / src / components / HistoricalRecoveryList.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import { app, onProjectTreeChanged } from "../lib/bridge";
3 import { asArray } from "../lib/array";
4 import { useT } from "../lib/i18n";
5 import type { RecoveryEntryView, SessionRestoreResult } from "../generated/desktopContract.generated";
6 import type { HistoryMessage } from "../lib/types";
7 import type { SessionRef } from "../lib/sessionRef";
8 import { useManagementT } from "../lib/managementLocale";
9
10 export function HistoricalRecoveryList({ active, onOpenSession }: {
11 active: boolean;
12 onOpenSession?: (ref: SessionRef) => Promise<void>;
13 }) {
14 const t = useT();
15 const m = useManagementT();
16 const [items, setItems] = useState<RecoveryEntryView[]>([]);
17 const [query, setQuery] = useState("");
18 const [nextCursor, setNextCursor] = useState("");
19 const [error, setError] = useState("");
20 const [loading, setLoading] = useState(false);
21 const [busy, setBusy] = useState(false);
22 const [preview, setPreview] = useState<HistoryMessage[]>([]);
23 const [restored, setRestored] = useState<SessionRestoreResult>();
24 const [workspaces, setWorkspaces] = useState<Record<string,string>>({});
25 const registryGeneration = useRef(0);
26 const surfaceGeneration = useRef(0);
27 const pendingRequests = useRef(new Map<string, import("../generated/desktopContract.generated").SessionLifecycleRequest>());
28 const generation = useRef(0);
29 const previewGeneration = useRef(0);
30 const mutating = useRef(false);
31 const reload = useCallback(async (cursor = "") => {
32 const seq = ++generation.current;
33 setLoading(true);
34 try {
35 const page = await app.ListRecoveryEntries(query, cursor, 50);
36 if (seq !== generation.current) return;
37 const rows = asArray<RecoveryEntryView>(page.items);
38 registryGeneration.current = page.generation;
39 setNextCursor(page.nextCursor ?? "");
40 setItems(current => cursor ? [...current, ...rows.filter(row => !current.some(old => old.id === row.id))] : rows);
41 setError("");
42 } catch (err) {
43 if (seq === generation.current) setError(err instanceof Error ? err.message : String(err));
44 throw err;
45 } finally {
46 if (seq === generation.current) setLoading(false);
47 }
48 }, [query]);
49 useEffect(() => {
50 if (!active) return;
51 void reload().catch(() => {});
52 const unsubscribe = onProjectTreeChanged(() => { if (!mutating.current) void reload().catch(() => {}); });
53 return () => { generation.current++; previewGeneration.current++; surfaceGeneration.current++; unsubscribe(); };
54 }, [active, reload]);
55 const restore = async (entry: RecoveryEntryView) => {
56 if (mutating.current) return;
57 const surface = surfaceGeneration.current;
58 mutating.current = true; setBusy(true);
59 const seq = ++generation.current;
60 const key = `${entry.id}:${workspaces[entry.id] ?? ""}`;
61 try {
62 const request = pendingRequests.current.get(key) ?? { operationId: crypto.randomUUID(), action: "restore",
63 targets: [{ recoveryEntryId: entry.id, workspaceId: workspaces[entry.id] }], expectedGeneration: registryGeneration.current };
64 pendingRequests.current.set(key, request);
65 const response = await app.ApplySessionLifecycle(request);
66 const item = response.items[0];
67 if (item && !item.committed && item.retryable === false) pendingRequests.current.delete(key);
68 if (!item?.committed || !item.ref) throw new Error(t("history.failedLoadHistory"));
69 const result: SessionRestoreResult = { session: item.ref, workspaceId: item.workspaceId, generation: response.generation };
70 if (seq !== generation.current) return;
71 setRestored(result);
72 setItems(current => current.filter(row => row.id !== entry.id));
73 try { await reload(); } catch { setError(t("history.restoredRefreshFailed")); }
74 if (surface === surfaceGeneration.current && onOpenSession) {
75 try { await onOpenSession(result.session); } catch { setError(t("history.restoredRefreshFailed")); }
76 }
77 } catch (err) {
78 if (String(err).includes("workspace mutation conflicts with persisted state")) {
79 pendingRequests.current.delete(key);
80 await reload().catch(() => {});
81 setError(String(err));
82 return;
83 }
84 if (seq === generation.current) setError(err instanceof Error ? err.message : String(err));
85 } finally { mutating.current = false; setBusy(false); }
86 };
87 const showPreview = async (entry: RecoveryEntryView) => {
88 const seq = ++previewGeneration.current;
89 try {
90 const page = await app.PreviewRecoveryEntry(entry.id);
91 if (seq === previewGeneration.current) setPreview(asArray<HistoryMessage>(page.messages));
92 } catch (err) {
93 if (seq === previewGeneration.current) setError(err instanceof Error ? err.message : String(err));
94 }
95 };
96 return <div className="archived-sessions">
97 <p>{m("historicalDescription")}</p>
98 <input aria-label={t("history.searchPlaceholder")} placeholder={t("history.searchPlaceholder")} value={query} disabled={busy} onChange={event => setQuery(event.target.value)} />
99 <button className="btn btn--small" disabled={busy || loading} onClick={() => void reload().catch(() => {})}>{t("common.retry")}</button>
100 {loading && <div role="status">{t("common.loading")}</div>}
101 {error && <div role="alert">{error}</div>}
102 {restored && onOpenSession && <button className="btn btn--small" onClick={() => void onOpenSession(restored.session).catch(err => setError(String(err)))}>{t("history.openRestored")}</button>}
103 {!loading && !error && items.length === 0 && <p>{m("noHistoricalSessions")}</p>}
104 {items.map(entry => <div className="archived-sessions__row" key={entry.id}>
105 <span>{entry.title}</span>
106 <small>{entry.format} · {entry.status}</small>
107 {!entry.canRestore && <span>{t("history.recoveryReview")}</span>}
108 {entry.reason === "workspace_conflict" && <select aria-label={t("history.recoveryWorkspace")} value={workspaces[entry.id] ?? ""} disabled={busy}
109 onChange={event => setWorkspaces(current => ({ ...current, [entry.id]: event.target.value }))}>
110 <option value="">{t("history.recoveryWorkspace")}</option>
111 {(entry.workspaceChoices ?? []).map(workspace => <option key={workspace.id} value={workspace.id}>{workspace.title}</option>)}
112 </select>}
113 <button className="btn btn--small" disabled={busy || !entry.canPreview} onClick={() => void showPreview(entry)}>{t("history.previewRecovery")}</button>
114 <button className="btn btn--small" disabled={busy || !entry.canRestore || (entry.reason === "workspace_conflict" && !workspaces[entry.id])} onClick={() => void restore(entry)}>{t("history.restore")}</button>
115 </div>)}
116 {nextCursor && <button className="btn btn--small" disabled={busy || loading} onClick={() => void reload(nextCursor).catch(() => {})}>{t("projectTree.loadMore")}</button>}
117 {preview.length > 0 && <div aria-label={t("history.previewRecovery")}>{preview.map((message, index) => <pre key={message.messageId || message.recordId || index}>{message.content}</pre>)}</div>}
118 </div>;
119 }
120
120 lines Plain Text