返回 DeepSeek-Reasonix
WorktreeMergeModal.tsx
根目录 / desktop / frontend / src / components / WorktreeMergeModal.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { AlertTriangle, ArrowRight, CheckCircle2, FileText, GitBranch, GitMerge, Loader2 } from "lucide-react";
3 import { app } from "../lib/bridge";
4 import { useT } from "../lib/i18n";
5 import type { WorktreeMergeBlocker, WorktreeMergeInspection, WorktreeMergeResult } from "../lib/types";
6 import { ModalCloseButton } from "./ModalCloseButton";
7 import "./WorktreeMergeModal.css";
8
9 interface WorktreeMergeModalProps {
10 tabId: string;
11 isOpen: boolean;
12 onClose: () => void;
13 onMerged?: (result: WorktreeMergeResult) => Promise<void> | void;
14 }
15
16 function inspectionIdentity(inspection: WorktreeMergeInspection): string {
17 return JSON.stringify({
18 worktreeRoot: inspection.worktreeRoot,
19 sourceRoot: inspection.sourceRoot,
20 worktreeBranch: inspection.worktreeBranch,
21 targetBranch: inspection.targetBranch,
22 worktreeHead: inspection.worktreeHead,
23 worktreeStateToken: inspection.worktreeStateToken,
24 targetHead: inspection.targetHead,
25 worktreeDirty: inspection.worktreeDirty,
26 sourceDirty: inspection.sourceDirty,
27 hasConflicts: inspection.hasConflicts,
28 blockers: inspection.blockers.map((item) => item.code),
29 });
30 }
31
32 function BlockerList({ blockers }: { blockers: WorktreeMergeBlocker[] }) {
33 if (blockers.length === 0) return null;
34 return (
35 <div className="worktree-merge__blockers" role="status">
36 {blockers.map((blocker, index) => (
37 <div className="worktree-merge__blocker" key={`${blocker.code}-${index}`}>
38 <AlertTriangle size={15} aria-hidden="true" />
39 <div>
40 <div>{blocker.message}</div>
41 {blocker.paths.length > 0 && <div className="worktree-merge__paths">{blocker.paths.join(", ")}</div>}
42 </div>
43 </div>
44 ))}
45 </div>
46 );
47 }
48
49 export function WorktreeMergeModal({ tabId, isOpen, onClose, onMerged }: WorktreeMergeModalProps) {
50 const t = useT();
51 const dialogRef = useRef<HTMLElement>(null);
52 const requestGeneration = useRef(0);
53 const mergingRef = useRef(false);
54 const onCloseRef = useRef(onClose);
55 onCloseRef.current = onClose;
56 const [loading, setLoading] = useState(true);
57 const [merging, setMerging] = useState(false);
58 const [inspection, setInspection] = useState<WorktreeMergeInspection | null>(null);
59 const [error, setError] = useState<string | null>(null);
60 const [autoCommitDirty, setAutoCommitDirty] = useState(false);
61
62 const fetchInspection = useCallback(async () => {
63 if (!tabId) return null;
64 const generation = ++requestGeneration.current;
65 setLoading(true);
66 setError(null);
67 try {
68 const result = await app.InspectWorktreeMerge(tabId);
69 if (generation !== requestGeneration.current) return null;
70 setInspection({
71 ...result,
72 changedFiles: result.changedFiles ?? [],
73 conflictFiles: result.conflictFiles ?? [],
74 blockers: result.blockers ?? [],
75 cleanupBlockers: result.cleanupBlockers ?? [],
76 });
77 return result;
78 } catch (caught: unknown) {
79 if (generation === requestGeneration.current) setError(caught instanceof Error ? caught.message : String(caught));
80 return null;
81 } finally {
82 if (generation === requestGeneration.current) setLoading(false);
83 }
84 }, [tabId]);
85
86 useEffect(() => {
87 if (!isOpen) return;
88 setAutoCommitDirty(false);
89 void fetchInspection();
90 const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
91 const onKeyDown = (event: KeyboardEvent) => {
92 if (event.key === "Escape" && !mergingRef.current) {
93 event.preventDefault();
94 onCloseRef.current();
95 }
96 if (event.key !== "Tab") return;
97 const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex='-1'])") ?? []);
98 if (focusable.length === 0) return;
99 const first = focusable[0];
100 const last = focusable[focusable.length - 1];
101 if (event.shiftKey && document.activeElement === first) {
102 event.preventDefault();
103 last.focus();
104 } else if (!event.shiftKey && document.activeElement === last) {
105 event.preventDefault();
106 first.focus();
107 }
108 };
109 window.addEventListener("keydown", onKeyDown);
110 requestAnimationFrame(() => dialogRef.current?.querySelector<HTMLElement>("button, input")?.focus());
111 return () => {
112 requestGeneration.current++;
113 window.removeEventListener("keydown", onKeyDown);
114 previousFocus?.focus();
115 };
116 }, [fetchInspection, isOpen]);
117
118 const canConfirm = useMemo(() => {
119 if (!inspection?.available) return false;
120 if (inspection.canMerge) return true;
121 return inspection.blockers.length > 0 && inspection.blockers.every((blocker) => blocker.code === "worktree_dirty" && autoCommitDirty);
122 }, [autoCommitDirty, inspection]);
123
124 const handleMerge = async () => {
125 if (!inspection || !tabId || merging || !canConfirm) return;
126 const generation = ++requestGeneration.current;
127 mergingRef.current = true;
128 setMerging(true);
129 setError(null);
130 try {
131 const refreshedRaw = await app.InspectWorktreeMerge(tabId);
132 if (generation !== requestGeneration.current) return;
133 const refreshed: WorktreeMergeInspection = {
134 ...refreshedRaw,
135 changedFiles: refreshedRaw.changedFiles ?? [], conflictFiles: refreshedRaw.conflictFiles ?? [],
136 blockers: refreshedRaw.blockers ?? [], cleanupBlockers: refreshedRaw.cleanupBlockers ?? [],
137 };
138 if (inspectionIdentity(refreshed) !== inspectionIdentity(inspection)) {
139 setInspection(refreshed);
140 setError(t("worktree.stateChanged"));
141 return;
142 }
143 if (!refreshed.targetBranch || !refreshed.targetHead || !refreshed.worktreeHead || !refreshed.worktreeStateToken) {
144 setInspection(refreshed);
145 setError(refreshed.reason || t("worktree.mergeUnavailable"));
146 return;
147 }
148 const result = await app.MergeWorktreeBack({
149 tabId,
150 expectedTargetBranch: refreshed.targetBranch,
151 expectedTargetHead: refreshed.targetHead,
152 expectedWorktreeHead: refreshed.worktreeHead,
153 expectedWorktreeStateToken: refreshed.worktreeStateToken,
154 autoCommitDirty,
155 });
156 if (generation !== requestGeneration.current) return;
157 if (!result.merged) {
158 setError(result.error || t("worktree.mergeFailed"));
159 return;
160 }
161 await onMerged?.(result);
162 if (generation === requestGeneration.current) onClose();
163 } catch (caught: unknown) {
164 if (generation === requestGeneration.current) setError(caught instanceof Error ? caught.message : String(caught));
165 } finally {
166 if (generation === requestGeneration.current) setMerging(false);
167 mergingRef.current = false;
168 }
169 };
170
171 if (!isOpen) return null;
172
173 return (
174 <div className="management-modal-backdrop" data-app-overlay="" onMouseDown={(event) => { if (event.target === event.currentTarget && !merging) onClose(); }}>
175 <section ref={dialogRef} className="management-modal worktree-merge-modal" role="dialog" aria-modal="true" aria-labelledby="worktree-merge-title" aria-describedby="worktree-merge-summary">
176 <header className="management-modal__head">
177 <div>
178 <div id="worktree-merge-title" className="management-modal__title worktree-merge__title"><GitMerge size={16} aria-hidden="true" />{t("worktree.mergeTitle")}</div>
179 <div id="worktree-merge-summary" className="management-modal__summary">{t("worktree.mergeSubtitle")}</div>
180 </div>
181 <div className="management-modal__actions"><ModalCloseButton label={t("common.close")} onClick={onClose} disabled={merging} /></div>
182 </header>
183
184 <div className="worktree-merge__body">
185 {loading ? (
186 <div className="worktree-merge__loading" role="status"><Loader2 className="spin" size={18} aria-hidden="true" />{t("worktree.inspecting")}</div>
187 ) : inspection ? (
188 <>
189 <div className="worktree-merge__route">
190 <span><GitBranch size={14} aria-hidden="true" />{inspection.worktreeBranch || "worktree"}</span>
191 <ArrowRight size={14} aria-hidden="true" />
192 <span><GitBranch size={14} aria-hidden="true" />{inspection.targetBranch || "—"}</span>
193 </div>
194 <div className="worktree-merge__heads"><span>{t("worktree.aheadBehind", { ahead: inspection.aheadCount, behind: inspection.behindCount })}</span><span>{inspection.worktreeHead?.slice(0, 12)} → {inspection.targetHead?.slice(0, 12)}</span></div>
195 <div className="worktree-merge__stats">
196 <div><span>{t("worktree.filesChanged")}</span><strong>{inspection.filesChanged}</strong></div>
197 <div><span>{t("worktree.insertions")}</span><strong className="worktree-merge__positive">+{inspection.insertions}</strong></div>
198 <div><span>{t("worktree.deletions")}</span><strong className="worktree-merge__negative">-{inspection.deletions}</strong></div>
199 </div>
200 {inspection.blockers.length > 0 ? <BlockerList blockers={inspection.blockers} /> : (
201 <div className="worktree-merge__ready"><CheckCircle2 size={16} aria-hidden="true" />{inspection.alreadyMerged ? t("worktree.alreadyMerged") : t("worktree.cleanMergeReady")}</div>
202 )}
203 {inspection.changedFiles.length > 0 && (
204 <div className="worktree-merge__files">
205 <div className="worktree-merge__section-title">{t("worktree.changedFilesList")}</div>
206 <div className="worktree-merge__file-list">{inspection.changedFiles.map((file) => <div key={file}><FileText size={12} aria-hidden="true" /><span>{file}</span></div>)}</div>
207 </div>
208 )}
209 {inspection.cleanupBlockers.filter((item) => item.code !== "not_merged").length > 0 && (
210 <div className="worktree-merge__cleanup-note">
211 <div className="worktree-merge__section-title">{t("worktree.cleanupBlocked")}</div>
212 <BlockerList blockers={inspection.cleanupBlockers.filter((item) => item.code !== "not_merged")} />
213 </div>
214 )}
215 {inspection.worktreeDirty && (
216 <label className="worktree-merge__option">
217 <input type="checkbox" checked={autoCommitDirty} onChange={(event) => setAutoCommitDirty(event.target.checked)} disabled={merging} />
218 <span><strong>{t("worktree.optionAutoCommit")}</strong><small>{t("worktree.optionAutoCommitHint")}</small></span>
219 </label>
220 )}
221 </>
222 ) : null}
223 {error && <div className="worktree-merge__error" role="alert"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span><button type="button" onClick={() => void fetchInspection()} disabled={merging}>{t("common.retry")}</button></div>}
224 </div>
225
226 <footer className="worktree-merge__actions">
227 <button type="button" className="btn btn--secondary" onClick={onClose} disabled={merging}>{t("common.cancel")}</button>
228 <button type="button" className="btn btn--primary worktree-merge__confirm" onClick={() => void handleMerge()} disabled={loading || merging || !canConfirm}>
229 {merging ? <><Loader2 className="spin" size={14} aria-hidden="true" />{t("worktree.merging")}</> : <><GitMerge size={14} aria-hidden="true" />{t("worktree.confirmMerge")}</>}
230 </button>
231 </footer>
232 </section>
233 </div>
234 );
235 }
236
236 lines Plain Text