diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 837dd75b..e04e32b6 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2217,6 +2217,10 @@ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTER const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`); const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`); const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`); +/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */ +const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//; +/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */ +const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0]; /** * Whether the line `nextText` carries on a file path `prevText` broke off. @@ -2226,15 +2230,20 @@ const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_G * spanning three or more lines; only worth keeping once a later line ends it), * and `null` otherwise. The broken-off token must already look like a path in * progress: it contains a `/`, ends on a path character and does not yet end - * on an extension. A next line that opens a NEW rooted path (`/home/…`, `~/…`) - * is never glued on. + * on an extension. A next line that opens a NEW path is never glued on: a rooted + * one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same + * top directory the broken token did (the next entry of a listing). */ function pathContinuation(prevText, nextText) { const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || ''; if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null; const next = String(nextText || '').replace(/^\s+/, ''); const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || ''; - if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head)) return null; + if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null; + // A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components` + // followed by `src/index.ts` is two entries, not one path broken after `src/components`. + // A wrapped path continues INSIDE a name: its next row does not open on the same top directory. + if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null; if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join'; return head === next.replace(/\s+$/, '') ? 'chain' : null; } diff --git a/test/file-path-link-forms.test.ts b/test/file-path-link-forms.test.ts index 428e0563..3399873c 100644 --- a/test/file-path-link-forms.test.ts +++ b/test/file-path-link-forms.test.ts @@ -153,6 +153,49 @@ describe('terminalPathLine', () => { expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 }); expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 }); }); + + // `git ls-files`, `find` and `ls -R` print one path per row. An extensionless + // entry (a directory, `Dockerfile`, `.gitignore`) followed by an entry that is + // equal or shorter looked exactly like a path broken short of the edge, and the + // two were glued: `src/components` + `src/index.ts` linked `src/componentssrc/index.ts` + // and the real `src/index.ts` link was gone (Ark0N, PR #604, round 2). + describe('a listing is never glued into one path', () => { + const LISTING = [ + 'src/components', + 'src/index.ts', + 'src/lib', + './index.ts', + '../docs/README.md', + 'packages/gesture-control/.gitignore', + 'packages/gesture-control/README.md', + 'packages/gesture-control/package.json', + 'data/runs/a.jsonl', + 'data/runs/b.jsonl', + 'data/runs/c.jsonl', + 'data/runs/d.json', + ]; + + it('keeps every row its own line', () => { + const buf = buffer(LISTING, 120); + LISTING.forEach((entry, row) => { + const line = terminalPathLine(buf, row, 120)!; + expect([line.startRow, line.endRow], entry).toEqual([row, row]); + expect(line.text, entry).toBe(entry); + }); + }); + + it('links the entry that used to be swallowed', () => { + const buf = buffer(LISTING, 120); + expect(paths(terminalPathLine(buf, 1, 120)!.text)).toEqual(['src/index.ts']); + }); + + it('still glues a path whose next row opens on a different directory (a real wrap)', () => { + // The control: `…/captures/2` + `026-10-10/…` has a `/` in its head too. + const line = terminalPathLine(buffer(BROKEN), 0, 80)!; + expect([line.startRow, line.endRow]).toEqual([0, 1]); + expect(paths(line.text)).toEqual([BROKEN_PATH]); + }); + }); }); type Link = {