| 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 |