返回 DeepSeek-Reasonix
githubLink.tsx
根目录 / desktop / frontend / src / components / githubLink.tsx
1 import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
2 import { ExternalLink, Mail } from "lucide-react";
3 import { app, openExternal } from "../lib/bridge";
4
5 export interface GitHubLinkInfo {
6 kind: "issue" | "pull" | "commit";
7 owner: string;
8 repo: string;
9 value: string;
10 compactLabel: string;
11 }
12
13 export type LinkIconKind = "github" | "external" | "mail";
14
15 function linkText(children: ReactNode): string {
16 if (typeof children === "string") return children;
17 if (typeof children === "number") return String(children);
18 if (!Array.isArray(children)) return "";
19 return children
20 .map((child) => typeof child === "string" || typeof child === "number" ? String(child) : "")
21 .join("");
22 }
23
24 function githubAccessibleLabel(info: GitHubLinkInfo): string {
25 const resource = info.kind === "pull"
26 ? `pull request #${info.value}`
27 : info.kind === "issue"
28 ? `issue #${info.value}`
29 : `commit ${info.compactLabel}`;
30 return `GitHub ${info.owner}/${info.repo} ${resource}`;
31 }
32
33 export function classifyLinkIcon(href?: string): LinkIconKind | null {
34 if (!href) return null;
35 let url: URL;
36 try {
37 url = new URL(href);
38 } catch {
39 return null;
40 }
41
42 if (url.protocol === "mailto:") return "mail";
43 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
44 if (url.protocol === "https:" && ["github.com", "www.github.com"].includes(url.hostname.toLowerCase())) {
45 return "github";
46 }
47 return "external";
48 }
49
50 export function parseGitHubLink(href?: string): GitHubLinkInfo | null {
51 if (!href) return null;
52 let url: URL;
53 try {
54 url = new URL(href);
55 } catch {
56 return null;
57 }
58 if (url.protocol !== "https:" || !["github.com", "www.github.com"].includes(url.hostname.toLowerCase())) {
59 return null;
60 }
61
62 const parts = url.pathname.split("/").filter(Boolean);
63 if (parts.length !== 4) return null;
64 const [owner, repo, resource, value] = parts;
65 if (!owner || !repo || !value) return null;
66
67 if (resource === "issues" && /^\d+$/.test(value)) {
68 return { kind: "issue", owner, repo, value, compactLabel: `#${value}` };
69 }
70 if (resource === "pull" && /^\d+$/.test(value)) {
71 return { kind: "pull", owner, repo, value, compactLabel: `PR #${value}` };
72 }
73 if (resource === "commit" && /^[0-9a-f]{7,40}$/i.test(value)) {
74 return { kind: "commit", owner, repo, value, compactLabel: value.slice(0, 7) };
75 }
76 return null;
77 }
78
79 function LinkMark({ kind }: { kind: LinkIconKind }) {
80 if (kind === "github") {
81 return (
82 <svg aria-hidden="true" fill="none" height="13" viewBox="0 0 24 24" width="13">
83 <path
84 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"
85 stroke="currentColor"
86 strokeLinecap="round"
87 strokeLinejoin="round"
88 strokeWidth="2"
89 />
90 <path
91 d="M8 19c-3 .9-3-1.5-4-2"
92 stroke="currentColor"
93 strokeLinecap="round"
94 strokeLinejoin="round"
95 strokeWidth="2"
96 />
97 </svg>
98 );
99 }
100 if (kind === "mail") return <Mail aria-hidden="true" size={13} strokeWidth={2} />;
101 return <ExternalLink aria-hidden="true" size={13} strokeWidth={2} />;
102 }
103
104 function openLink(href: string | undefined) {
105 const local = localPathFromHref(href);
106 if (local !== null) {
107 // Local paths (linkified plain text or explicit file:/// links) open in
108 // the OS default app via the native binding, never in the system browser.
109 void app.OpenLocalPath(local).catch(() => {});
110 return;
111 }
112 if (href) openExternal(href);
113 }
114
115 // localPathFromHref returns the decoded local filesystem path when href is a
116 // file:/// URL (the form remarkLocalPathLinks emits), or null otherwise.
117 export function localPathFromHref(href?: string): string | null {
118 if (!href || !href.startsWith("file:///")) return null;
119 try {
120 return decodeURIComponent(href.slice("file:///".length));
121 } catch {
122 return null;
123 }
124 }
125
126 export function RichMarkdownLink({
127 href,
128 children,
129 }: {
130 href?: string;
131 children: ReactNode;
132 }) {
133 const github = parseGitHubLink(href);
134 const iconKind = classifyLinkIcon(href);
135 const compactLabel = github && linkText(children) === href ? github.compactLabel : undefined;
136 const accessibleLabel = github ? githubAccessibleLabel(github) : undefined;
137 const handlers = {
138 onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
139 event.preventDefault();
140 openLink(href);
141 },
142 onAuxClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
143 event.preventDefault();
144 openLink(href);
145 },
146 onMouseDown: (event: ReactMouseEvent<HTMLAnchorElement>) => {
147 if (event.button === 1) event.preventDefault();
148 },
149 };
150
151 if (!iconKind) {
152 return <a href={href} {...handlers}>{children}</a>;
153 }
154
155 return (
156 <a
157 aria-label={compactLabel ? accessibleLabel : undefined}
158 className={`md-rich-link md-rich-link--${iconKind}`}
159 href={href}
160 title={github ? accessibleLabel : undefined}
161 {...handlers}
162 >
163 <LinkMark kind={iconKind} />
164 <span
165 className="md-rich-link__label"
166 data-display-label={compactLabel}
167 >
168 {children}
169 </span>
170 </a>
171 );
172 }
173
173 lines Plain Text