返回 DeepSeek-Reasonix
localPathLinks.ts
根目录 / desktop / frontend / src / lib / localPathLinks.ts
1 // Local-path linkification for chat markdown (issue #7426).
2 //
3 // AI replies frequently print local file paths as plain text (Windows drive
4 // paths, UNC paths, file:/// URLs). GFM autolink literals only recognize
5 // http(s)/www/email, so these render as inert text. This module rewrites
6 // matching text nodes into markdown links whose href is a file:/// URL; the
7 // link click handler (RichMarkdownLink) routes those to the native
8 // OpenLocalPath binding instead of the system browser.
9
10 import { visit } from "unist-util-visit";
11 import type { Parent, Link, Root, Text } from "mdast";
12 import { hasDisallowedWindowsPathSyntax, isLocalFileHref } from "./localFileUrl";
13 import { unescapeRefPath } from "./refToken";
14
15 // Sentence punctuation is excluded from path characters: Windows forbids `:`
16 // in file names, and the CJK/ASCII punctuation set below almost never appears
17 // inside a real path. Excluding it at match time is far more reliable than
18 // trimming a trailing marker after the fact ("D:\x\y.md。已生成" must stop at
19 // `。`). `.` is deliberately kept (extensions) and only stripped when it is
20 // actually trailing.
21 const SENT_PUNCT = ",。;、!?,;!?()";
22
23 // One path character: either a backslash-escaped space/tab (kept, matching the
24 // @path grammar in lib/refToken), or any non-whitespace char that is not a
25 // quote, angle bracket, pipe, `?`, `*`, colon or sentence punctuation.
26 // The `\\[ \t]` alternative must come FIRST: in a `(?:A|B)+` loop the engine
27 // backtracks by dropping repetitions, not by retrying the alternative at the
28 // same position, so a trailing `\ ` would otherwise never be consumed.
29 const PATH_CHAR = String.raw`(?:\\[ \t]|[^\s<>"|?*::${SENT_PUNCT}])`;
30
31 // file URLs are matched whole (their `:` and `.` are legal there). Drive and
32 // UNC prefix boundaries are checked in JavaScript rather than with regular
33 // expression lookbehind: macOS 12's WebKit rejects `(?<!...)` while loading the
34 // whole lazy Markdown chunk, before any message is rendered. URL candidates are
35 // validated with the shared parser below so malformed file URLs stay inert.
36 const FILE_RE = new RegExp(String.raw`file://[^\s<>"|?*${SENT_PUNCT}]+`, "g");
37 const DRIVE_RE = new RegExp(String.raw`[A-Za-z]:[\\/]${PATH_CHAR}+`, "g");
38 // UNC share paths: the plugin runs on parsed markdown text nodes, where
39 // CommonMark backslash escaping has already folded `\\` into `\`. A share
40 // therefore arrives as `\nas\share\docs\report.md` (single leading
41 // backslash); linkify restores the `\\` prefix so the native opener receives
42 // the real UNC form. The leading `\` is mandatory (the fold always leaves
43 // one). The JavaScript prefix guard keeps `C:\nas` or a plain `a\b` from being
44 // mistaken for a share start without requiring WebKit lookbehind support.
45 const UNC_RE = new RegExp(
46 String.raw`\\(?!\\)[^\s\\<>"|?*::${SENT_PUNCT}]+\\${PATH_CHAR}+`,
47 "g",
48 );
49
50 // POSIX absolute paths. Claiming every absolute path would turn `/api/v1` or
51 // `/usr/bin/env` inside prose into a file link, so a candidate must end in a
52 // file extension and must start a token. The host still verifies it before it
53 // becomes clickable, and an unverified one renders as its original text.
54 const POSIX_RE = new RegExp(
55 String.raw`/(?:(?:\\[ \t])|[^\s<>"|?*::${SENT_PUNCT}])*\.[A-Za-z0-9]{1,12}(?![A-Za-z0-9])`,
56 "g",
57 );
58
59 const DRIVE_PREFIX_RE = /[:/A-Za-z]/;
60 const UNC_PREFIX_RE = /[\\/\w:;:,。、!?()]/;
61 // A scanned POSIX path must start a token: `a/b.png` is relative, and the `/y`
62 // of `https://x/y` belongs to a URL.
63 const POSIX_PREFIX_RE = /[A-Za-z0-9_./\\~:@%+-]/;
64 // A file URL must start a URI-like token. Without this guard, the matcher can
65 // start in the middle of `profile://...` or `http://file://...` and produce a
66 // clickable suffix that was never a local path.
67 const FILE_PREFIX_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./\\:+-?#&=";
68
69 type LocalPathKind = "file" | "drive" | "unc" | "posix";
70
71 function hasValidPrefixBoundary(text: string, start: number, kind: LocalPathKind): boolean {
72 if (start === 0) return true;
73 const previous = text[start - 1];
74 if (kind === "file") return !FILE_PREFIX_CHARS.includes(previous);
75 if (kind === "posix") return !POSIX_PREFIX_RE.test(previous);
76 return kind === "drive" ? !DRIVE_PREFIX_RE.test(previous) : !UNC_PREFIX_RE.test(previous);
77 }
78
79 // Trailing closers that are more likely sentence punctuation than file name
80 // characters. A trailing `)` is only stripped when parens are unbalanced —
81 // "C:\Program Files (x86)" keeps its closing paren, "D:\x\y.md)." loses it.
82 const TRAIL_STRIP_RE = /[.)\]]+$/;
83
84 function stripTrailingClosers(raw: string): string {
85 const stripped = raw.replace(TRAIL_STRIP_RE, "");
86 if (stripped === raw) return raw;
87 // A trailing `)` may be a real file-name character inside balanced parens
88 // ("C:\Program Files (x86)") — only strip it when parens are unbalanced,
89 // i.e. the removed part contains a `)` with no matching opener.
90 if (raw.slice(stripped.length).includes(")")) {
91 const open = (raw.match(/\(/g) ?? []).length;
92 const close = (raw.match(/\)/g) ?? []).length;
93 if (close <= open) return raw;
94 }
95 return stripped;
96 }
97
98 export interface LocalPathSegment {
99 /** Raw text to render (keeps the original spelling, escapes intact). */
100 text: string;
101 /** When present, this segment is a clickable local path. */
102 path?: string;
103 /** Recognition rule that produced the segment, for the renderer's policy. */
104 kind?: LocalPathKind;
105 }
106
107 /**
108 * Splits `text` into plain segments and clickable local-path segments.
109 * Pure function — unit tests cover the full recognition matrix here.
110 */
111 export function linkifyLocalPaths(text: string): LocalPathSegment[] {
112 const matches: Array<{ start: number; end: number; raw: string; kind: LocalPathKind }> = [];
113 const patterns: Array<[RegExp, LocalPathKind]> = [
114 [FILE_RE, "file"],
115 [DRIVE_RE, "drive"],
116 [UNC_RE, "unc"],
117 [POSIX_RE, "posix"],
118 ];
119 for (const [re, kind] of patterns) {
120 re.lastIndex = 0;
121 let m: RegExpExecArray | null;
122 while ((m = re.exec(text)) !== null) {
123 const match = m;
124 const matchEnd = match.index + match[0].length;
125 if (!hasValidPrefixBoundary(text, match.index, kind)) {
126 continue;
127 }
128 if (kind === "file" && !isLocalFileHref(stripTrailingClosers(match[0]))) {
129 continue;
130 }
131 // FILE_RE stops before a question mark so it cannot consume query
132 // syntax. Do not linkify the safe-looking prefix of a file URL that has
133 // a raw query suffix; localPathFromHref intentionally rejects queries.
134 if (kind === "file" && text[matchEnd] === "?") {
135 continue;
136 }
137 // Do not turn the safe-looking prefix of an alternate data stream into
138 // a link (for example, C:\\report.md in C:\\report.md:payload).
139 if (kind !== "file" && text[matchEnd] === ":" && !/\s/.test(text[matchEnd + 1] ?? "")) {
140 continue;
141 }
142 // RegExpExecArray has no start/end — compare via index/length.
143 const overlapped = matches.some((p) => !(matchEnd <= p.start || match.index >= p.end));
144 if (!overlapped) {
145 matches.push({ start: match.index, end: matchEnd, raw: match[0], kind });
146 }
147 if (match.index === re.lastIndex) re.lastIndex += 1; // guard against zero-width
148 }
149 }
150 matches.sort((a, b) => a.start - b.start);
151
152 const segments: LocalPathSegment[] = [];
153 let cursor = 0;
154 for (const m of matches) {
155 if (m.start > cursor) segments.push({ text: text.slice(cursor, m.start) });
156 // UNC text arrives with a single leading backslash (markdown folded the
157 // `\\` escape); restore the real UNC prefix for the native opener.
158 const path = m.kind === "unc" ? "\\" + stripTrailingClosers(m.raw) : stripTrailingClosers(m.raw);
159 const decodedPath = unescapeRefPath(path);
160 if (path && !hasDisallowedWindowsPathSyntax(decodedPath)) {
161 segments.push({ text: m.raw, path: decodedPath, kind: m.kind });
162 } else {
163 segments.push({ text: m.raw });
164 }
165 cursor = Math.max(cursor, m.end);
166 }
167 if (cursor < text.length) segments.push({ text: text.slice(cursor) });
168 return segments;
169 }
170
171 /**
172 * Builds the href for a clickable local path. Uses file:/// with forward
173 * slashes (the standard Windows form) and percent-encodes non-ASCII and `#`.
174 * Valid file URLs are preserved verbatim so authority-form UNC URLs are not
175 * rewritten into a different path.
176 */
177 export function localPathHref(path: string): string {
178 if (isLocalFileHref(path)) return path;
179 if (path.startsWith("file:///")) {
180 try {
181 path = decodeURIComponent(path.slice("file:///".length));
182 } catch {
183 // Malformed escapes: keep the literal text rather than failing.
184 }
185 }
186 const slash = path.replace(/\\/g, "/");
187 const encoded = encodeURI(slash).replace(/#/g, "%23");
188 // A POSIX path already starts with the separator the URL needs; adding
189 // another would turn `/tmp/a.svg` into the UNC-looking `//tmp/a.svg`.
190 return slash.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`;
191 }
192
193 /**
194 * Remark plugin: rewrites plain-text nodes containing local paths into link
195 * nodes (text + link alternation), so the markdown renderer hands them to
196 * RichMarkdownLink which routes them to the native opener.
197 *
198 * Collection and replacement are separate passes: replacing during the visit
199 * would re-visit the freshly inserted link children and nest anchors.
200 */
201 export function remarkLocalPathLinks() {
202 return (tree: Root) => {
203 const plan: Array<{ parent: Parent; index: number; nodes: Array<Text | Link> }> = [];
204 visit(tree, "text", (node: Text, index: number | undefined, parent) => {
205 if (parent === undefined || parent === null || index === undefined || index === null) return;
206 // Skip link internals: rewriting their text would nest <a> elements
207 // (mdast forbids links inside links) and double-fire on click.
208 if (parent.type === "link" || parent.type === "linkReference") return;
209 const segments = linkifyLocalPaths(node.value);
210 if (segments.length === 1 && segments[0].path === undefined) return;
211 plan.push({
212 parent,
213 index,
214 nodes: segments.map<Text | Link>((seg) =>
215 seg.path !== undefined
216 ? {
217 type: "link",
218 url: localPathHref(seg.path),
219 title: null,
220 children: [{ type: "text", value: seg.text }],
221 // A scanned POSIX path is only a guess: the renderer keeps it
222 // as ordinary text unless the host confirms the file exists.
223 // Drive, UNC and file:// links keep their established behavior.
224 ...(seg.kind === "posix" ? { data: { hProperties: { "data-scanned-path": "posix" } } } : {}),
225 }
226 : { type: "text", value: seg.text },
227 ),
228 });
229 });
230 // Back-to-front keeps earlier indices valid within the same parent.
231 for (let i = plan.length - 1; i >= 0; i--) {
232 const { parent, index, nodes } = plan[i];
233 parent.children.splice(index, 1, ...nodes);
234 }
235 };
236 }
237
237 lines TYPESCRIPT