mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
A Bash( row holds a command, so its parentheses do not bound one spaced path: `Bash(/tmp/a.out input.txt)` became a single link to a file that cannot exist and lost master's `input.txt` link. The path tools keep linking a spaced name whole, and a hard-wrapped Bash( row gets no spaced lookup either. Test rows from #734. Co-Authored-By: DDT <141296842+Hugo-DDT@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
480 lines
23 KiB
TypeScript
480 lines
23 KiB
TypeScript
// Port: none (a pure function in a vm context: no browser, no server).
|
||
//
|
||
// File paths with spaces in them (`OneDrive - Contoso/…`, `90 Sensitive/…`) were
|
||
// cut at the first space by every path pattern, so the terminal and the response
|
||
// viewer linked a fragment such as `/mnt/c/proj/My` plus a bogus relative tail
|
||
// `Docs/report.docx`, each opening the wrong target. findFilePathLinks() now takes
|
||
// a spaced path WHOLE where the line makes its extent unambiguous: the path is the
|
||
// whole absolute path, or a relative path after an explicit path label. A spaced line that
|
||
// is one path but not a linkable one (unknown root, `..`) links nothing at all,
|
||
// rather than a fragment.
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import vm from 'node:vm';
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||
|
||
function load(): (text: string) => Array<{ path: string; index: number; folder?: true }> {
|
||
const context = vm.createContext({ console, window: undefined });
|
||
vm.runInContext(`${SOURCE}\nglobalThis.__fn = findFilePathLinks;`, context, { filename: 'constants.js' });
|
||
return (context as { __fn: never })['__fn'] as never;
|
||
}
|
||
|
||
const findFilePathLinks = load();
|
||
const paths = (text: string) => findFilePathLinks(text).map((f) => f.path);
|
||
|
||
const LONG =
|
||
'/mnt/c/Users/someone/OneDrive - Contoso/01_Projects/Other/kb/out/deliverables/fy27-messaging-market-story.docx';
|
||
|
||
describe('spaced file paths', () => {
|
||
it('keeps a parenthesised configuration filename whole inside a sentence', () => {
|
||
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
|
||
const text = `Prepared a corrected configuration (${path}).`;
|
||
expect(findFilePathLinks(text)).toEqual([{ path, index: text.indexOf(path) }]);
|
||
});
|
||
|
||
it('keeps separate parenthesised paths and the surrounding prose separate', () => {
|
||
const first = '/mnt/data/Kobo/Kobo eReader.conf';
|
||
const second = '/home/me/My Docs/notes.md';
|
||
expect(paths(`Updated (${first}) and (${second}).`)).toEqual([first, second]);
|
||
expect(paths('Do not link (/etc/private files/settings.conf).')).toEqual([]);
|
||
expect(paths('Do not link (/mnt/data/../private files/settings.conf).')).toEqual([]);
|
||
});
|
||
|
||
it('does not take a comma-separated list inside parentheses as one spaced filename', () => {
|
||
expect(paths('Updated (/home/me/proj, config.json)')).toEqual(['/home/me/proj', 'config.json']);
|
||
});
|
||
|
||
it.each([
|
||
['/mnt/c/Users/me/Smith, John/report.docx', '/mnt/c/Users/me/Smith, John/report.docx'],
|
||
['- /home/me/Clients/Acme, Inc/contract.pdf', '/home/me/Clients/Acme, Inc/contract.pdf'],
|
||
['Saved to: /home/me/proj/Invoice 12, final.pdf', '/home/me/proj/Invoice 12, final.pdf'],
|
||
])('keeps commas in a non-parenthesised spaced path: %s', (line, path) => {
|
||
expect(findFilePathLinks(line)).toEqual([{ path, index: line.indexOf(path) }]);
|
||
});
|
||
|
||
it('links an own-line absolute path with spaces whole', () => {
|
||
expect(paths(LONG)).toEqual([LONG]);
|
||
expect(paths('/mnt/c/proj/My Big Docs/report.docx')).toEqual(['/mnt/c/proj/My Big Docs/report.docx']);
|
||
});
|
||
|
||
it('reports where the whole path starts', () => {
|
||
const line = ' - Path: /mnt/c/proj/My Big Docs/report.docx';
|
||
expect(findFilePathLinks(line)).toEqual([
|
||
{ path: '/mnt/c/proj/My Big Docs/report.docx', index: line.indexOf('/mnt') },
|
||
]);
|
||
});
|
||
|
||
it('leaves a single trailing sentence period out', () => {
|
||
expect(paths('/mnt/c/proj/My Big Docs/report.docx.')).toEqual(['/mnt/c/proj/My Big Docs/report.docx']);
|
||
});
|
||
|
||
it.each(['Path:', 'File:', 'Saved to:'])('links an absolute spaced path after %s', (label) => {
|
||
const p = '/mnt/sync/brain/90 Sensitive/team/training/attendees.xlsx';
|
||
expect(paths(`${label} ${p}`)).toEqual([p]);
|
||
});
|
||
|
||
it.each([
|
||
'Path:',
|
||
'File:',
|
||
'Files:',
|
||
'Saved to:',
|
||
'Saved as:',
|
||
'Wrote:',
|
||
'Written to:',
|
||
'Output:',
|
||
'Created:',
|
||
'Location:',
|
||
'Attachment:',
|
||
'pAtH:',
|
||
])('links a relative spaced path after %s', (label) => {
|
||
const p = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx';
|
||
expect(paths(`${label} ${p}`)).toEqual([p]);
|
||
});
|
||
|
||
it.each(['- ', '* ', '• ', '1. ', '2) '])(
|
||
'defers a relative spaced path after a bare list marker %j, links it after a path label',
|
||
(marker) => {
|
||
const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx';
|
||
expect(paths(`${marker}${rel}`)).toEqual(['Design/Build-SIPOC.pptx']);
|
||
expect(paths(`${marker}Path: ${rel}`)).toEqual([rel]);
|
||
expect(paths(` ${marker}${LONG}`)).toEqual([LONG]);
|
||
}
|
||
);
|
||
|
||
it('works line by line in code-block text, where list markers remain literal', () => {
|
||
const text = `Wrote two files:\n- ${LONG}\n- Path: notes/a b/c.md\n`;
|
||
expect(paths(text)).toEqual([LONG, 'notes/a b/c.md']);
|
||
});
|
||
|
||
it('links nothing for a spaced path outside the known roots (no partial tail)', () => {
|
||
expect(paths('/etc/secret files/passwords.txt')).toEqual([]);
|
||
expect(paths('/srvx/team share/notes/passwords.txt')).toEqual([]);
|
||
});
|
||
|
||
it('links nothing for a spaced path with a `..` or `.` segment', () => {
|
||
expect(paths('/mnt/c/proj/../other place/docs/report.docx')).toEqual([]);
|
||
expect(paths('File: 03 Attachments/../private/report.xlsx')).toEqual([]);
|
||
expect(paths('- File: 03 Attachments/./private/report.xlsx')).toEqual([]);
|
||
});
|
||
|
||
it('never merges two space-separated paths into one link', () => {
|
||
expect(paths('/mnt/c/proj/a.md /mnt/c/proj/b.pdf')).toEqual(['/mnt/c/proj/a.md', '/mnt/c/proj/b.pdf']);
|
||
expect(paths('- docs/a.md docs/b.md')).toEqual(['docs/a.md', 'docs/b.md']);
|
||
expect(paths('/mnt/c/proj/a b.md -> /mnt/c/proj/c.md')).not.toContain('/mnt/c/proj/a b.md -> /mnt/c/proj/c.md');
|
||
});
|
||
|
||
it('does not take prose into a path', () => {
|
||
// No marker or label: a relative spaced candidate is prose, not a path.
|
||
expect(paths('See docs/report.docx for details')).toEqual(['docs/report.docx']);
|
||
expect(paths('see the docs/a b.md')).not.toContain('see the docs/a b.md');
|
||
// A long description before a colon is a sentence, not a label: only the path links.
|
||
const rel = '.worktrees/feature-x/docs/specs/2026-09-05-design.md';
|
||
expect(paths(`• Design committed in the isolated worktree: ${rel}`)).toEqual([rel]);
|
||
});
|
||
|
||
it.each([
|
||
['- Updated src/foo.ts', 'src/foo.ts'],
|
||
['1. Added a test in test/foo.test.ts', 'test/foo.test.ts'],
|
||
['• Ran npm test -- test/foo.test.ts', 'test/foo.test.ts'],
|
||
['Error: cannot open src/foo.ts', 'src/foo.ts'],
|
||
['Note: see src/foo.ts', 'src/foo.ts'],
|
||
['- Moved ../a/b.ts', '../a/b.ts'],
|
||
])('defers ambiguous relative prose to ordinary detectors: %s', (line, path) => {
|
||
expect(paths(line)).toEqual([path]);
|
||
});
|
||
|
||
it('requires a path label for relative candidates but retains own-line absolute ambiguity', () => {
|
||
expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Docs/a.md']);
|
||
// Documented trade-off: a labelled relative path whose only space is in its top
|
||
// directory reads like prose after the label, so it splits as on master.
|
||
expect(paths('Saved to: 03 Attachments/plan.pptx')).toEqual(['Attachments/plan.pptx']);
|
||
expect(paths('Saved to: My Docs/plan.md')).toEqual(['Docs/plan.md']);
|
||
// Documented residual: words after an own-line absolute path that hold no prose
|
||
// word read like a spaced name (`/home/me/docs/Read Me.txt`), so they stay in.
|
||
expect(paths('/home/me/proj/out report.pdf')).toEqual(['/home/me/proj/out report.pdf']);
|
||
expect(paths('Saved to: ~/My Docs/plan.md')).toEqual(['~/My Docs/plan.md']);
|
||
});
|
||
|
||
it.each([
|
||
['1. Ran 45/45 tests in test/foo.test.ts', ['test/foo.test.ts']],
|
||
['- Fixed CI/CD pipeline in .github/workflows/ci.yml', ['.github/workflows/ci.yml']],
|
||
['- Added read/write support in config.ts', ['config.ts']],
|
||
['- Tests: 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']],
|
||
['Error: I/O failure reading data.json', ['data.json']],
|
||
['- Moved src/ into lib/a.ts', ['src/', 'lib/a.ts']],
|
||
['- Copied files from ../shared into lib/a.ts', ['lib/a.ts']],
|
||
['- Ran 45/45 tests in ~/proj/foo.test.ts', ['~/proj/foo.test.ts']],
|
||
])('does not swallow slash-containing prose: %s', (line, expected) => {
|
||
expect(paths(line)).toEqual(expected);
|
||
});
|
||
|
||
it.each(['~/', './', '../', '/tmp/'])('does not merge a later new path starting with %s', (prefix) => {
|
||
const line = `Path: notes/a ${prefix}other/file.md`;
|
||
expect(paths(line)).not.toContain(line.slice(6));
|
||
});
|
||
|
||
it('does not merge a folder token into a labelled spaced path', () => {
|
||
expect(paths('Path: src/ into lib/a.ts')).toEqual(['src/', 'lib/a.ts']);
|
||
});
|
||
|
||
it.each([40000, 80000])('stays linear with %i interior spaces', (length) => {
|
||
const line = '/home/a' + ' '.repeat(length) + 'x';
|
||
const start = performance.now();
|
||
findFilePathLinks(line);
|
||
expect(performance.now() - start).toBeLessThan(500);
|
||
});
|
||
|
||
it('leaves unspaced paths to the ordinary detectors', () => {
|
||
expect(paths('- /mnt/c/proj/docs/report.docx')).toEqual(['/mnt/c/proj/docs/report.docx']);
|
||
expect(paths('• committed as /tmp/x/docs/specs/design.md:1 (e7ae1b4).')).toEqual(['/tmp/x/docs/specs/design.md']);
|
||
});
|
||
|
||
it('stays linear on a long spaced line', () => {
|
||
const line = '- ' + 'a '.repeat(20000) + '/x.md';
|
||
const t0 = performance.now();
|
||
findFilePathLinks(line);
|
||
expect(performance.now() - t0).toBeLessThan(500);
|
||
});
|
||
});
|
||
|
||
/** The links master returned for `line`, in order: `[path, folder?]` pairs, each at its first occurrence. */
|
||
const masterLinks = (line: string, ...links: Array<[string, true?]>) =>
|
||
links.map(([path, folder]) => ({ path, index: line.indexOf(path), ...(folder ? { folder } : {}) }));
|
||
|
||
describe('spaced file paths: shapes that keep master links', () => {
|
||
// grep -n, rg -n and compiler output: a `:` plus a digit inside an earlier
|
||
// word is a line suffix, so the path already ended there.
|
||
it.each([
|
||
['/home/me/proj/README.md:12:See docs/setup.md', '/home/me/proj/README.md', 'docs/setup.md'],
|
||
[
|
||
' /home/me/proj/CLAUDE.md:42:Deep detail lives in docs/architecture-invariants.md',
|
||
'/home/me/proj/CLAUDE.md',
|
||
'docs/architecture-invariants.md',
|
||
],
|
||
['/home/me/proj/src/a.ts:42:5 - error in b.ts', '/home/me/proj/src/a.ts', 'b.ts'],
|
||
['/home/me/proj/src/index.ts:10 updated config.json', '/home/me/proj/src/index.ts', 'config.json'],
|
||
])('ends the path at a line suffix: %s', (line, first, second) => {
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [first], [second]));
|
||
});
|
||
|
||
// A generic label introduces prose for an absolute path as much as a relative one.
|
||
it.each([
|
||
['Error: /home/me/proj contains a.md', '/home/me/proj', 'a.md'],
|
||
['Warning: /home/me/proj is not tracked, see notes.md', '/home/me/proj', 'notes.md'],
|
||
['Working directory: /home/me/proj, see README.md', '/home/me/proj', 'README.md'],
|
||
['- Note: /tmp/out has the logs in run.log', '/tmp/out', 'run.log'],
|
||
])('defers an absolute path after a generic label: %s', (line, folder, file) => {
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [folder, true], [file]));
|
||
});
|
||
|
||
it('documented trade-off: an absolute spaced path after a non-path label splits as on master', () => {
|
||
const line = 'Saved the report to: /mnt/c/My Docs/a.docx';
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/mnt/c/My', true], ['Docs/a.docx']));
|
||
});
|
||
|
||
it.each([
|
||
['/usr/bin/python3 script.py', 'script.py'],
|
||
['/usr/bin/env node build.js', 'build.js'],
|
||
['/review the changes in src/foo.ts', 'src/foo.ts'],
|
||
])('does not reserve a command line that only looks like one path: %s', (line, file) => {
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file]));
|
||
});
|
||
|
||
// A path label vouches that a path follows, not for the prose before it.
|
||
it.each([
|
||
['Wrote: 120 lines to src/foo.ts', 'src/foo.ts'],
|
||
['Created: new file src/components/Button.tsx', 'src/components/Button.tsx'],
|
||
['Output: written to logs/run.log', 'logs/run.log'],
|
||
['Files: 3 changed in src/foo.ts', 'src/foo.ts'],
|
||
['Location: line 42 in src/foo.ts', 'src/foo.ts'],
|
||
['Path: see src/foo.ts', 'src/foo.ts'],
|
||
['- Output: stored in dist/app.js', 'dist/app.js'],
|
||
['1. File: changes in src/a.ts', 'src/a.ts'],
|
||
['Created: 2026-10-24 build/x.json', 'build/x.json'],
|
||
])('keeps label prose out of the path: %s', (line, file) => {
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file]));
|
||
});
|
||
|
||
it('keeps labelled spaced paths whole when a `/` word comes before the last', () => {
|
||
for (const p of [
|
||
'03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
|
||
'~/My Docs/plan.md',
|
||
'./My Docs/plan.md',
|
||
'notes/a b/c.md',
|
||
'/mnt/c/My Docs/a.docx',
|
||
]) {
|
||
expect(paths(`Saved to: ${p}`)).toEqual([p]);
|
||
}
|
||
});
|
||
|
||
// A connective or verb before the last word puts the candidate in a sentence,
|
||
// whatever the shape: own line, list marker or path label, absolute or relative.
|
||
it.each([
|
||
['/home/me/proj contains a.md', ['/home/me/proj', true], ['a.md']],
|
||
['/home/me/proj in the README.md', ['/home/me/proj', true], ['README.md']],
|
||
['/home/me/proj/src is where the code lives, see README.md', ['/home/me/proj/src', true], ['README.md']],
|
||
['/tmp/a and b.md', ['/tmp/a', true], ['b.md']],
|
||
['/home/me/proj/out vs b.md', ['/home/me/proj/out', true], ['b.md']],
|
||
['- /tmp/out has the logs in run.log', ['/tmp/out', true], ['run.log']],
|
||
['* /home/me/proj/docs contains a.md', ['/home/me/proj/docs', true], ['a.md']],
|
||
['1. /home/me/proj/src then test/foo.test.ts', ['/home/me/proj/src', true], ['test/foo.test.ts']],
|
||
['- /home/me/proj is the root, see README.md', ['/home/me/proj', true], ['README.md']],
|
||
['Path: /home/me/proj/out contains a.md', ['/home/me/proj/out', true], ['a.md']],
|
||
['Saved to: /home/me/out as report.docx', ['/home/me/out', true], ['report.docx']],
|
||
['Files: src/components and utils/x.ts', ['utils/x.ts']],
|
||
['Wrote: 3 files to docs/design notes/overview.md', ['notes/overview.md']],
|
||
['Created: the file src/components/My Button/Button.tsx', ['Button/Button.tsx']],
|
||
['Output: 2 files in dist/my app/a.js', ['app/a.js']],
|
||
['Output: 12 KB written to dist/my app/a.js', ['app/a.js']],
|
||
['/home/me/proj/out = report.pdf', ['/home/me/proj/out', true], ['report.pdf']],
|
||
] as Array<[string, ...Array<[string, true?]>]>)('keeps a sentence out of the path: %s', (line, ...links) => {
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, ...links));
|
||
});
|
||
|
||
it('ends the path at an earlier word that holds an extension', () => {
|
||
expect(findFilePathLinks('/home/me/proj/src/foo.ts#L12 foo.ts')).toEqual([
|
||
{ path: '/home/me/proj/src/foo.ts', index: 0 },
|
||
{ path: 'foo.ts', index: 29 },
|
||
]);
|
||
});
|
||
|
||
it('documented trade-off: a name that uses a prose word splits as on master', () => {
|
||
const line = '/home/me/docs/Pros and Cons.md';
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/home/me/docs/Pros', true], ['Cons.md']));
|
||
// `at` and `of` are not prose words, so these names stay whole.
|
||
for (const p of [
|
||
'/Users/me/Desktop/Screenshot 2026-10-24 at 10.00.00.png',
|
||
'/home/me/docs/Copy of report.docx',
|
||
'/home/me/docs/Terms of Service.pdf',
|
||
]) {
|
||
expect(paths(p)).toEqual([p]);
|
||
expect(paths(`Saved to: ${p}`)).toEqual([p]);
|
||
}
|
||
});
|
||
|
||
// A command whose executable lives under a known root, or one carrying a flag.
|
||
it.each([
|
||
['/opt/homebrew/bin/node server.js', '/opt/homebrew/bin/node', 'server.js'],
|
||
['/home/me/.venv/bin/python run.py', '/home/me/.venv/bin/python', 'run.py'],
|
||
[
|
||
'/home/me/proj/node_modules/.bin/vitest run test/foo.test.ts',
|
||
'/home/me/proj/node_modules/.bin/vitest',
|
||
'test/foo.test.ts',
|
||
],
|
||
['/home/me/proj/node_modules/.bin/tsc -p tsconfig.json', '/home/me/proj/node_modules/.bin/tsc', 'tsconfig.json'],
|
||
['/home/me/proj/scripts/deploy prod.yml', '/home/me/proj/scripts/deploy', 'prod.yml'],
|
||
['/home/me/scripts/deploy config.yml', '/home/me/scripts/deploy', 'config.yml'],
|
||
['/home/me/proj/tools/lint src/a.ts', '/home/me/proj/tools/lint', 'src/a.ts'],
|
||
])('does not reserve a command under a known root: %s', (line, exe, file) => {
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [exe, true], [file]));
|
||
});
|
||
|
||
it.each([
|
||
'/Users/me/Desktop/Screenshot 2026-10-24 at 10.15.32 AM.png',
|
||
'/mnt/c/Users/me/Docs - Copy/a.docx',
|
||
'- /mnt/c/Users/me/Docs - Copy/a.docx',
|
||
])('keeps a spaced path whole when a word only looks like a flag or a version: %s', (line) => {
|
||
expect(paths(line)).toEqual([line.replace(/^- /, '')]);
|
||
});
|
||
|
||
it('still refuses an unlinkable spaced path that reads as one path to its end', () => {
|
||
expect(paths('/etc/secret files/passwords.txt')).toEqual([]);
|
||
});
|
||
|
||
it('does not merge a column gap into one path', () => {
|
||
const line = '/home/me/proj modified notes.md';
|
||
expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/home/me/proj', true], ['notes.md']));
|
||
});
|
||
|
||
it('reads a leading ./ as the workspace, not a segment', () => {
|
||
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 any earlier word ending in `$`
|
||
// (the first, or a later one when the directory has a space in it), 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']],
|
||
['/mnt/c/Users/me/OneDrive - Contoso/proj$ code notes.md', ['/mnt/c/Users/me/OneDrive', true], ['notes.md']],
|
||
['/mnt/c/My Docs$ ls notes.md', ['/mnt/c/My', 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', () => {
|
||
type Link = { text: string; activate: (event: unknown, text: string) => void };
|
||
const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||
|
||
function linksFor(line: string) {
|
||
const noop = () => {};
|
||
const context = vm.createContext({
|
||
console: { ...console, debug: noop, log: noop },
|
||
CodemanApp: class CodemanApp {},
|
||
document: { addEventListener: noop, getElementById: noop, documentElement: { dataset: {} } },
|
||
window: { addEventListener: noop, removeEventListener: noop, document: { documentElement: { dataset: {} } } },
|
||
});
|
||
vm.runInContext(`${SOURCE}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||
const proto = (context as unknown as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
|
||
let provider: { provideLinks: (row: number, cb: (links?: Link[]) => void) => void } | undefined;
|
||
const opened: string[] = [];
|
||
const app = {
|
||
terminal: {
|
||
cols: 200,
|
||
registerLinkProvider: (p: typeof provider) => (provider = p),
|
||
buffer: {
|
||
active: {
|
||
length: 1,
|
||
getLine: (r: number) =>
|
||
r === 0
|
||
? {
|
||
isWrapped: false,
|
||
translateToString: (trim?: boolean) => (trim === false ? line.padEnd(200) : line),
|
||
}
|
||
: undefined,
|
||
},
|
||
},
|
||
},
|
||
activeSessionId: 's1',
|
||
sessions: new Map([['s1', { workingDir: '/workspace/project' }]]),
|
||
openFilePreview: (p: string) => opened.push(p),
|
||
openLogViewerWindow: (p: string) => opened.push(`log:${p}`),
|
||
_isExternalPreviewPath: () => false,
|
||
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
|
||
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
|
||
};
|
||
app.registerFilePathLinkProvider.call(app);
|
||
let links: Link[] = [];
|
||
provider!.provideLinks(1, (value) => (links = value ?? []));
|
||
return { links, opened };
|
||
}
|
||
|
||
it('links the whole spaced path and opens it, resolved against the workspace', () => {
|
||
const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx';
|
||
const { links, opened } = linksFor(`Saved to: ${rel}`);
|
||
expect(links.map((l) => l.text)).toEqual([rel]);
|
||
links[0].activate(null, links[0].text);
|
||
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([]);
|
||
});
|
||
|
||
// `Bash(` holds a command, so its parentheses bound no spaced path: master links the file
|
||
// (and the folder the executable sits in), never the whole command. The path tools do
|
||
// hold a path, a spaced filename included.
|
||
it.each([
|
||
['Bash(/tmp/a.out input.txt)', [{ path: 'input.txt' }]],
|
||
[
|
||
'Bash(/home/me/proj/build/mytool convert input.json)',
|
||
[{ path: '/home/me/proj/build/mytool', folder: true }, { path: 'input.json' }],
|
||
],
|
||
[
|
||
'Bash(/home/me/proj/target/release/app config.toml)',
|
||
[{ path: '/home/me/proj/target/release/app', folder: true }, { path: 'config.toml' }],
|
||
],
|
||
])('links %s the way master does, not as the whole command', (line, linked) => {
|
||
const found = findFilePathLinks(line).map(({ path, folder }) => (folder ? { path, folder } : { path }));
|
||
expect(found).toEqual(linked);
|
||
expect(found.map((hit) => hit.path)).not.toContain(line.slice(line.indexOf('(') + 1, -1));
|
||
});
|
||
|
||
it.each(['Read', 'Update', 'Write', 'Edit', 'NotBash'])('takes a spaced filename in %s( whole', (tool) => {
|
||
const path = '/mnt/data/Kobo/Kobo eReader.conf';
|
||
expect(paths(`${tool}(${path})`)).toEqual([path]);
|
||
});
|
||
});
|