diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e9b72a0a..d4484540 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -413,7 +413,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli 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. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **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/constants.js b/src/web/public/constants.js index 062cd139..742ac363 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2859,7 +2859,8 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { * continues from, which fills the width), a HARD continuation's indent is * dropped (the cell mapping keeps the cells), and the first row stops at the * URL's cell when other table columns follow it, so those columns never sit - * between the URL's pieces. + * between the URL's pieces. Those columns still link: they are moved after the + * stitched rows as a segment of their own, mapped back to their cells. * * @returns Same shape as {@link terminalPathLine}; null when the row does not exist. */ @@ -2872,12 +2873,16 @@ function terminalLinkLine(buffer, row, cols, maxRows) { const endRow = Math.max(extent.endRow, base.endRow); const segments = []; let text = ''; + let columnsAfterUrl = ''; for (let r = startRow; r <= endRow; r++) { const line = buffer.getLine(r); if (!line) break; const next = r < endRow ? buffer.getLine(r + 1) : null; let rowText = line.translateToString(!(next && next.isWrapped)) || ''; - if (r === extent.startRow && extent.firstRowUrlEnd != null) rowText = rowText.slice(0, extent.firstRowUrlEnd); + if (r === extent.startRow && extent.firstRowUrlEnd != null) { + columnsAfterUrl = rowText.slice(extent.firstRowUrlEnd); + rowText = rowText.slice(0, extent.firstRowUrlEnd); + } let colStart = 0; if (r > startRow && !line.isWrapped) { colStart = rowText.length - rowText.replace(/^\s+/, '').length; @@ -2886,6 +2891,17 @@ function terminalLinkLine(buffer, row, cols, maxRows) { segments.push({ row: r, textStart: text.length, colStart }); text += rowText; } + const lastRow = segments[segments.length - 1].row; + // The columns cut off the URL's first row (a path or date column of a table) + // are scanned after the stitched rows, so they keep their links on that row. + // They open with the cell gap, which keeps them apart from the URL; the one + // space before them belongs to the last row, so an end offset there still + // maps onto that row and never onto their first cell. + if (columnsAfterUrl.trim()) { + text += ' '; + segments.push({ row: extent.startRow, textStart: text.length, colStart: extent.firstRowUrlEnd }); + text += columnsAfterUrl; + } const offsetToCell = (offset) => { for (let i = segments.length - 1; i >= 0; i--) { const seg = segments[i]; @@ -2893,11 +2909,13 @@ function terminalLinkLine(buffer, row, cols, maxRows) { } return { row: startRow, col: offset }; }; + // The cut row has two segments: the one starting at or left of the cell owns it. const cellToOffset = (targetRow, targetCol) => { - const seg = segments.find((s) => s.row === targetRow); + let seg = null; + for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s; return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1; }; - return { startRow, endRow: segments[segments.length - 1].row, text, offsetToCell, cellToOffset }; + return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset }; } /** diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index e91c5860..3d03c880 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -30,7 +30,7 @@ type Link = { function loadPure() { const context = vm.createContext({ console, window: undefined }); vm.runInContext( - `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine;`, + `${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine; globalThis.__linkLine = terminalLinkLine;`, context, { filename: 'constants.js' } ); @@ -38,14 +38,24 @@ function loadPure() { __urls: (text: string) => UrlRef[]; __extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; __logical: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + __linkLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => LinkLine | null; }; return { findTerminalUrls: ctx.__urls, terminalUrlContinuationExtent: ctx.__extent, terminalLogicalLine: ctx.__logical, + terminalLinkLine: ctx.__linkLine, }; } +type LinkLine = { + startRow: number; + endRow: number; + text: string; + offsetToCell: (offset: number) => { row: number; col: number }; + cellToOffset: (row: number, col: number) => number; +}; + function fakeBuffer(rows: Row[], cols: number) { const lines = rows.map((row) => ({ isWrapped: row.isWrapped ?? false, @@ -94,6 +104,32 @@ const TABLE_WITH_TRAILING_COLUMNS: Row[] = [ TABLE[4], ]; const TABLE_COLS = 220; +// A wrapped URL with another column to its right. The URL's first row is cut at +// its cell to stitch the URL, and the column after it must still link on that +// row (a release-review regression: only the URL's pieces were offered there). +const WIKI_URL = 'https://wiki.example.com/spaces/ENG/pages/12345/Design-Spec-v2'; +const URL_THEN_PATH_COLS = 140; +const URL_THEN_PATH_ROWS: Row[] = [ + { text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ /home/me/proj/docs/design.md' }, + { text: ' Design-Spec-v2' }, + { text: ' 2 Notes n/a /home/me/proj/notes.md' }, +]; +// The same shape with a relative and a home path in the columns after the URL. +const URL_THEN_PATHS_ROWS: Row[] = [ + { + text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ docs/design.md ~/notes/todo.txt', + }, + { text: ' Design-Spec-v2' }, +]; +// A URL in the column after the wrapped one is the row's LAST URL, and only the +// last URL on a row is continued, so this row is never stitched or cut: both URLs +// link on row 1 as single-row links, as before the wrap joining existed. +const URL_THEN_URL_ROWS: Row[] = [ + { + text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ https://other.example.com/notes.md', + }, + { text: ' Design-Spec-v2' }, +]; const PROSE_ROWS = [ [ '⏺ The guide lives at https://example.com/guide/', @@ -457,6 +493,32 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { }); }); +describe('terminalLinkLine (the text the provider scans)', () => { + const { terminalLinkLine } = loadPure(); + + it('keeps the columns after a wrapped URL, mapped back to their own cells', () => { + const buffer = fakeBuffer(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_PATH_ROWS[0].text; + const pathCol = row.indexOf('/home/'); + const urlCol = row.indexOf('https://'); + for (const hovered of [0, 1]) { + const line = terminalLinkLine(buffer, hovered, URL_THEN_PATH_COLS, 12)!; + expect(line).toMatchObject({ startRow: 0, endRow: 1 }); + // The URL reads whole, and the path column follows it. + expect(line.text).toContain(WIKI_URL); + const pathOffset = line.text.indexOf('/home/me/proj/docs/design.md'); + expect(pathOffset).toBeGreaterThan(line.text.indexOf(WIKI_URL)); + expect(line.offsetToCell(pathOffset)).toEqual({ row: 0, col: pathCol }); + expect(line.cellToOffset(0, pathCol)).toBe(pathOffset); + expect(line.cellToOffset(0, urlCol)).toBe(line.text.indexOf(WIKI_URL)); + // The end of the URL's last row still maps onto that row, never onto the + // first cell of the column moved after it. + const urlEnd = line.text.indexOf(WIKI_URL) + WIKI_URL.length; + expect(line.offsetToCell(urlEnd)).toEqual({ row: 1, col: URL_THEN_PATH_ROWS[1].text.length }); + } + }); +}); + describe('native OSC 8 terminal URLs', () => { it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => { const open = vi.fn(); @@ -538,7 +600,7 @@ describe('terminal link provider with wrapped table URLs', () => { }); return links ?? []; }; - return { provide, openSpy }; + return { provide, openSpy, app }; } it.each(PROSE_ROWS)('preserves the original clickable URL before prose: %s', (first, next, url) => { @@ -668,6 +730,58 @@ describe('terminal link provider with wrapped table URLs', () => { } }); + it('links the path in the column right of a wrapped URL on the URL first row', () => { + const { provide, app } = harness(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS); + const pathStart = URL_THEN_PATH_ROWS[0].text.indexOf('/home/'); + const links = provide(1); + const path = links.find((link) => link.text === '/home/me/proj/docs/design.md'); + expect(path).toBeDefined(); + expect(path!.range.start).toEqual({ x: pathStart + 1, y: 1 }); + expect(path!.range.end.y).toBe(1); + // The stitched URL keeps one link per row, and its first-row piece ends + // before the path's column. + const urlPieces = links.filter((link) => /^https?:/.test(link.text)); + expect(new Set(urlPieces.map((link) => link.text))).toEqual(new Set([WIKI_URL])); + expect(urlPieces.map((link) => link.range.start.y)).toEqual([1, 2]); + expect(urlPieces[0].range.end.x).toBeLessThan(pathStart + 1); + // Nothing else is linked, so nothing glues the column onto the URL. + expect(links.map((link) => link.text).sort()).toEqual([WIKI_URL, WIKI_URL, '/home/me/proj/docs/design.md'].sort()); + path!.activate(null, path!.text); + expect(app.openFilePreview).toHaveBeenCalledExactlyOnceWith('/home/me/proj/docs/design.md', 's1'); + // The URL's second row still opens the whole URL, and the next record keeps its path. + expect( + new Set( + provide(2) + .filter((link) => /^https?:/.test(link.text)) + .map((link) => link.text) + ) + ).toEqual(new Set([WIKI_URL])); + expect(provide(3).map((link) => link.text)).toContain('/home/me/proj/notes.md'); + }); + + it('links a relative and a home path in the columns right of a wrapped URL', () => { + const { provide } = harness(URL_THEN_PATHS_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_PATHS_ROWS[0].text; + const links = provide(1); + const startOf = (text: string) => links.find((link) => link.text === text)?.range.start; + expect(startOf('docs/design.md')).toEqual({ x: row.indexOf('docs/design.md') + 1, y: 1 }); + expect(startOf('~/notes/todo.txt')).toEqual({ x: row.indexOf('~/notes/todo.txt') + 1, y: 1 }); + expect(new Set(links.filter((link) => /^https?:/.test(link.text)).map((link) => link.text))).toEqual( + new Set([WIKI_URL]) + ); + }); + + it('links a URL in the column right of another URL whole, without stitching either', () => { + const { provide } = harness(URL_THEN_URL_ROWS, URL_THEN_PATH_COLS); + const row = URL_THEN_URL_ROWS[0].text; + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => [link.text, link.range.start.x, link.range.start.y])).toEqual([ + ['https://wiki.example.com/spaces/ENG/pages/12345/', row.indexOf('https://wiki') + 1, 1], + ['https://other.example.com/notes.md', row.indexOf('https://other') + 1, 1], + ]); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + }); + it('underlines only the URL cells on each row, never the indent between pieces', () => { const { provide } = harness(TABLE, COLS); const segments = provide(3).filter((link) => link.text === URL);