返回 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 { unescapeRefPath } from "./refToken";
13
14 // Sentence punctuation is excluded from path characters: Windows forbids `:`
15 // in file names, and the CJK/ASCII punctuation set below almost never appears
16 // inside a real path. Excluding it at match time is far more reliable than
17 // trimming a trailing marker after the fact ("D:\x\y.md。已生成" must stop at
18 // `。`). `.` is deliberately kept (extensions) and only stripped when it is
19 // actually trailing.
20 const SENT_PUNCT = ",。;、!?,;!?()";
21
22 // One path character: either a backslash-escaped space/tab (kept, matching the
23 // @path grammar in lib/refToken), or any non-whitespace char that is not a
24 // quote, angle bracket, pipe, `?`, `*`, colon or sentence punctuation.
25 // The `\\[ \t]` alternative must come FIRST: in a `(?:A|B)+` loop the engine
26 // backtracks by dropping repetitions, not by retrying the alternative at the
27 // same position, so a trailing `\ ` would otherwise never be consumed.
28 const PATH_CHAR = String.raw`(?:\\[ \t]|[^\s<>"|?*:${SENT_PUNCT}])`;
29
30 // file:/// URLs are matched whole (their `:` and `.` are legal there). Drive
31 // and UNC prefix boundaries are checked in JavaScript rather than with regular
32 // expression lookbehind: macOS 12's WebKit rejects `(?<!...)` while loading the
33 // whole lazy Markdown chunk, before any message is rendered.
34 const FILE_RE = new RegExp(String.raw`file:///[^\s<>"|?*${SENT_PUNCT}]+`, "g");
35 const DRIVE_RE = new RegExp(String.raw`[A-Za-z]:[\\/]${PATH_CHAR}+`, "g");
36 // UNC share paths: the plugin runs on parsed markdown text nodes, where
37 // CommonMark backslash escaping has already folded `\\` into `\`. A share
38 // therefore arrives as `\nas\share\docs\report.md` (single leading
39 // backslash); linkify restores the `\\` prefix so the native opener receives
40 // the real UNC form. The leading `\` is mandatory (the fold always leaves
41 // one). The JavaScript prefix guard keeps `C:\nas` or a plain `a\b` from being
42 // mistaken for a share start without requiring WebKit lookbehind support.
43 const UNC_RE = new RegExp(
44 String.raw`\\(?!\\)[^\s\\<>"|?*:${SENT_PUNCT}]+\\${PATH_CHAR}+`,
45 "g",
46 );
47
48 const DRIVE_PREFIX_RE = /[:/A-Za-z]/;
49 const UNC_PREFIX_RE = /[\\/\w:;:,。、!?()]/;
50
51 function hasValidPrefixBoundary(text: string, start: number, kind: "file" | "drive" | "unc"): boolean {
52 if (kind === "file" || start === 0) return true;
53 const previous = text[start - 1];
54 return kind === "drive" ? !DRIVE_PREFIX_RE.test(previous) : !UNC_PREFIX_RE.test(previous);
55 }
56
57 // Trailing closers that are more likely sentence punctuation than file name
58 // characters. A trailing `)` is only stripped when parens are unbalanced —
59 // "C:\Program Files (x86)" keeps its closing paren, "D:\x\y.md)." loses it.
60 const TRAIL_STRIP_RE = /[.)\]]+$/;
61
62 function stripTrailingClosers(raw: string): string {
63 const stripped = raw.replace(TRAIL_STRIP_RE, "");
64 if (stripped === raw) return raw;
65 // A trailing `)` may be a real file-name character inside balanced parens
66 // ("C:\Program Files (x86)") — only strip it when parens are unbalanced,
67 // i.e. the removed part contains a `)` with no matching opener.
68 if (raw.slice(stripped.length).includes(")")) {
69 const open = (raw.match(/\(/g) ?? []).length;
70 const close = (raw.match(/\)/g) ?? []).length;
71 if (close <= open) return raw;
72 }
73 return stripped;
74 }
75
76 export interface LocalPathSegment {
77 /** Raw text to render (keeps the original spelling, escapes intact). */
78 text: string;
79 /** When present, this segment is a clickable local path. */
80 path?: string;
81 }
82
83 /**
84 * Splits `text` into plain segments and clickable local-path segments.
85 * Pure function — unit tests cover the full recognition matrix here.
86 */
87 export function linkifyLocalPaths(text: string): LocalPathSegment[] {
88 const matches: Array<{ start: number; end: number; raw: string; kind: "file" | "drive" | "unc" }> = [];
89 const patterns: Array<[RegExp, "file" | "drive" | "unc"]> = [
90 [FILE_RE, "file"],
91 [DRIVE_RE, "drive"],
92 [UNC_RE, "unc"],
93 ];
94 for (const [re, kind] of patterns) {
95 re.lastIndex = 0;
96 let m: RegExpExecArray | null;
97 while ((m = re.exec(text)) !== null) {
98 const match = m;
99 const matchEnd = match.index + match[0].length;
100 if (!hasValidPrefixBoundary(text, match.index, kind)) {
101 continue;
102 }
103 // RegExpExecArray has no start/end — compare via index/length.
104 const overlapped = matches.some((p) => !(matchEnd <= p.start || match.index >= p.end));
105 if (!overlapped) {
106 matches.push({ start: match.index, end: matchEnd, raw: match[0], kind });
107 }
108 if (match.index === re.lastIndex) re.lastIndex += 1; // guard against zero-width
109 }
110 }
111 matches.sort((a, b) => a.start - b.start);
112
113 const segments: LocalPathSegment[] = [];
114 let cursor = 0;
115 for (const m of matches) {
116 if (m.start > cursor) segments.push({ text: text.slice(cursor, m.start) });
117 // UNC text arrives with a single leading backslash (markdown folded the
118 // `\\` escape); restore the real UNC prefix for the native opener.
119 const path = m.kind === "unc" ? "\\" + stripTrailingClosers(m.raw) : stripTrailingClosers(m.raw);
120 if (path) {
121 segments.push({ text: m.raw, path: unescapeRefPath(path) });
122 } else {
123 segments.push({ text: m.raw });
124 }
125 cursor = Math.max(cursor, m.end);
126 }
127 if (cursor < text.length) segments.push({ text: text.slice(cursor) });
128 return segments;
129 }
130
131 /**
132 * Builds the href for a clickable local path. Uses file:/// with forward
133 * slashes (the standard Windows form) and percent-encodes non-ASCII and `#`.
134 * Already-absolute file:/// matches (FILE_RE raw text) are decoded first so
135 * their literal %xx sequences are not double-encoded. The click handler
136 * decodes it back before calling the native opener.
137 */
138 export function localPathHref(path: string): string {
139 if (path.startsWith("file:///")) {
140 try {
141 path = decodeURIComponent(path.slice("file:///".length));
142 } catch {
143 // Malformed escapes: keep the literal text rather than failing.
144 }
145 }
146 return "file:///" + encodeURI(path.replace(/\\/g, "/")).replace(/#/g, "%23");
147 }
148
149 /**
150 * Remark plugin: rewrites plain-text nodes containing local paths into link
151 * nodes (text + link alternation), so the markdown renderer hands them to
152 * RichMarkdownLink which routes them to the native opener.
153 *
154 * Collection and replacement are separate passes: replacing during the visit
155 * would re-visit the freshly inserted link children and nest anchors.
156 */
157 export function remarkLocalPathLinks() {
158 return (tree: Root) => {
159 const plan: Array<{ parent: Parent; index: number; nodes: Array<Text | Link> }> = [];
160 visit(tree, "text", (node: Text, index: number | undefined, parent) => {
161 if (parent === undefined || parent === null || index === undefined || index === null) return;
162 // Skip link internals: rewriting their text would nest <a> elements
163 // (mdast forbids links inside links) and double-fire on click.
164 if (parent.type === "link" || parent.type === "linkReference") return;
165 const segments = linkifyLocalPaths(node.value);
166 if (segments.length === 1 && segments[0].path === undefined) return;
167 plan.push({
168 parent,
169 index,
170 nodes: segments.map<Text | Link>((seg) =>
171 seg.path !== undefined
172 ? {
173 type: "link",
174 url: localPathHref(seg.path),
175 title: null,
176 children: [{ type: "text", value: seg.text }],
177 }
178 : { type: "text", value: seg.text },
179 ),
180 });
181 });
182 // Back-to-front keeps earlier indices valid within the same parent.
183 for (let i = plan.length - 1; i >= 0; i--) {
184 const { parent, index, nodes } = plan[i];
185 parent.children.splice(index, 1, ...nodes);
186 }
187 };
188 }
189
189 lines TYPESCRIPT