From e52cc60c7c2ec8955296714ba8aa95d345d09e1c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 27 Oct 2026 01:34:15 +0100 Subject: [PATCH] fix: apply the review items for #687 at merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js:2296,2301 spacedFilePathOnLine: refuse a first word ending in `$` and an earlier word with no letter or digit (other than a bare `-`), so a shell prompt showing an absolute working directory (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj$ cat notes.md`) keeps master's links; the six rows from the review are pinned, plus OneDrive, `Program Files (x86)` and `Docs - Copy` still linking whole. - constants.js:2312: the leading strip is now `/^(?:~?\/|(?:\.\.?\/)+)/`, so `Saved to: ../My Docs/plan.md` links whole; an interior `..` still links nothing. Tests for the detector and the terminal provider. - app.js:3032 _linkifyFilePaths: carry any leading bold label and let spacedFilePathOnLine() judge it, and give a text node that follows another inline element a placeholder word, so `**Note:** /home/me/proj/out report.pdf` and ``Use `--config` /home/me/proj/cfg data.json`` split as in the terminal; a spaced path inside inline code still links whole. The now-unused label-pattern guard is gone. - JSDoc (findFilePathLinks, SPACED_FILE_PATH_LABEL_PATTERN, spacedFilePathOnLine) and the spaced-path paragraph in docs/architecture-invariants.md updated to match. Co-Authored-By: Claude Opus 5.5 --- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 23 ++++++++------- src/web/public/constants.js | 24 +++++++++++----- test/response-viewer-file-links.test.ts | 22 ++++++++++++++ test/spaced-file-path-links.test.ts | 38 +++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 18 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ebbcbaa6..17e7ade0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading `./` is not a segment. An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/` and `./` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. The response viewer uses a leading `` path label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), as detection context without changing the label or displayed text; paths split across other inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `` label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 131f2837..cea66e2f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3015,23 +3015,26 @@ class CodemanApp { // invalidates the traversal. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const targets = []; - // Same promise as the guard above: a stale constants.js without the label - // pattern only loses the carried label, it never throws. - const labelPattern = typeof SPACED_FILE_PATH_LABEL_PATTERN !== 'undefined' ? SPACED_FILE_PATH_LABEL_PATTERN : null; for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (node.parentElement?.closest('a')) continue; - // Markdown puts **Saved to:** in a separate node. Carry only - // an explicit path label that starts its line into detection; never - // arbitrary prose or a label from another block. Chat renders with + // Markdown puts **Saved to:** in a separate node. Carry a bold + // label that starts its line into detection and let spacedFilePathOnLine() + // judge it, as it does the same line in the terminal (`Note:` is not a + // path label); never a label from another block. Chat renders with // `breaks: true`, so a later line of a paragraph starts after a
. - // The label itself stays untouched. + // A text node after any other inline element (`Use --config + // /a/b c.json`) starts mid-line, so a placeholder word stands in for what + // precedes it. The label itself stays untouched. const label = node.previousSibling; let beforeLabel = label?.previousSibling; if (beforeLabel?.nodeType === 3 && !beforeLabel.nodeValue.trim()) beforeLabel = beforeLabel.previousSibling; - const labelText = label?.nodeName === 'STRONG' ? label.textContent : ''; const leadingLabel = - /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && (!beforeLabel || beforeLabel.nodeName === 'BR'); - const prefix = leadingLabel && labelPattern?.test(labelText) ? `${labelText} ` : ''; + label?.nodeName === 'STRONG' && + /^(?:P|LI)$/.test(node.parentElement?.tagName || '') && + (!beforeLabel || beforeLabel.nodeName === 'BR'); + let prefix = ''; + if (leadingLabel) prefix = `${label.textContent} `; + else if (label?.nodeType === 1 && label.nodeName !== 'BR') prefix = 'x '; const found = findFilePathLinks(prefix + (node.nodeValue || '')) .filter((hit) => hit.index >= prefix.length) .map((hit) => ({ ...hit, index: hit.index - prefix.length })); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 9ac14456..5e3d3dbc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2159,8 +2159,10 @@ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LIN * and folder detectors then skip reserved spans, earlier matches and URLs. * * The one entry point for both consumers (terminal link provider, response - * viewer). The viewer restores a leading bold path label's context, but paths - * split across other inline elements are still detected per text node. + * viewer). The viewer carries a leading bold label into the text it passes + * (spacedFilePathOnLine judges it, as for the same line in the terminal) and + * reads a text node that follows another inline element as mid-line; paths + * split across inline elements are still detected per text node. * * @param {string} text * @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in @@ -2218,7 +2220,7 @@ function findFilePathLinks(text) { return found.sort((a, b) => a.index - b.index); } -/** Labels that explicitly identify a path, shared with the rendered-markdown linkifier. */ +/** Labels that explicitly identify a path, for {@link spacedFilePathOnLine}. */ const SPACED_FILE_PATH_LABEL_PATTERN = /^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i; @@ -2239,11 +2241,12 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic * labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative * path needs a path label to be taken spaced. One trailing sentence mark is left - * out, and a leading `./` is not a segment. The candidate must read as ONE path: + * out, and a leading run of `./` or `../` is not a segment. The candidate must read as ONE path: * it ends on a known extension, a rooted one has a directory before its first * space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`, * and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new - * `/`, `~/`, `./` or `../` path, no earlier word ends in `:` or is an arrow, and + * `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has + * no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and * a run of spaces (a column gap) is refused, so two paths on a line are never * merged. A relative candidate whose only `/` word is its last is prose after the * label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits @@ -2252,7 +2255,8 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`), * so a name such as `Pros and Cons.md` splits as master does. A command line is * refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the - * first word, or any `-x`/`--x` flag word. + * first word, a first word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`), + * or any `-x`/`--x` flag word. * * A candidate that is one path but not a linkable one (an unknown root such as * `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last @@ -2287,8 +2291,14 @@ function spacedFilePathOnLine(line) { if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) { return null; } + // A shell prompt that prints its working directory in full (`/home/me/proj$ cat + // notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too. + if (words[0].endsWith('$')) return null; for (const word of words.slice(0, -1)) { if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null; + // A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never + // part of a file name, apart from a bare `-` (`OneDrive - Contoso`). + if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null; // `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix. if (/:\d/.test(word)) return null; // An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there. @@ -2299,7 +2309,7 @@ function spacedFilePathOnLine(line) { } if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null; const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); - const segments = path.replace(/^(?:~?\/|\.\/)/, '').split('/'); + const segments = path.replace(/^(?:~?\/|(?:\.\.?\/)+)/, '').split('/'); const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); // A path label vouches that a path follows, not for the words before it: when // only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index fff275c4..0eff0b35 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -121,6 +121,28 @@ describe('response viewer file-path linkifier', () => { expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual(expected); }); + // The terminal splits these lines (`Note:` is not a path label, and the path does + // not start the line), so the viewer must split them too. + it.each([ + ['**Note:** /home/me/proj/out report.pdf', ['/home/me/proj/out', 'report.pdf']], + ['Use `--config` /home/me/proj/cfg data.json', ['/home/me/proj/cfg', 'data.json']], + ])('splits a spaced candidate the terminal splits: %s', (markdown, expected) => { + const html = renderChat(markdown); + const original = document.createElement('div'); + original.innerHTML = html; + const root = linkify(html); + expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(expected); + expect(root.textContent).toBe(original.textContent); + }); + + it('still links a spaced path inside inline code whole, after another inline element too', () => { + const root = linkify(renderChat('Use `--config` `/home/me/My Docs/cfg data.json`')); + + const links = paths(root); + expect(links.map((a) => a.getAttribute('data-path'))).toEqual(['/home/me/My Docs/cfg data.json']); + expect(links[0].closest('code')).not.toBeNull(); + }); + it('does not inherit a bold label from earlier prose or another block', () => { for (const markdown of [ 'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index ef4c2698..73a6fe8f 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -326,6 +326,37 @@ describe('spaced file paths: shapes that keep master links', () => { expect(paths('Saved to: ./My Docs/plan.md')).toEqual(['./My Docs/plan.md']); expect(paths('Saved to: ./notes.md')).toEqual(['./notes.md']); }); + + it('reads a leading run of ../ as parent directories, not segments', () => { + expect(paths('Saved to: ../My Docs/plan.md')).toEqual(['../My Docs/plan.md']); + expect(paths('Saved to: ../../My Docs/plan.md')).toEqual(['../../My Docs/plan.md']); + expect(paths('Saved to: ../notes.md')).toEqual(['../notes.md']); + // An interior `..` is still a segment, so the span links nothing. + expect(paths('Saved to: My Docs/../plan.md')).toEqual([]); + expect(paths('Saved to: ../My Docs/../x/plan.md')).toEqual([]); + }); + + // A shell prompt that prints its working directory in full (WSL's `/mnt/c/...` is + // outside `$HOME`): a word with no letter or digit, or a first word ending in `$`, + // is never part of a file name, so every earlier command in the scrollback keeps + // master's links. + it.each([ + ['/mnt/c/proj ❯ code notes.md', ['/mnt/c/proj', true], ['notes.md']], + ['/home/me/proj $ cat notes.md', ['/home/me/proj', true], ['notes.md']], + ['/home/me/proj$ cat notes.md', ['/home/me/proj', true], ['notes.md']], + ['/home/me/proj ❯ git add src/a.ts', ['/home/me/proj', true], ['src/a.ts']], + ['/mnt/c/Users/me/OneDrive - Contoso/proj ❯ code notes.md', ['/mnt/c/Users/me/OneDrive', true], ['notes.md']], + ['/home/me/proj/out ✓ report.pdf', ['/home/me/proj/out', true], ['report.pdf']], + ] as Array<[string, ...Array<[string, true?]>]>)('keeps a shell prompt out of the path: %s', (line, ...links) => { + expect(findFilePathLinks(line)).toEqual(masterLinks(line, ...links)); + }); + + it.each([LONG, '/mnt/c/Program Files (x86)/app/readme.txt', '/mnt/c/Users/me/Docs - Copy/a.docx'])( + 'keeps a name whose words hold a letter or digit, or a bare -, whole: %s', + (line) => { + expect(paths(line)).toEqual([line]); + } + ); }); describe('terminal link provider with spaced paths', () => { @@ -383,6 +414,13 @@ describe('terminal link provider with spaced paths', () => { expect(opened).toEqual([`/workspace/project/${rel}`]); }); + it('links a labelled spaced path under ../ and opens it outside the workspace', () => { + const { links, opened } = linksFor('Saved to: ../My Docs/plan.md'); + expect(links.map((l) => l.text)).toEqual(['../My Docs/plan.md']); + links[0].activate(null, links[0].text); + expect(opened).toEqual(['/workspace/My Docs/plan.md']); + }); + it('links nothing for an unlinkable spaced path', () => { expect(linksFor('/etc/secret files/passwords.txt').links).toEqual([]); });