SLOPSHOPPER

path-links

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

newrowscommandtimer
★ 1v0.1.0MITupdated 2026-10-13pepperonas/path-links
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · path-links
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /path-links ⎿ path-links: path-links: mode segments · code blocks on · relative on ⎿ path-links: ⎿ path-links: Commands: ⎿ path-links: /path-links this report ⎿ path-links: /path-links segments|file|off every folder a link · the whole path one link · off ⎿ path-links: /path-links blocks on|off a clickable line under code blocks that name paths ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

🔗 path-links

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> &nbsp; <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>

version node tests engine tests lines of code

CI Claude Code mod tested with TypeScript Node runtime deps links platforms network calls mutation tested Keep a Changelog SemVer last commit open issues repo size stars forks PRs welcome License: MIT

Donate with PayPal Rate celox.io on Google


[!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.

🎬 Demo

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

📸 Screenshots

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

✨ Features

  • One link per folder — click 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.
  • Paths with spaces — 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.
  • Only real paths — every candidate is checked against the file system first. Nothing that doesn't exist becomes a link, so there are no dead links and and/or stays text.
  • Absolute, ~/ 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.
  • Inline code — ` /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.
  • Code blocks stay copyable — a fenced block is never touched; the paths it names appear under it as ↳ …, each once, three at most.
  • Links stay links — existing markdown links, autolinks and https:// URLs are never rewritten.
  • Cheap — lookups are cached (a hit for a minute, a miss for five seconds) and capped per drawing; a reply without a / is passed through untouched.

📥 Install

Requirements

  • Claude Code with mods — mods are on by default in current releases; path-links is tested with 2.1.296.
  • A terminal that opens OSC 8 hyperlinks — iTerm2, Terminal.app, Ghostty, WezTerm, Kitty, VS Code's terminal, GNOME Terminal and most others. In iTerm2 and Terminal.app a link opens with ⌘-click; in Claude Code's fullscreen mode a plain click does it.
  • macOS or Linux. file:// links open with open / xdg-open, which is your terminal's business, not the mod's.

Option 1 — marketplace (recommended)

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

Option 2 — skills folder

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.

Option 3 — one session

git clone https://github.com/pepperonas/path-links
claude --plugin-dir ./path-links

Option 4 — desktop app and SDK hosts

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

🕹️ Usage

Nothing to do: ask Claude where something is, and the path in the answer is clickable. The command tunes it:

CommandWhat it does
/path-linksShows the current settings and this list
/path-links segmentsEvery folder and the file are links (default)
/path-links fileThe whole path is one link, to the file
/path-links offNo 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.

⚙️ Configuration

/plugin configure path-links@pepperonas-path-links sets the defaults; the command above overrides them.

OptionWhat it doesDefault
modesegments: every folder and the file are links · file: the whole path is one link · offsegments
codeBlocksPaths inside a code block get a clickable line under the blockon
relativeLink relative paths, resolved against the working directoryon

🧠 How it works

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.

  1. Scan (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.
  2. Look up (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.
  3. Rewrite (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.

🔒 Privacy

  • No network. The mod makes no request of any kind.
  • No processes. It never runs open itself; clicking is between you and your terminal.
  • Never touches network locations. A lookup can reach the network through an automounter (/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.
  • Reads only metadata. It asks whether a path exists and whether it is a file or a folder — never a file's contents.
  • No logs, no telemetry. Its only stored value is your /path-links settings.

🧪 Testing

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.

❓ FAQ

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.

📝 Changelog

The full history is in CHANGELOG.md (Keep a Changelog).

  • 0.1.0 — every folder and the file in a reply's paths as file:// links, paths with spaces, ~ and relative paths, :line suffixes, the ↳ line under code blocks, /path-links.

🤝 Contributing

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.

💛 Support

path-links is free and stays that way. If it saved you a few trips through Finder:

📄 License

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.

Source 2 files
hooks/register.tsx 156 lines
1import 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}
156
hooks/paths.ts 342 lines
1/**
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