返回 DeepSeek-Reasonix
RecoveryLineageDialog.tsx
根目录 / desktop / frontend / src / components / RecoveryLineageDialog.tsx
1 import { useEffect, useMemo, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { GitBranch, Pencil, X } from "lucide-react";
4 import { app } from "../lib/bridge";
5 import { recordFrontendDiagnostic } from "../lib/frontendDiagnosticBridge";
6 import type { ProjectTopicKey } from "../lib/sessionCatalogTypes";
7 import type { RecoveryLineageMember, RecoveryLineageView } from "../lib/types";
8 import { useT, type DictKey } from "../lib/i18n";
9 import { useToast } from "../lib/toast";
10 import { normalizeRecoveryLineageView, userVisibleRecoveryVersions } from "../lib/sessionRecoveryVersions";
11
12 interface RecoveryLineageDialogProps {
13 topic: ProjectTopicKey;
14 initial: RecoveryLineageView;
15 onClose: () => void;
16 onChanged: (view: RecoveryLineageView) => Promise<void> | void;
17 onOpenVersion?: (member: RecoveryLineageMember) => Promise<void> | void;
18 }
19
20 /** Heads of one log share a path, so the head id is the version identity. */
21 function memberKey(member: RecoveryLineageMember): string {
22 return member.headId ? `${member.path}#${member.headId}` : member.path;
23 }
24
25 const headKindKeys: Record<string, DictKey> = {
26 main: "recovery.headKind.main",
27 fork: "recovery.headKind.fork",
28 rewind: "recovery.headKind.rewind",
29 concurrent: "recovery.headKind.concurrent",
30 };
31
32 function memberTitleKey(member: RecoveryLineageMember): DictKey {
33 if (member.headId) return member.selected ? "recovery.currentVersion" : "recovery.alternateVersion";
34 return member.canonical ? "recovery.defaultVersion" : "recovery.alternateVersion";
35 }
36
37 function versionActivityAt(member: RecoveryLineageMember): number {
38 return member.lastActivityAt || member.createdAt || 0;
39 }
40
41 function failureText(error: unknown): string {
42 return error instanceof Error ? error.message : String(error);
43 }
44
45 export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOpenVersion }: RecoveryLineageDialogProps) {
46 const t = useT();
47 const { showToast } = useToast();
48 const [view, setView] = useState(() => normalizeRecoveryLineageView(initial));
49 const [busy, setBusy] = useState(false);
50 const [editingPath, setEditingPath] = useState("");
51 const [noteDraft, setNoteDraft] = useState("");
52 const members = useMemo(() => userVisibleRecoveryVersions(view), [view]);
53 const heads = view.state === "heads";
54
55 useEffect(() => setView(normalizeRecoveryLineageView(initial)), [initial]);
56
57 const refresh = async () => {
58 const next = normalizeRecoveryLineageView(await app.GetRecoveryLineage(topic));
59 setView(next);
60 await onChanged(next);
61 return next;
62 };
63
64 const choose = async (member: RecoveryLineageMember) => {
65 if (busy) return;
66 setBusy(true);
67 try {
68 await app.ChooseRecoveryBranch({ ...topic, path: member.path, headId: member.headId });
69 await refresh();
70 } catch (error) {
71 recordFrontendDiagnostic("app", "session.recovery-choose-failed", { status: "error" });
72 showToast(t("recovery.chooseBranchFailed", { error: failureText(error) }), "error");
73 } finally {
74 setBusy(false);
75 }
76 };
77
78 const openVersion = async (member: RecoveryLineageMember) => {
79 if (busy || !onOpenVersion) return;
80 setBusy(true);
81 try {
82 await onOpenVersion(member);
83 onClose();
84 } catch (error) {
85 recordFrontendDiagnostic("app", "session.recovery-open-failed", { status: "error" });
86 showToast(t("recovery.openVersionFailed", { error: failureText(error) }), "error");
87 } finally {
88 setBusy(false);
89 }
90 };
91
92 const startNoteEdit = (member: RecoveryLineageMember) => {
93 if (busy) return;
94 setEditingPath(memberKey(member));
95 setNoteDraft(member.versionNote || "");
96 };
97
98 const retireCovered = async () => {
99 if (busy) return;
100 setBusy(true);
101 try {
102 const result = await app.CleanRecoveryLineage({ scope: topic.scope, workspaceRoot: topic.workspaceRoot, topicId: topic.topicId, apply: true });
103 showToast(t("recovery.retireCoveredDone", { removed: result.moved, busy: result.busy }), result.busy > 0 ? "warn" : "info");
104 await refresh();
105 } catch (error) {
106 showToast(t("recovery.retireCoveredFailed", { error: failureText(error) }), "error");
107 } finally {
108 setBusy(false);
109 }
110 };
111
112 const saveNote = async (member: RecoveryLineageMember) => {
113 if (busy || editingPath !== memberKey(member)) return;
114 setBusy(true);
115 try {
116 if (member.headId) await app.RenameSessionHead(member.path, member.headId, noteDraft.trim());
117 else await app.RenameSession(member.path, noteDraft.trim());
118 setEditingPath("");
119 await refresh();
120 } catch (error) {
121 showToast(failureText(error), "error");
122 } finally {
123 setBusy(false);
124 }
125 };
126
127 return createPortal(
128 <div className="management-modal-backdrop recovery-lineage-backdrop" data-app-overlay="" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
129 <section className="management-modal recovery-lineage-dialog" role="dialog" aria-modal="true" aria-labelledby="recovery-lineage-title">
130 <header className="management-modal__head">
131 <div>
132 <div className="management-modal__title" id="recovery-lineage-title">
133 <GitBranch size={17} aria-hidden="true" /> {t("recovery.lineageTitle")}
134 </div>
135 <div className="management-modal__summary">
136 {heads ? t("recovery.headsSummary", { branches: members.length }) : t("recovery.lineageSummary", { branches: members.length, unresolved: view.unresolved })}
137 </div>
138 </div>
139 <button type="button" className="icon-btn" onClick={onClose} aria-label={t("common.close")}><X size={16} /></button>
140 </header>
141 <div className="recovery-lineage-dialog__body">
142 {members.map((member) => {
143 const activityAt = versionActivityAt(member);
144 const editing = editingPath === memberKey(member);
145 return (
146 <article className="recovery-lineage-dialog__member" key={memberKey(member)}>
147 <div className="recovery-lineage-dialog__member-copy">
148 <div className="recovery-lineage-dialog__member-name">
149 {member.headName?.trim() || t(memberTitleKey(member))}
150 {member.headKind && headKindKeys[member.headKind] && <span className="hist-item__badge">{t(headKindKeys[member.headKind])}</span>}
151 {(member.open || member.running) && <span className="hist-item__badge hist-item__badge--open">{t("recovery.inUse")}</span>}
152 </div>
153 {editing ? (
154 <div className="recovery-lineage-dialog__note-editor">
155 <input
156 autoFocus
157 value={noteDraft}
158 onChange={(event) => setNoteDraft(event.target.value)}
159 onKeyDown={(event) => {
160 if (event.key === "Enter") void saveNote(member);
161 if (event.key === "Escape") setEditingPath("");
162 }}
163 placeholder={t("recovery.versionNotePlaceholder")}
164 />
165 <button type="button" className="btn btn--small" disabled={busy} onClick={() => void saveNote(member)}>{t("common.save")}</button>
166 <button type="button" className="btn btn--small" disabled={busy} onClick={() => setEditingPath("")}>{t("common.cancel")}</button>
167 </div>
168 ) : (
169 <button type="button" className="recovery-lineage-dialog__version-note" disabled={busy} onClick={() => startNoteEdit(member)}>
170 <Pencil size={12} aria-hidden="true" />
171 {member.versionNote?.trim() || t("recovery.addVersionNote")}
172 </button>
173 )}
174 <div className="recovery-lineage-dialog__preview">{member.preview?.trim() || t("recovery.versionPreviewEmpty")}</div>
175 <div className="recovery-lineage-dialog__member-meta">
176 <span>{t(member.turns === 1 ? "history.turnOne" : "history.turnOther", { n: member.turns })}</span>
177 {activityAt > 0 && <span>{new Date(activityAt).toLocaleString()}</span>}
178 </div>
179 </div>
180 <div className="recovery-lineage-dialog__member-actions">
181 {onOpenVersion && (
182 <button type="button" className="btn btn--small btn--primary" disabled={busy} onClick={() => void openVersion(member)}>
183 {t("recovery.openVersion")}
184 </button>
185 )}
186 {!member.canonical && (
187 <button type="button" className="btn btn--small" disabled={busy} onClick={() => void choose(member)}>
188 {t(member.headId ? "recovery.makeCurrent" : "recovery.chooseBranch")}
189 </button>
190 )}
191 </div>
192 </article>
193 );
194 })}
195 {members.length === 0 && <div className="management-modal__summary">{t("recovery.lineageEmpty")}</div>}
196 </div>
197 <footer className="modal__actions recovery-lineage-dialog__actions">
198 {heads && view.cleanupEligible > 0 && (
199 <button type="button" className="btn" disabled={busy} onClick={() => void retireCovered()}>
200 {t("recovery.retireCovered", { n: view.cleanupEligible })}
201 </button>
202 )}
203 <button type="button" className="btn" onClick={onClose}>{t("common.close")}</button>
204 </footer>
205 </section>
206 </div>,
207 document.body,
208 );
209 }
210
210 lines Plain Text