返回 DeepSeek-Reasonix
githubLink.tsx
根目录 / desktop / frontend / src / components / githubLink.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
3 import { Copy, ExternalLink, FolderOpen, Hash, Mail, Save } from "lucide-react";
4 import { app, openExternal } from "../lib/bridge";
5 import { writeClipboardText } from "../lib/clipboard";
6 import { t } from "../lib/i18n";
7 import { localPathFromHref } from "../lib/localFileUrl";
8 import type { ExternalOpenerView, ExternalOpenersView } from "../lib/types";
9 import { useToast } from "../lib/toast";
10 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
11
12 export { localPathFromHref } from "../lib/localFileUrl";
13
14 export interface GitHubLinkInfo {
15 kind: "issue" | "pull" | "commit";
16 owner: string;
17 repo: string;
18 value: string;
19 compactLabel: string;
20 }
21
22 export type LinkIconKind = "github" | "external" | "mail";
23
24 function linkText(children: ReactNode): string {
25 if (typeof children === "string") return children;
26 if (typeof children === "number") return String(children);
27 if (!Array.isArray(children)) return "";
28 return children
29 .map((child) => typeof child === "string" || typeof child === "number" ? String(child) : "")
30 .join("");
31 }
32
33 function githubAccessibleLabel(info: GitHubLinkInfo): string {
34 const resource = info.kind === "pull"
35 ? `pull request #${info.value}`
36 : info.kind === "issue"
37 ? `issue #${info.value}`
38 : `commit ${info.compactLabel}`;
39 return `GitHub ${info.owner}/${info.repo} ${resource}`;
40 }
41
42 export function classifyLinkIcon(href?: string): LinkIconKind | null {
43 if (!href) return null;
44 let url: URL;
45 try {
46 url = new URL(href);
47 } catch {
48 return null;
49 }
50
51 if (url.protocol === "mailto:") return "mail";
52 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
53 if (url.protocol === "https:" && ["github.com", "www.github.com"].includes(url.hostname.toLowerCase())) {
54 return "github";
55 }
56 return "external";
57 }
58
59 export function parseGitHubLink(href?: string): GitHubLinkInfo | null {
60 if (!href) return null;
61 let url: URL;
62 try {
63 url = new URL(href);
64 } catch {
65 return null;
66 }
67 if (url.protocol !== "https:" || !["github.com", "www.github.com"].includes(url.hostname.toLowerCase())) {
68 return null;
69 }
70
71 const parts = url.pathname.split("/").filter(Boolean);
72 if (parts.length !== 4) return null;
73 const [owner, repo, resource, value] = parts;
74 if (!owner || !repo || !value) return null;
75
76 if (resource === "issues" && /^\d+$/.test(value)) {
77 return { kind: "issue", owner, repo, value, compactLabel: `#${value}` };
78 }
79 if (resource === "pull" && /^\d+$/.test(value)) {
80 return { kind: "pull", owner, repo, value, compactLabel: `PR #${value}` };
81 }
82 if (resource === "commit" && /^[0-9a-f]{7,40}$/i.test(value)) {
83 return { kind: "commit", owner, repo, value, compactLabel: value.slice(0, 7) };
84 }
85 return null;
86 }
87
88 function LinkMark({ kind }: { kind: LinkIconKind }) {
89 if (kind === "github") {
90 return (
91 <svg aria-hidden="true" fill="none" height="13" viewBox="0 0 24 24" width="13">
92 <path
93 d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3.3-.4 6.8-1.6 6.8-7A5.4 5.4 0 0 0 19.4 4 5 5 0 0 0 19.3.5S18.2.1 15 1.8a13.4 13.4 0 0 0-7 0C4.8.1 3.7.5 3.7.5A5 5 0 0 0 3.6 4a5.4 5.4 0 0 0-1.4 3.7c0 5.4 3.5 6.6 6.8 7A4.8 4.8 0 0 0 8 18v4"
94 stroke="currentColor"
95 strokeLinecap="round"
96 strokeLinejoin="round"
97 strokeWidth="2"
98 />
99 <path
100 d="M8 19c-3 .9-3-1.5-4-2"
101 stroke="currentColor"
102 strokeLinecap="round"
103 strokeLinejoin="round"
104 strokeWidth="2"
105 />
106 </svg>
107 );
108 }
109 if (kind === "mail") return <Mail aria-hidden="true" size={13} strokeWidth={2} />;
110 return <ExternalLink aria-hidden="true" size={13} strokeWidth={2} />;
111 }
112
113 function openLink(href: string | undefined) {
114 const local = localPathFromHref(href);
115 if (local !== null) {
116 // Local paths (linkified plain text or explicit file:/// links) open in
117 // the OS default app via the native binding, never in the system browser.
118 void app.OpenLocalPath(local).catch(() => {});
119 return;
120 }
121 if (href) openExternal(href);
122 }
123
124 function localPathErrorText(error: unknown): string {
125 return error instanceof Error ? error.message : String(error);
126 }
127
128 // Menu actions transform information the link already carries (open, copy,
129 // derive a compact reference); no network or async work is introduced here.
130 function richLinkMenuItems(
131 href: string,
132 github: GitHubLinkInfo | null,
133 closeMenu: () => void,
134 copyText: (text: string) => void,
135 ): ContextMenuItem[] {
136 const isMail = classifyLinkIcon(href) === "mail";
137 let copyTarget = href;
138 if (isMail) {
139 const address = new URL(href).pathname;
140 try {
141 copyTarget = decodeURIComponent(address);
142 } catch {
143 // Keep malformed escapes readable without breaking the menu.
144 copyTarget = address;
145 }
146 }
147 const reference = github === null
148 ? null
149 : github.kind === "commit"
150 ? `${github.owner}/${github.repo}@${github.compactLabel}`
151 : `${github.owner}/${github.repo}#${github.value}`;
152 return [
153 {
154 key: "open",
155 icon: <ExternalLink size={13} />,
156 label: isMail ? t("richLink.composeEmail") : t("richLink.openInBrowser"),
157 onSelect: () => {
158 closeMenu();
159 openExternal(href);
160 },
161 },
162 { type: "separator", key: "open-separator" },
163 {
164 key: "copy-link",
165 icon: <Copy size={13} />,
166 label: isMail ? t("richLink.copyEmail") : t("richLink.copyLink"),
167 onSelect: () => copyText(copyTarget),
168 },
169 ...(reference !== null
170 ? [{
171 key: "copy-reference",
172 icon: <Hash size={13} />,
173 label: t("richLink.copyReference"),
174 onSelect: () => copyText(reference),
175 }]
176 : []),
177 ];
178 }
179
180 function LocalPathMarkdownLink({
181 href,
182 path,
183 children,
184 }: {
185 href: string;
186 path: string;
187 children: ReactNode;
188 }) {
189 const { showToast } = useToast();
190 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
191 const [openers, setOpeners] = useState<ExternalOpenersView>({ openers: [], preferred: "" });
192
193 const closeMenu = useCallback(() => setMenuPoint(null), []);
194 const openerRequestRef = useRef(0);
195 const mountedRef = useRef(true);
196 const refreshOpeners = useCallback(() => {
197 const request = ++openerRequestRef.current;
198 void app.ExternalOpeners().then((next) => {
199 if (!mountedRef.current || request !== openerRequestRef.current) return;
200 setOpeners({
201 openers: Array.isArray(next.openers) ? next.openers : [],
202 preferred: next.preferred ?? "",
203 });
204 }).catch(() => {});
205 }, []);
206
207 useEffect(() => {
208 // React StrictMode replays mount effects in development. Reset the guard
209 // during every setup so the replayed mount can still accept discoveries.
210 mountedRef.current = true;
211 return () => {
212 mountedRef.current = false;
213 openerRequestRef.current += 1;
214 };
215 }, []);
216
217 const openWith = useCallback((opener: ExternalOpenerView) => {
218 closeMenu();
219 void app.OpenLocalPathInExternalOpener(path, opener.id).catch((error) => {
220 showToast(t("externalOpener.failed", { name: opener.name, error: localPathErrorText(error) }), "error");
221 });
222 }, [closeMenu, path, showToast]);
223
224 const menuItems = useMemo<ContextMenuItem[]>(() => {
225 const openerItems = openers.openers.filter((opener) => opener.kind !== "file-manager").map((opener) => ({
226 key: `open-with-${opener.id}`,
227 label: t("externalOpener.openIn", { name: opener.name }),
228 onSelect: () => openWith(opener),
229 }));
230 return [
231 {
232 key: "open-default",
233 icon: <ExternalLink size={13} />,
234 label: t("externalOpener.openDefault"),
235 onSelect: () => {
236 closeMenu();
237 openLink(href);
238 },
239 },
240 ...(openerItems.length > 0
241 ? [{ type: "separator" as const, key: "open-with-separator" }, ...openerItems]
242 : []),
243 { type: "separator" as const, key: "path-separator" },
244 {
245 key: "reveal",
246 icon: <FolderOpen size={13} />,
247 label: t("externalOpener.reveal"),
248 onSelect: () => {
249 closeMenu();
250 void app.RevealPath(path).catch((error) => {
251 showToast(t("externalOpener.failed", { name: t("externalOpener.reveal"), error: localPathErrorText(error) }), "error");
252 });
253 },
254 },
255 {
256 key: "copy-path",
257 icon: <Copy size={13} />,
258 label: t("projectTree.copyPath"),
259 onSelect: () => {
260 closeMenu();
261 void writeClipboardText(path);
262 },
263 },
264 {
265 key: "save-as",
266 icon: <Save size={13} />,
267 label: t("externalOpener.saveAs"),
268 onSelect: () => {
269 closeMenu();
270 void app.SaveLocalPathAs(path).then((savedPath) => {
271 if (savedPath) {
272 showToast(t("externalOpener.saved", { path: savedPath }), "info");
273 }
274 }).catch((error) => {
275 showToast(t("externalOpener.failed", { name: t("externalOpener.saveAs"), error: localPathErrorText(error) }), "error");
276 });
277 },
278 },
279 ];
280 }, [closeMenu, href, openWith, openers.openers, path, showToast]);
281
282 return (
283 <>
284 <a
285 className="md-rich-link md-rich-link--local"
286 href={href}
287 onClick={(event) => {
288 event.preventDefault();
289 closeMenu();
290 openLink(href);
291 }}
292 onAuxClick={(event) => {
293 if (event.button !== 1) return;
294 event.preventDefault();
295 openLink(href);
296 }}
297 onMouseDown={(event) => {
298 if (event.button === 1) event.preventDefault();
299 }}
300 onContextMenu={(event) => {
301 event.preventDefault();
302 event.stopPropagation();
303 setMenuPoint(contextMenuPointFromEvent(event));
304 refreshOpeners();
305 }}
306 >
307 <ExternalLink aria-hidden="true" size={13} strokeWidth={2} />
308 <span className="md-rich-link__label">{children}</span>
309 </a>
310 <ContextMenu
311 open={menuPoint !== null}
312 point={menuPoint}
313 items={menuItems}
314 onClose={closeMenu}
315 minWidth={220}
316 ariaLabel={t("externalOpener.choose")}
317 />
318 </>
319 );
320 }
321
322 export function RichMarkdownLink({
323 href,
324 children,
325 }: {
326 href?: string;
327 children: ReactNode;
328 }) {
329 // Menu state lives here so web/mail links get a context menu; local file
330 // links keep their own richer menu inside LocalPathMarkdownLink below.
331 const { showToast } = useToast();
332 const github = parseGitHubLink(href);
333 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
334 const closeMenu = useCallback(() => setMenuPoint(null), []);
335 const copyText = useCallback((text: string) => {
336 closeMenu();
337 void writeClipboardText(text).then((copied) => {
338 if (copied) showToast(t("richLink.copied"), "info");
339 else showToast(t("richLink.copyFailed"), "error");
340 });
341 }, [closeMenu, showToast]);
342 const menuItems = useMemo(
343 () => richLinkMenuItems(href ?? "", github, closeMenu, copyText),
344 [closeMenu, copyText, github, href],
345 );
346 const openMenu = (event: ReactMouseEvent<HTMLAnchorElement> | ReactKeyboardEvent<HTMLAnchorElement>) => {
347 event.preventDefault();
348 event.stopPropagation();
349 setMenuPoint(contextMenuPointFromEvent(event));
350 };
351
352 const local = localPathFromHref(href);
353 if (local !== null) {
354 return <LocalPathMarkdownLink href={href ?? ""} path={local} children={children} />;
355 }
356
357 const iconKind = classifyLinkIcon(href);
358 const compactLabel = github && linkText(children) === href ? github.compactLabel : undefined;
359 const accessibleLabel = github ? githubAccessibleLabel(github) : undefined;
360 const handlers = {
361 onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
362 event.preventDefault();
363 openLink(href);
364 },
365 onAuxClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
366 if (event.button !== 1) return;
367 event.preventDefault();
368 openLink(href);
369 },
370 onMouseDown: (event: ReactMouseEvent<HTMLAnchorElement>) => {
371 if (event.button === 1) event.preventDefault();
372 },
373 };
374
375 if (!iconKind) {
376 return <a href={href} {...handlers}>{children}</a>;
377 }
378
379 return (
380 <>
381 <a
382 aria-label={compactLabel ? accessibleLabel : undefined}
383 className={`md-rich-link md-rich-link--${iconKind}`}
384 href={href}
385 title={github ? accessibleLabel : undefined}
386 {...handlers}
387 onContextMenu={openMenu}
388 onKeyDown={(event) => {
389 if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
390 openMenu(event);
391 }}
392 >
393 <LinkMark kind={iconKind} />
394 <span
395 className="md-rich-link__label"
396 data-display-label={compactLabel}
397 >
398 {children}
399 </span>
400 </a>
401 <ContextMenu
402 open={menuPoint !== null}
403 point={menuPoint}
404 items={menuItems}
405 onClose={closeMenu}
406 minWidth={200}
407 ariaLabel={t("richLink.menuAriaLabel")}
408 />
409 </>
410 );
411 }
412
412 lines Plain Text