Clickable paths in Claude Code replies: click a folder to open it in your file manager, click the file name to open the file

<a href="https://github.com/pepperonas/path-links"><img src="docs/social.png" alt="path-links — click a folder in a path to open it in Finder, click the file name to open the file" width="100%"></a>
A Claude Code mod that turns every file path in Claude's replies into links — one per folder. Click a folder to open it in your file manager, click the file name to open the file.
<a href="#-install"><img alt="Install in 10 seconds" height="56" src="https://img.shields.io/badge/%E2%AC%87%EF%B8%8F_Install-in_10_seconds-2E9E5B?style=for-the-badge"></a> <a href="#-demo"><img alt="See it move" height="56" src="https://img.shields.io/badge/%F0%9F%8E%AC_Demo-see_it_move-7B4DFF?style=for-the-badge"></a>
<h3>👉 <code>/plugin marketplace add pepperonas/path-links</code> · <code>/plugin install path-links@pepperonas-path-links</code> — that's it.</h3>
[!NOTE] Nothing is changed but the drawing. The reply Claude wrote stays exactly as it was (ctrl+o shows it); path-links only rewrites how the transcript draws it. It reads nothing but whether a path exists, makes no network call and starts no process — the click is your terminal's.
<img src="docs/demo.gif" alt="The pointer walks along a path in a Claude Code reply: each folder lights up as its own link, and the last segment opens the file." width="100%">
<sub>Every folder in the path is its own link, and so is the file. Point at one and your terminal shows where it goes; click it and it opens — a folder in Finder (or your file manager), a file in the app it belongs to.</sub>
<img src="docs/hero.png" alt="A Claude Code reply with a long path; the folder Projekt_Demo is hovered and a card says it opens in Finder." width="100%">
<sub>Hover any segment: <code>Projekt_Demo</code> opens that folder, not the file next to it.</sub>
<img src="docs/before-after.png" alt="Left: the path as plain text. Right: the same path with every folder and the file underlined as links." width="100%">
<sub>Before and after — the text is the same, character for character; only the links are new.</sub>
<img src="docs/codeblock.png" alt="A code block with an open command stays as written; under it a line repeats the path as links. A relative path with :42 is linked too." width="100%">
<sub>Code blocks stay copyable as written; the paths they name follow on a line of their own. Relative paths and <code>:line</code> suffixes work too.</sub>
<img src="docs/mode-file.png" alt="With /path-links file, the whole path is one link." width="100%">
<sub><code>/path-links file</code>: the whole path as one link, if you prefer it that way.</sub>
docs to open docs, click Projekt_Demo to open that folder, click the file name to open the file. Folders open in Finder (or your Linux file manager), files in their default app.My Drive, Main St. 24: running text is matched word by word and the longest spelling that exists wins, so the sentence after a path is never swallowed.and/or stays text.~/ and relative — hooks/register.tsx resolves against the session's working directory; the spelling you see is kept as written.:42 and :42:7 — line suffixes stay as text after the file link. /etc/hosts ` becomes the links (a link can't live inside a code span); inline code holding a whole command stays code and its paths follow on the next line.↳ …, each once, three at most.https:// URLs are never rewritten./ is passed through untouched.file:// links open with open / xdg-open, which is your terminal's business, not the mod's.The repository is its own plugin marketplace. In Claude Code:
/plugin marketplace add pepperonas/path-links
/plugin install path-links@pepperonas-path-links
or from the shell:
claude plugin marketplace add pepperonas/path-links
claude plugin install path-links@pepperonas-path-links
Replies drawn after that carry the links. Update with /plugin marketplace update pepperonas-path-links, then claude plugin update path-links@pepperonas-path-links. Settings: /plugin configure path-links@pepperonas-path-links (every option has a default, so you can skip it).
Claude Code loads a plugin it finds in ~/.claude/skills/<name> by itself, in every session:
git clone https://github.com/pepperonas/path-links ~/.claude/skills/path-links
Update with git -C ~/.claude/skills/path-links pull. If you also install it from the marketplace, the marketplace copy wins.
git clone https://github.com/pepperonas/path-links
claude --plugin-dir ./path-links
Where you can't pass a flag, name the folder in CLAUDE_CODE_PLUGIN_DIRS — in your shell environment or in the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/src/path-links" } }
Nothing to do: ask Claude where something is, and the path in the answer is clickable. The command tunes it:
| Command | What it does | |
|---|---|---|
/path-links | Shows the current settings and this list | |
/path-links segments | Every folder and the file are links (default) | |
/path-links file | The whole path is one link, to the file | |
/path-links off | No links; replies are drawn as written | |
| `/path-links blocks on\ | off` | The ↳ line under code blocks and command-style inline code |
| `/path-links relative on\ | off` | Link relative paths such as src/routes/login.ts |
Settings changed with the command are remembered across sessions.
/plugin configure path-links@pepperonas-path-links sets the defaults; the command above overrides them.
| Option | What it does | Default |
|---|---|---|
mode | segments: every folder and the file are links · file: the whole path is one link · off | segments |
codeBlocks | Paths inside a code block get a clickable line under the block | on |
relative | Link relative paths, resolved against the working directory | on |
Claude Code draws each block of an assistant reply through a ui.render event for the AssistantMessage component. path-links hooks it and hands the engine the same markdown with the paths turned into links:
/Users/me/My Drive/x.html
↓
/[Users](file:///Users/)/[me](file:///Users/me/)/[My Drive](file:///Users/me/My%20Drive/)/[x.html](file:///Users/me/My%20Drive/x.html)
Claude Code's own markdown renderer draws those as OSC 8 hyperlinks — the terminal shows the text unchanged and knows where each piece points. A click hands the file:// URL to the system: a URL ending in / is a folder and opens in the file manager, anything else opens with its default app.
hooks/paths.ts, pure) — the markdown is split into code blocks, inline code, links/URLs and running text. Path candidates start at /, ~/, ./ or word/; in running text a candidate may stretch over up to ten space-separated words, in quotes or inline code it is taken whole. Trailing punctuation, an unopened ) and a :line suffix are peeled off.hooks/register.tsx + walk in hooks/paths.ts) — every candidate is walked down from its trusted root with $.fs.list, one folder at a time, never through a symlink (see Privacy); at most 64 candidates per drawing, listings and answers cached. Whether a path is a file or a folder comes from the listing.hooks/paths.ts) — the same scanner runs again with the answers and picks the longest spelling that exists. Each segment becomes name; labels are markdown-escaped (Projekt\_Demo), URLs percent-encoded (spaces, umlauts, #, ?, parentheses).The stored message is never changed — only its drawing — so copying, ctrl+o, /export and the model's context all see the reply exactly as Claude wrote it.
open itself; clicking is between you and your terminal./net, /Network, macOS's /home), a mounted share or a symlink to one — and reply text must never make your machine contact a host. So no path from a reply is ever handed to stat, which follows links. A lookup starts at a trusted root — your home folder, the working directory (unless it is /), or a local system folder (/etc, /tmp, /usr, /opt, /Applications, /Library, …) — and walks down one directory listing at a time; a listing names a symlink without following it, and a path that runs through a symlink is simply not linked. /Volumes, /mnt, /media, /System/… and everything outside the roots are never looked at. A listing slower than 300 ms counts as missing, so a stalled mount can't freeze the transcript./path-links settings.There are two suites, and the split is deliberate.
Node suite — tests/*.spec.ts, plain node:test, no Claude Code needed; this is what CI runs. It drives the scanner against a pretend file system: paths with spaces in running text, the words after a path not swallowed, trailing punctuation and unbalanced brackets, :line:col, ~ and relative spellings, missing paths left exactly as written, links/autolinks/URLs untouched, inline code with a path vs. with a command, fenced blocks unchanged with the ↳ line (and none while a fence is still streaming), dedupe and the three-path cap, quoted paths, file and off modes, URL encoding (spaces, umlauts, #?()), markdown escaping — and that rewrite never looks up a path probes did not name. Plus drift guards that hold this README to the code: the version everywhere, the test-count badges, every /config field and its default, every command, every image, the marketplace entry and the install commands.
Engine suite — hooks/*.test.tsx, run by claude plugin test . against Claude Code's own engine: a reply is drawn on the terminal and desktop surfaces with one link per folder plus the file, a reply without existing paths is drawn exactly as written, relative paths resolve against the working directory, a path through a symlink is not linked, /path-links off and back, and a second drawing lists nothing again while only trusted roots are ever stat-ed.
Every guarded behaviour is mutated once. A test that has never been seen red is not an assurance. So each one gets its bug put back — the existence check skipped, the longest-match order reversed, the trailing-punctuation strip removed, the folder slash dropped from the URL, the label escaping removed, fenced blocks rewritten, URLs rewritten, the cache bypassed, the walk allowed through a symlink, / accepted as a working-directory root, a reply's path stat-ed directly — and the suite must go red.
npm install # dev tools only; the mod itself has no dependencies
npm test # node suite (CI)
claude plugin test . # engine suite
claude plugin validate . # what the module hooks and calls
npm run screenshots # re-render docs/ (needs `npx playwright install chromium` and ffmpeg)
It was also verified live: Claude Code 2.1.296 with --plugin-dir, a reply naming a real path under ~/My Drive/ with spaces and a dot in a folder name — the raw terminal output carries one ESC ] 8 ;; file://… hyperlink per segment.
Nothing is clickable. Check that your terminal supports OSC 8 hyperlinks and how it opens them — in iTerm2 and Terminal.app it is ⌘-click. /path-links shows whether the mod is on; if it says mode off, run /path-links segments. A path that doesn't exist on this machine is deliberately left as text.
A path with spaces is only half linked. In running text the mod stretches over at most ten words and only accepts a spelling that exists. If Claude wrote a path that doesn't exist yet (a file it is about to create), there is nothing to open — it gets linked once the file is there and the reply is drawn again.
Why not link paths inside code blocks? A link can't live inside a code block in markdown, and rewriting the block would break copying the command. So the block stays exactly as written and its paths follow underneath.
Windows? Not yet — the scanner speaks /-separated paths. PRs welcome.
The full history is in CHANGELOG.md (Keep a Changelog).
file:// links, paths with spaces, ~ and relative paths, :line suffixes, the ↳ line under code blocks, /path-links.Issues and pull requests are welcome. Please keep both suites green (npm test, claude plugin test .) and put the bug back once before you trust a new test. A change to a command, a setting or the version usually needs its counterpart in this README in the same PR — the drift guards will point at it.
path-links is free and stays that way. If it saved you a few trips through Finder:
MIT © 2026 Martin Pfeffer · celox.io. See LICENSE.
path-links is an independent community project and is not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC.
hooks/register.tsx 156 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { DEFAULT_PREFS, probes, rewrite, rootOf, walk } from './paths'
4import type { Ctx, Entry, Kind, Lister, Mode, Prefs } from './paths'
5
6const MODES: readonly Mode[] = ['segments', 'file', 'off']
7/** How long a lookup is trusted: a path that exists rarely vanishes, a missing one may appear soon. */
8const HIT_MS = 60_000
9const MISS_MS = 5_000
10/** Lookups one drawing may start; the rest wait for the next drawing. */
11const MAX_PROBES = 64
12/** Lookups remembered at most. */
13const MAX_CACHE = 2000
14
15/** userConfig defaults, overridden by what /path-links stored. */
16export function prefsOf(options: PluginOptions, stored: unknown): Prefs {
17 const s = (stored && typeof stored === 'object' ? stored : {}) as Partial<Prefs>
18 const isMode = (v: unknown): v is Mode => MODES.includes(v as Mode)
19 const isBool = (v: unknown): v is boolean => typeof v === 'boolean'
20 const opt = <T,>(v: unknown, ok: (v: unknown) => v is T, d: T): T => (ok(v) ? v : d)
21 const base: Prefs = {
22 mode: opt(options.mode, isMode, DEFAULT_PREFS.mode),
23 codeBlocks: opt(options.codeBlocks, isBool, DEFAULT_PREFS.codeBlocks),
24 relative: opt(options.relative, isBool, DEFAULT_PREFS.relative),
25 }
26 return {
27 mode: opt(s.mode, isMode, base.mode),
28 codeBlocks: opt(s.codeBlocks, isBool, base.codeBlocks),
29 relative: opt(s.relative, isBool, base.relative),
30 }
31}
32
33let prefs: Prefs = DEFAULT_PREFS
34let home: string | undefined
35const cache = new Map<string, { kind: Kind | null; at: number }>()
36
37/** A listing that has not answered by then counts as missing, so a stalled mount never holds up a drawing. */
38const LIST_TIMEOUT_MS = 300
39/** How long a directory listing is reused. */
40const LIST_MS = 5_000
41
42const listings = new Map<string, { entries: readonly Entry[] | undefined; at: number }>()
43const rootsReal = new Map<string, string | null>()
44
45async function timed<T>($: EngineInterface, p: Promise<T>): Promise<T | undefined> {
46 let timer: { cancel(): void } | undefined
47 const late = new Promise<undefined>(done => {
48 timer = $.clock.after(LIST_TIMEOUT_MS, () => done(undefined))
49 })
50 const r = await Promise.race([p.catch(() => undefined), late])
51 timer?.cancel()
52 return r
53}
54
55/** Where a trusted root really lies (`/tmp` is `/private/tmp` on macOS); only roots are ever stat-ed. */
56async function realRoot($: EngineInterface, root: string): Promise<string | null> {
57 if (!rootsReal.has(root)) {
58 const st = await timed($, $.fs.stat(root, { resolve: true }))
59 rootsReal.set(root, st?.kind === 'dir' && st.realPath ? st.realPath : null)
60 }
61 return rootsReal.get(root) ?? null
62}
63
64async function kindOf($: EngineInterface, abs: string, ctx: Ctx, now: number): Promise<Kind | null> {
65 const root = rootOf(abs, ctx)
66 if (!root) return null
67 const real = await realRoot($, root)
68 if (!real) return null
69 const list: Lister = async dir => {
70 const c = listings.get(dir)
71 if (c && now - c.at <= LIST_MS) return c.entries
72 const entries = await timed($, $.fs.list(dir))
73 listings.set(dir, { entries, at: now })
74 return entries
75 }
76 return (await walk(abs, root, real, list)) ?? null
77}
78
79/** Look up what the text names and is not fresh in the cache. */
80async function lookUp($: EngineInterface, paths: string[], now: number, ctx: Ctx): Promise<void> {
81 const stale = paths.filter(p => {
82 const c = cache.get(p)
83 return !c || now - c.at > (c.kind ? HIT_MS : MISS_MS)
84 })
85 await Promise.all(
86 stale.slice(0, MAX_PROBES).map(async p => {
87 cache.set(p, { kind: await kindOf($, p, ctx, now), at: now })
88 }),
89 )
90 if (listings.size > MAX_CACHE) listings.clear()
91 if (cache.size > MAX_CACHE) {
92 for (const k of [...cache.keys()].slice(0, cache.size - MAX_CACHE)) cache.delete(k)
93 }
94}
95
96async function ctxOf($: EngineInterface): Promise<Ctx> {
97 if (home === undefined) home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
98 const cwd = await $.session.cwd().catch(() => undefined)
99 return { home: home || undefined, cwd }
100}
101
102const HELP = [
103 'Commands:',
104 ' /path-links this report',
105 ' /path-links segments|file|off every folder a link · the whole path one link · off',
106 ' /path-links blocks on|off a clickable line under code blocks that name paths',
107 ' /path-links relative on|off link relative paths (resolved against the working directory)',
108]
109
110export const register: Register = (on, options) => {
111 on('session.start', async ($, e, next) => {
112 prefs = prefsOf(options, await $.store.get('prefs').catch(() => undefined))
113 await $.command.register({
114 name: 'path-links',
115 description: 'path-links: clickable file and folder paths in replies (/path-links help)',
116 argumentHint: '[segments|file|off|blocks on|off|relative on|off]',
117 immediate: true,
118 })
119 return next(e)
120 })
121
122 on('command.run', { command: 'path-links' }, async ($, e) => {
123 const [cmd = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
124 const save = async (p: Prefs, said: string) => {
125 prefs = p
126 await $.store.set('prefs', p).catch(() => undefined)
127 $.ui.invalidate('ui.render')
128 return { text: `path-links: ${said}` }
129 }
130 const flag = (v: boolean) => (arg === 'on' ? true : arg === 'off' ? false : !v)
131 if (MODES.includes(cmd as Mode)) return save({ ...prefs, mode: cmd as Mode }, `mode ${cmd}`)
132 if (cmd === 'blocks') {
133 const v = flag(prefs.codeBlocks)
134 return save({ ...prefs, codeBlocks: v }, `code block line ${v ? 'on' : 'off'}`)
135 }
136 if (cmd === 'relative') {
137 const v = flag(prefs.relative)
138 return save({ ...prefs, relative: v }, `relative paths ${v ? 'on' : 'off'}`)
139 }
140 const status = `path-links: mode ${prefs.mode} · code blocks ${prefs.codeBlocks ? 'on' : 'off'} · relative ${prefs.relative ? 'on' : 'off'}`
141 return { text: [status, '', ...HELP].join('\n') }
142 })
143
144 // The stored reply stays as written; only its drawing carries the links.
145 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
146 if (prefs.mode === 'off') return next(e)
147 const text = e.props.text
148 const ctx = await ctxOf($)
149 const wanted = probes(text, ctx, prefs)
150 if (wanted.length === 0) return next(e)
151 await lookUp($, wanted, await $.clock.now(), ctx)
152 const out = rewrite(text, ctx, prefs, abs => cache.get(abs)?.kind ?? undefined)
153 return out === text ? next(e) : next({ ...e, props: { ...e.props, text: out } })
154 }).catch(($, e, next) => next(e)) // never in the way of a reply
155}
156hooks/paths.ts 342 lines1/**
2 * The whole of path-links that does not need the engine: find file system
3 * paths in an assistant reply's markdown and turn them into `file://` links,
4 * one per path segment, so a click on a folder opens that folder and a click
5 * on the file name opens the file.
6 *
7 * Pure: whether a path exists comes in through `look`, so the hook can stat
8 * first (`probes`) and rewrite second (`rewrite`) with the same scanner.
9 */
10
11export type Kind = 'file' | 'dir'
12export type Mode = 'segments' | 'file' | 'off'
13
14export type Prefs = {
15 /** segments: every folder and the file are links · file: the whole path is one link · off */
16 mode: Mode
17 /** Paths inside fenced code blocks get a clickable line under the block. */
18 codeBlocks: boolean
19 /** Relative paths (`hooks/register.tsx`) are resolved against the working directory. */
20 relative: boolean
21}
22
23export const DEFAULT_PREFS: Prefs = { mode: 'segments', codeBlocks: true, relative: true }
24
25export type Ctx = {
26 /** $HOME, for `~/…`; absent leaves `~` paths alone. */
27 home?: string
28 /** The session's working directory, for relative paths. */
29 cwd?: string
30}
31
32/** What a path is, by its absolute spelling; undefined when missing or not known yet. */
33export type Look = (abs: string) => Kind | undefined
34
35/** A path found in the text: how it was written, where it lands, what it is. */
36export type Found = { spelled: string; abs: string; kind: Kind; suffix: string }
37
38/**
39 * Where a path may be looked up at all. A lookup can reach the network
40 * through an automounter (macOS `/net`, `/Network`, `/home` = the firmlink
41 * `/System/Volumes/Data/home`; Linux autofs anywhere), a mounted share or a
42 * symlink to one, and reply text must never make the machine contact a host.
43 *
44 * So no path from the text is ever handed to `stat`, which follows links.
45 * A lookup starts at one of these trusted roots (the home folder, the
46 * working directory, local system folders) and walks down with directory
47 * listings, which name a link without following it (`walk`). A path that
48 * runs through a symlink is not linked.
49 */
50export const LOCAL_ROOTS = ['/Users', '/etc', '/tmp', '/private/tmp', '/private/etc', '/var/folders', '/private/var/folders', '/usr', '/opt', '/Applications', '/Library', '/bin', '/sbin']
51/** Never, even under an allowed root. */
52const NEVER = ['/users/shared/.autofs', '/private/var/automount']
53const under = (a: string, r: string) => a === r || a.startsWith(`${r.replace(/\/$/, '')}/`)
54
55/**
56 * The trusted root a path lies under, the deepest one; compared without case
57 * (macOS file systems ignore it). The working directory counts only when it
58 * is not `/` itself, else it would allow everything.
59 */
60export function rootOf(abs: string, ctx: Ctx): string | undefined {
61 const a = abs.toLowerCase()
62 if (NEVER.some(r => under(a, r))) return undefined
63 const roots = [...LOCAL_ROOTS, ctx.home, ctx.cwd]
64 .filter((r): r is string => !!r && r.startsWith('/') && r !== '/')
65 .map(r => r.replace(/\/+$/, ''))
66 return roots.filter(r => under(a, r.toLowerCase())).sort((x, y) => y.length - x.length)[0]
67}
68
69export const mayLookUp = (abs: string, ctx: Ctx) => rootOf(abs, ctx) !== undefined
70
71/** One entry of a directory listing: the entry itself, a link not followed. */
72export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; isLink: boolean }
73/** Lists a directory by its real path; undefined when it can't be listed. */
74export type Lister = (dir: string) => Promise<readonly Entry[] | undefined>
75
76/**
77 * What `abs` is, found by walking down from `root` (whose real path is
78 * `rootReal`) one listing at a time: undefined when a component is missing,
79 * is a link, or is something other than a folder on the way. No link is
80 * ever followed, so where the walk goes is where the text says, below a
81 * place that was trusted to begin with.
82 */
83export async function walk(abs: string, root: string, rootReal: string, list: Lister): Promise<Kind | undefined> {
84 const rest = abs.slice(root.length).split('/').filter(Boolean)
85 let dir = rootReal.replace(/\/+$/, '') || '/'
86 for (let i = 0; i < rest.length; i++) {
87 const entries = await list(dir)
88 if (!entries) return undefined
89 const want = rest[i]!
90 let e = entries.find(x => x.name === want)
91 if (!e) {
92 const ci = entries.filter(x => x.name.toLowerCase() === want.toLowerCase())
93 if (ci.length === 1) e = ci[0]
94 }
95 if (!e || e.isLink) return undefined
96 const last = i === rest.length - 1
97 if (last) return e.kind === 'file' ? 'file' : e.kind === 'dir' ? 'dir' : undefined
98 if (e.kind !== 'dir') return undefined
99 dir = `${dir === '/' ? '' : dir}/${e.name}`
100 }
101 return 'dir'
102}
103
104/** Words a path in running text may stretch over, `My Drive` style. */
105const MAX_WORDS = 10
106/** Paths listed under one code block. */
107const MAX_BLOCK_PATHS = 3
108/** Lines of a code block searched for paths. */
109const MAX_BLOCK_LINES = 100
110
111/** Characters a path is made of in running text (no space: that is the word step). */
112const PATH_CH = /[^\s`"'<>|[\]{}*]/
113/** Where a path may begin: an absolute, a home or a relative spelling. */
114const START = /(?<![\w.\/~$@+-])(?:~\/|\/(?=[^\s/])|\.{1,2}\/|[\w@][\w@.+-]*\/(?=[^\s/]))/g
115/** `:42` or `:42:7` after a file, kept as text after the link. */
116const LINE_SUFFIX = /:\d+(?::\d+)?$/
117/** Punctuation that ends a sentence rather than a path. */
118const TRAIL = /[.,;:!?]+$/
119
120/** The absolute path a spelling lands on, `.` and `..` folded; undefined when it cannot be placed. */
121export function resolvePath(spelled: string, ctx: Ctx, relative = true): string | undefined {
122 let base: string
123 let rest: string
124 if (spelled === '~' || spelled.startsWith('~/')) {
125 if (!ctx.home) return undefined
126 base = ctx.home
127 rest = spelled.slice(1)
128 } else if (spelled.startsWith('/')) {
129 base = ''
130 rest = spelled
131 } else {
132 if (!relative || !ctx.cwd) return undefined
133 base = ctx.cwd
134 rest = `/${spelled}`
135 }
136 const parts: string[] = []
137 for (const p of `${base}${rest}`.split('/')) {
138 if (p === '' || p === '.') continue
139 if (p === '..') parts.pop()
140 else parts.push(p)
141 }
142 return `/${parts.join('/')}`
143}
144
145/** A `file://` URL for an absolute path; folders end in `/`. */
146export function fileUrl(abs: string, kind: Kind): string {
147 const path = kind === 'dir' && !abs.endsWith('/') ? `${abs}/` : abs
148 return `file://${encodeURI(path).replace(/[#?()]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`
149}
150
151/** Text safe inside a markdown link label. */
152export function escapeLabel(s: string): string {
153 return s.replace(/[\\`*_[\]<>~]/g, c => `\\${c}`)
154}
155
156const link = (label: string, abs: string, kind: Kind) => `[${escapeLabel(label)}](${fileUrl(abs, kind)})`
157
158/**
159 * The markdown for one found path: each folder and the file a link of its own
160 * (`segments`), or the whole spelling one link (`file`). Separators and an
161 * empty first segment (the root's `/`) stay plain text.
162 */
163export function linkPath(f: Found, ctx: Ctx, mode: Mode): string {
164 if (mode === 'file') return link(f.spelled, f.abs, f.kind) + f.suffix
165 const segs = f.spelled.split('/')
166 let last = segs.length - 1
167 while (last > 0 && segs[last] === '') last--
168 const out: string[] = []
169 for (let i = 0; i < segs.length; i++) {
170 const seg = segs[i]!
171 if (seg === '' || i > last) {
172 out.push('')
173 continue
174 }
175 const prefix = segs.slice(0, i + 1).join('/') || '/'
176 const abs = i === last ? f.abs : resolvePath(prefix, ctx)
177 out.push(abs ? link(seg, abs, i === last ? f.kind : 'dir') : escapeLabel(seg))
178 }
179 return out.join('/') + f.suffix
180}
181
182/** A candidate spelling cleaned of trailing punctuation and a line suffix, then looked up. */
183function tryCandidate(raw: string, ctx: Ctx, prefs: Prefs, look: Look): (Found & { used: number }) | undefined {
184 let s = raw
185 // A closing bracket belongs to the path only when the path opened one.
186 for (;;) {
187 const before = s
188 s = s.replace(TRAIL, '')
189 if (s.endsWith(')') && (s.match(/\(/g)?.length ?? 0) < (s.match(/\)/g)?.length ?? 0)) s = s.slice(0, -1)
190 if (s === before) break
191 }
192 let suffix = ''
193 const m = s.match(LINE_SUFFIX)
194 if (m) {
195 suffix = m[0]
196 s = s.slice(0, -suffix.length)
197 }
198 if (s.length < 2 || s.endsWith('/') && s.length < 3) return undefined
199 // `..` is folded by text here but by the kernel through symlinks (`/tmp`, `/var`, `/etc` are
200 // links on macOS): the two can disagree about where a path lands, and the network check
201 // below must judge the place the stat will reach. Such spellings are never looked up.
202 if (s.split('/').includes('..')) return undefined
203 const abs = resolvePath(s, ctx, prefs.relative)
204 if (!abs || abs === '/' || !mayLookUp(abs, ctx)) return undefined
205 const kind = look(abs)
206 if (!kind) return undefined
207 return { spelled: s, abs, kind, suffix, used: s.length + suffix.length }
208}
209
210/**
211 * Paths in a stretch of running text (no code, no links). Calls `found` for
212 * each and returns the text with each path replaced by what `found` returns.
213 */
214function scanPlain(s: string, ctx: Ctx, prefs: Prefs, look: Look, found: (f: Found) => string): string {
215 let out = ''
216 let pos = 0
217 START.lastIndex = 0
218 for (let m = START.exec(s); m; m = START.exec(s)) {
219 const at = m.index
220 if (at < pos) continue
221 const quote = s[at - 1] === '"' || s[at - 1] === "'" ? s[at - 1]! : undefined
222 const candidates: string[] = []
223 if (quote) {
224 const close = s.indexOf(quote, at)
225 if (close > at) candidates.push(s.slice(at, close))
226 }
227 // One word, then further words joined by single spaces: longest first.
228 let end = at
229 while (end < s.length && PATH_CH.test(s[end]!)) end++
230 const words = [s.slice(at, end)]
231 let cur = end
232 while (words.length < MAX_WORDS && s[cur] === ' ' && cur + 1 < s.length && PATH_CH.test(s[cur + 1]!)) {
233 let e2 = cur + 1
234 while (e2 < s.length && PATH_CH.test(s[e2]!)) e2++
235 words.push(s.slice(at, e2))
236 cur = e2
237 }
238 candidates.push(...words.reverse())
239
240 let hit: (Found & { used: number }) | undefined
241 for (const c of candidates) {
242 hit = tryCandidate(c, ctx, prefs, look)
243 if (hit) break
244 }
245 if (!hit) {
246 START.lastIndex = at + 1
247 continue
248 }
249 out += s.slice(pos, at) + found(hit)
250 pos = at + hit.used
251 START.lastIndex = pos
252 }
253 return out + s.slice(pos)
254}
255
256/** Tokens of one line that are not running text: code spans, links, autolinks, URLs. */
257const TOKENS = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)|!?\[[^\]]*\]\([^)]*\)|<[a-z][\w+.-]*:[^>\s]*>|\b[a-z][\w+.-]*:\/\/[^\s<>`]*/gi
258
259/** One line outside a code block, paths linked; paths inside inline code that stays code go to `extra`. */
260function rewriteLine(line: string, ctx: Ctx, prefs: Prefs, look: Look, extra: Found[]): string {
261 const plain = (s: string) => scanPlain(s, ctx, prefs, look, f => linkPath(f, ctx, prefs.mode))
262 let out = ''
263 let pos = 0
264 TOKENS.lastIndex = 0
265 for (let m = TOKENS.exec(line); m; m = TOKENS.exec(line)) {
266 out += plain(line.slice(pos, m.index))
267 pos = m.index + m[0].length
268 if (m[1] === undefined) {
269 out += m[0] // a link or URL: never touched
270 continue
271 }
272 const code = m[2]!.trim()
273 const whole = tryCandidate(code, ctx, prefs, look)
274 if (whole && whole.used === code.length) {
275 out += linkPath(whole, ctx, prefs.mode)
276 continue
277 }
278 out += m[0]
279 scanPlain(code, ctx, prefs, look, f => {
280 extra.push(f)
281 return ''
282 })
283 }
284 return out + plain(line.slice(pos))
285}
286
287const unique = (fs: Found[]) => fs.filter((f, i) => fs.findIndex(g => g.abs === f.abs) === i)
288
289/** The line under a code block or a line whose inline code named paths. */
290function extraLine(fs: Found[], ctx: Ctx, prefs: Prefs): string {
291 return `↳ ${unique(fs).slice(0, MAX_BLOCK_PATHS).map(f => linkPath({ ...f, suffix: '' }, ctx, prefs.mode)).join(' · ')}`
292}
293
294const FENCE = /^ {0,3}(`{3,}|~{3,})/
295
296/**
297 * The reply's markdown with every path that exists linked. Fenced code is
298 * left as written (it stays copyable); with `codeBlocks` the paths it names
299 * follow the block on a line of their own.
300 */
301export function rewrite(text: string, ctx: Ctx, prefs: Prefs, look: Look): string {
302 if (prefs.mode === 'off' || !text.includes('/')) return text
303 const lines = text.split('\n')
304 const out: string[] = []
305 for (let i = 0; i < lines.length; i++) {
306 const line = lines[i]!
307 const fence = line.match(FENCE)
308 if (fence) {
309 const mark = fence[1]!
310 let j = i + 1
311 while (j < lines.length && !new RegExp(`^ {0,3}${mark[0] === '`' ? '`' : '~'}{${mark.length},}\\s*$`).test(lines[j]!)) j++
312 out.push(...lines.slice(i, j + 1))
313 if (prefs.codeBlocks && j < lines.length) {
314 const found: Found[] = []
315 for (const l of lines.slice(i + 1, Math.min(j, i + 1 + MAX_BLOCK_LINES))) {
316 scanPlain(l, ctx, prefs, look, f => {
317 found.push(f)
318 return ''
319 })
320 }
321 if (found.length) out.push('', extraLine(found, ctx, prefs))
322 }
323 i = j
324 continue
325 }
326 const extra: Found[] = []
327 const done = rewriteLine(line, ctx, prefs, look, extra)
328 out.push(extra.length && !line.trimStart().startsWith('|') ? `${done} \n${extraLine(extra, ctx, prefs)}` : done)
329 }
330 return out.join('\n')
331}
332
333/** Every absolute path `rewrite` would look up for this text, so the hook can stat them first. */
334export function probes(text: string, ctx: Ctx, prefs: Prefs): string[] {
335 const asked = new Set<string>()
336 rewrite(text, ctx, prefs, abs => {
337 asked.add(abs)
338 return undefined
339 })
340 return [...asked]
341}
342