返回 DeepSeek-Reasonix
ExternalOpener.tsx
根目录 / desktop / frontend / src / components / ExternalOpener.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { Check, ChevronDown, Code2, Folder, SquareTerminal } from "lucide-react";
3
4 import { app as desktopApp } from "../lib/bridge";
5 import { t } from "../lib/i18n";
6 import { useToast } from "../lib/toast";
7 import type { ExternalOpenerView, ExternalOpenersView, TabMeta } from "../lib/types";
8 import { Tooltip } from "./Tooltip";
9
10 export interface ExternalOpenerBridge {
11 ExternalOpenersForTab(tabID: string): Promise<ExternalOpenersView>;
12 SetPreferredExternalOpener(id: string): Promise<void>;
13 OpenWorkspaceInExternalOpenerForTab(tabID: string, id: string): Promise<void>;
14 }
15
16 type ExternalOpenerPreferenceCoordinator = {
17 nextIntent: number;
18 latestSuccessfulIntent: number;
19 writeTail: Promise<void>;
20 preferred: string;
21 revision: number;
22 listeners: Set<(preferred: string) => void>;
23 };
24
25 const externalOpenerPreferenceCoordinators = new WeakMap<ExternalOpenerBridge, ExternalOpenerPreferenceCoordinator>();
26
27 function preferenceCoordinatorFor(bridge: ExternalOpenerBridge): ExternalOpenerPreferenceCoordinator {
28 const current = externalOpenerPreferenceCoordinators.get(bridge);
29 if (current) return current;
30 const created: ExternalOpenerPreferenceCoordinator = {
31 nextIntent: 0,
32 latestSuccessfulIntent: 0,
33 writeTail: Promise.resolve(),
34 preferred: "",
35 revision: 0,
36 listeners: new Set(),
37 };
38 externalOpenerPreferenceCoordinators.set(bridge, created);
39 return created;
40 }
41
42 function persistPreferredExternalOpener(
43 bridge: ExternalOpenerBridge,
44 id: string,
45 coordinator: ExternalOpenerPreferenceCoordinator,
46 intent: number,
47 ): Promise<boolean> {
48 const write = coordinator.writeTail.then(async () => {
49 if (intent !== coordinator.latestSuccessfulIntent) return false;
50 await bridge.SetPreferredExternalOpener(id);
51 coordinator.preferred = id;
52 coordinator.revision += 1;
53 for (const listener of coordinator.listeners) listener(id);
54 return true;
55 });
56 coordinator.writeTail = write.then(() => undefined, () => undefined);
57 return write;
58 }
59
60 function fallbackOpenerIcon(opener: ExternalOpenerView) {
61 if (opener.kind === "file-manager") return <Folder size={15} strokeWidth={1.9} />;
62 if (opener.kind === "terminal") return <SquareTerminal size={15} strokeWidth={1.9} />;
63 return <Code2 size={15} strokeWidth={1.9} />;
64 }
65
66 function OpenerIcon({ opener }: { opener: ExternalOpenerView }) {
67 return (
68 <span className={`external-opener__app-icon external-opener__app-icon--${opener.kind}`} aria-hidden="true">
69 <span className="external-opener__fallback-icon">{fallbackOpenerIcon(opener)}</span>
70 {opener.iconDataUrl && (
71 <img
72 src={opener.iconDataUrl}
73 alt=""
74 draggable={false}
75 onError={(event) => {
76 event.currentTarget.hidden = true;
77 }}
78 />
79 )}
80 </span>
81 );
82 }
83
84 function errorText(error: unknown): string {
85 return error instanceof Error ? error.message : String(error);
86 }
87
88 function normalizeOpeners(next: ExternalOpenersView): ExternalOpenersView {
89 return {
90 openers: Array.isArray(next.openers) ? next.openers : [],
91 preferred: next.preferred ?? "",
92 workspaceOpenable: next.workspaceOpenable === true,
93 };
94 }
95
96 type ResolvedExternalOpeners = ExternalOpenersView & { tabId: string };
97
98 export function shouldMountExternalOpener(
99 tab: Pick<TabMeta, "id" | "scope"> | null | undefined,
100 imDetailVisible: boolean,
101 ): boolean {
102 return !imDetailVisible && Boolean(tab?.id);
103 }
104
105 export function ExternalOpener({
106 tabId,
107 dismissSignal,
108 bridge = desktopApp,
109 }: {
110 tabId: string;
111 dismissSignal: number;
112 bridge?: ExternalOpenerBridge;
113 }) {
114 const { showToast } = useToast();
115 const rootRef = useRef<HTMLDivElement>(null);
116 const discoveryRequestRef = useRef(0);
117 const mountedRef = useRef(true);
118 const busyRef = useRef(false);
119 const [state, setState] = useState<ResolvedExternalOpeners>({
120 openers: [],
121 preferred: "",
122 workspaceOpenable: false,
123 tabId: "",
124 });
125 const [menuOpen, setMenuOpen] = useState(false);
126 const [busy, setBusy] = useState(false);
127
128 const refreshOpeners = useCallback(async () => {
129 const request = ++discoveryRequestRef.current;
130 const preferenceCoordinator = preferenceCoordinatorFor(bridge);
131 const preferenceRevision = preferenceCoordinator.revision;
132 try {
133 const next = normalizeOpeners(await bridge.ExternalOpenersForTab(tabId));
134 if (preferenceCoordinator.revision !== preferenceRevision && preferenceCoordinator.preferred) {
135 next.preferred = preferenceCoordinator.preferred;
136 }
137 if (mountedRef.current && request === discoveryRequestRef.current) setState({ ...next, tabId });
138 } catch (error) {
139 if (mountedRef.current && request === discoveryRequestRef.current) {
140 console.error("Failed to discover external openers", error);
141 }
142 }
143 }, [bridge, tabId]);
144
145 useEffect(() => {
146 mountedRef.current = true;
147 const preferenceCoordinator = preferenceCoordinatorFor(bridge);
148 const syncPreferred = (preferred: string) => {
149 if (mountedRef.current) setState((current) => ({ ...current, preferred }));
150 };
151 preferenceCoordinator.listeners.add(syncPreferred);
152 void refreshOpeners();
153 return () => {
154 mountedRef.current = false;
155 discoveryRequestRef.current += 1;
156 preferenceCoordinator.listeners.delete(syncPreferred);
157 };
158 }, [bridge, refreshOpeners]);
159
160 useEffect(() => setMenuOpen(false), [dismissSignal, tabId]);
161
162 useEffect(() => {
163 if (!menuOpen) return;
164 const onPointerDown = (event: MouseEvent) => {
165 if (!rootRef.current?.contains(event.target as Node)) setMenuOpen(false);
166 };
167 const onKeyDown = (event: KeyboardEvent) => {
168 if (event.key === "Escape") setMenuOpen(false);
169 };
170 document.addEventListener("mousedown", onPointerDown);
171 document.addEventListener("keydown", onKeyDown);
172 return () => {
173 document.removeEventListener("mousedown", onPointerDown);
174 document.removeEventListener("keydown", onKeyDown);
175 };
176 }, [menuOpen]);
177
178 const selected = useMemo(
179 () => state.tabId === tabId
180 ? state.openers.find((opener) => opener.id === state.preferred) ?? state.openers[0]
181 : undefined,
182 [state, tabId],
183 );
184
185 const openIn = useCallback(
186 async (opener: ExternalOpenerView, persist: boolean) => {
187 if (busyRef.current) return;
188 const preferenceCoordinator = persist ? preferenceCoordinatorFor(bridge) : undefined;
189 const preferenceIntent = preferenceCoordinator ? ++preferenceCoordinator.nextIntent : 0;
190 busyRef.current = true;
191 discoveryRequestRef.current += 1;
192 setBusy(true);
193 setMenuOpen(false);
194 try {
195 // Launch before persisting so a config-write failure cannot block the
196 // launch and a failed launch never becomes the saved preference.
197 try {
198 await bridge.OpenWorkspaceInExternalOpenerForTab(tabId, opener.id);
199 } catch (error) {
200 showToast(t("externalOpener.failed", { name: opener.name, error: errorText(error) }), "error");
201 return;
202 }
203 if (preferenceCoordinator) {
204 preferenceCoordinator.latestSuccessfulIntent = Math.max(
205 preferenceCoordinator.latestSuccessfulIntent,
206 preferenceIntent,
207 );
208 try {
209 const persisted = await persistPreferredExternalOpener(
210 bridge,
211 opener.id,
212 preferenceCoordinator,
213 preferenceIntent,
214 );
215 if (persisted && mountedRef.current) setState((current) => ({ ...current, preferred: opener.id }));
216 } catch (error) {
217 showToast(t("externalOpener.persistFailed", { name: opener.name, error: errorText(error) }), "error");
218 }
219 }
220 } finally {
221 busyRef.current = false;
222 setBusy(false);
223 }
224 },
225 [bridge, showToast, tabId],
226 );
227
228 if (state.tabId !== tabId || state.workspaceOpenable !== true || !selected) return null;
229 const openLabel = t("externalOpener.openIn", { name: selected.name });
230
231 return (
232 <div ref={rootRef} className={`external-opener${menuOpen ? " external-opener--open" : ""}`}>
233 <Tooltip label={openLabel} className="external-opener__primary-wrap">
234 <button
235 className="external-opener__primary"
236 type="button"
237 disabled={busy}
238 aria-label={openLabel}
239 onClick={() => void openIn(selected, false)}
240 >
241 <OpenerIcon opener={selected} />
242 </button>
243 </Tooltip>
244 <button
245 className="external-opener__menu-trigger"
246 type="button"
247 disabled={busy}
248 aria-label={t("externalOpener.choose")}
249 title={t("externalOpener.choose")}
250 aria-haspopup="menu"
251 aria-expanded={menuOpen}
252 onClick={() => {
253 setMenuOpen((open) => !open);
254 if (!menuOpen) void refreshOpeners();
255 }}
256 >
257 <ChevronDown size={14} />
258 </button>
259 {menuOpen && (
260 <div className="external-opener__menu" role="menu" aria-label={t("externalOpener.choose")}>
261 {state.openers.map((opener) => (
262 <button
263 key={opener.id}
264 type="button"
265 role="menuitemradio"
266 aria-checked={opener.id === selected.id}
267 onClick={() => void openIn(opener, true)}
268 >
269 <OpenerIcon opener={opener} />
270 <span>{opener.name}</span>
271 {opener.id === selected.id && <Check className="external-opener__check" size={15} aria-hidden="true" />}
272 </button>
273 ))}
274 </div>
275 )}
276 </div>
277 );
278 }
279
279 lines Plain Text