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