返回 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 } from "../lib/types";
8 import { Tooltip } from "./Tooltip";
9
10 export interface ExternalOpenerBridge {
11 ExternalOpeners(): Promise<ExternalOpenersView>;
12 SetPreferredExternalOpener(id: string): Promise<void>;
13 OpenWorkspaceInExternalOpenerForTab(tabID: string, id: string): Promise<void>;
14 }
15
16 function fallbackOpenerIcon(opener: ExternalOpenerView) {
17 if (opener.kind === "file-manager") return <Folder size={15} strokeWidth={1.9} />;
18 if (opener.kind === "terminal") return <SquareTerminal size={15} strokeWidth={1.9} />;
19 return <Code2 size={15} strokeWidth={1.9} />;
20 }
21
22 function OpenerIcon({ opener }: { opener: ExternalOpenerView }) {
23 return (
24 <span className={`external-opener__app-icon external-opener__app-icon--${opener.kind}`} aria-hidden="true">
25 <span className="external-opener__fallback-icon">{fallbackOpenerIcon(opener)}</span>
26 {opener.iconDataUrl && (
27 <img
28 src={opener.iconDataUrl}
29 alt=""
30 draggable={false}
31 onError={(event) => {
32 event.currentTarget.hidden = true;
33 }}
34 />
35 )}
36 </span>
37 );
38 }
39
40 function errorText(error: unknown): string {
41 return error instanceof Error ? error.message : String(error);
42 }
43
44 function normalizeOpeners(next: ExternalOpenersView): ExternalOpenersView {
45 return { openers: Array.isArray(next.openers) ? next.openers : [], preferred: next.preferred ?? "" };
46 }
47
48 export function ExternalOpener({
49 tabId,
50 dismissSignal,
51 bridge = desktopApp,
52 }: {
53 tabId: string;
54 dismissSignal: number;
55 bridge?: ExternalOpenerBridge;
56 }) {
57 const { showToast } = useToast();
58 const rootRef = useRef<HTMLDivElement>(null);
59 const discoveryRequestRef = useRef(0);
60 const mountedRef = useRef(true);
61 const busyRef = useRef(false);
62 const [state, setState] = useState<ExternalOpenersView>({ openers: [], preferred: "" });
63 const [menuOpen, setMenuOpen] = useState(false);
64 const [busy, setBusy] = useState(false);
65
66 const refreshOpeners = useCallback(async () => {
67 const request = ++discoveryRequestRef.current;
68 try {
69 const next = normalizeOpeners(await bridge.ExternalOpeners());
70 if (mountedRef.current && request === discoveryRequestRef.current) setState(next);
71 } catch (error) {
72 if (mountedRef.current && request === discoveryRequestRef.current) {
73 console.error("Failed to discover external openers", error);
74 }
75 }
76 }, [bridge]);
77
78 useEffect(() => {
79 mountedRef.current = true;
80 void refreshOpeners();
81 return () => {
82 mountedRef.current = false;
83 discoveryRequestRef.current += 1;
84 };
85 }, [refreshOpeners]);
86
87 useEffect(() => setMenuOpen(false), [dismissSignal, tabId]);
88
89 useEffect(() => {
90 if (!menuOpen) return;
91 const onPointerDown = (event: MouseEvent) => {
92 if (!rootRef.current?.contains(event.target as Node)) setMenuOpen(false);
93 };
94 const onKeyDown = (event: KeyboardEvent) => {
95 if (event.key === "Escape") setMenuOpen(false);
96 };
97 document.addEventListener("mousedown", onPointerDown);
98 document.addEventListener("keydown", onKeyDown);
99 return () => {
100 document.removeEventListener("mousedown", onPointerDown);
101 document.removeEventListener("keydown", onKeyDown);
102 };
103 }, [menuOpen]);
104
105 const selected = useMemo(
106 () => state.openers.find((opener) => opener.id === state.preferred) ?? state.openers[0],
107 [state],
108 );
109
110 const openIn = useCallback(
111 async (opener: ExternalOpenerView, persist: boolean) => {
112 if (busyRef.current) return;
113 busyRef.current = true;
114 discoveryRequestRef.current += 1;
115 setBusy(true);
116 setMenuOpen(false);
117 try {
118 // Launch before persisting so a config-write failure cannot block the
119 // launch and a failed launch never becomes the saved preference.
120 try {
121 await bridge.OpenWorkspaceInExternalOpenerForTab(tabId, opener.id);
122 } catch (error) {
123 showToast(t("externalOpener.failed", { name: opener.name, error: errorText(error) }), "error");
124 return;
125 }
126 if (persist && opener.id !== state.preferred) {
127 try {
128 await bridge.SetPreferredExternalOpener(opener.id);
129 setState((current) => ({ ...current, preferred: opener.id }));
130 } catch (error) {
131 showToast(t("externalOpener.persistFailed", { name: opener.name, error: errorText(error) }), "error");
132 }
133 }
134 } finally {
135 busyRef.current = false;
136 setBusy(false);
137 }
138 },
139 [bridge, showToast, state.preferred, tabId],
140 );
141
142 if (!selected) return null;
143 const openLabel = t("externalOpener.openIn", { name: selected.name });
144
145 return (
146 <div ref={rootRef} className={`external-opener${menuOpen ? " external-opener--open" : ""}`}>
147 <Tooltip label={openLabel} className="external-opener__primary-wrap">
148 <button
149 className="external-opener__primary"
150 type="button"
151 disabled={busy}
152 aria-label={openLabel}
153 onClick={() => void openIn(selected, false)}
154 >
155 <OpenerIcon opener={selected} />
156 </button>
157 </Tooltip>
158 <button
159 className="external-opener__menu-trigger"
160 type="button"
161 disabled={busy}
162 aria-label={t("externalOpener.choose")}
163 title={t("externalOpener.choose")}
164 aria-haspopup="menu"
165 aria-expanded={menuOpen}
166 onClick={() => {
167 setMenuOpen((open) => !open);
168 if (!menuOpen) void refreshOpeners();
169 }}
170 >
171 <ChevronDown size={14} />
172 </button>
173 {menuOpen && (
174 <div className="external-opener__menu" role="menu" aria-label={t("externalOpener.choose")}>
175 {state.openers.map((opener) => (
176 <button
177 key={opener.id}
178 type="button"
179 role="menuitemradio"
180 aria-checked={opener.id === selected.id}
181 onClick={() => void openIn(opener, true)}
182 >
183 <OpenerIcon opener={opener} />
184 <span>{opener.name}</span>
185 {opener.id === selected.id && <Check className="external-opener__check" size={15} aria-hidden="true" />}
186 </button>
187 ))}
188 </div>
189 )}
190 </div>
191 );
192 }
193
193 lines Plain Text