mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge pull request #686: feat(terminal): link URLs and paths that wrap across rows or sit in table cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3d0abbf21c
10 files changed
+1591
-89
No files matched your search
@@ -17,6 +17,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8');
|
||||
|
||||
@@ -99,8 +100,9 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
// Excluding `&` truncated every real query string: a WordPress edit link
|
||||
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
|
||||
// login URL (many `&` params) was not clickable at all.
|
||||
const url = shippedPattern('urlPattern');
|
||||
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
|
||||
const context = vm.createContext({ console, window: undefined });
|
||||
vm.runInContext(`${CONSTANTS_SOURCE}\nglobalThis.findUrls = findTerminalUrls;`, context);
|
||||
const findUrls = context.findUrls as (text: string) => Array<{ url: string }>;
|
||||
const cases: Array<[string, string]> = [
|
||||
[
|
||||
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
@@ -115,10 +117,7 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
|
||||
];
|
||||
for (const [line, want] of cases) {
|
||||
url.lastIndex = 0;
|
||||
const m = url.exec(line);
|
||||
expect(m, line).not.toBeNull();
|
||||
expect(strip(m![0]), line).toBe(want);
|
||||
expect(findUrls(line).map((match) => match.url), line).toEqual([want]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// Port: none (pure functions + the link provider in a vm context — no browser, no server).
|
||||
//
|
||||
// Absolute file paths codex word-wrapped inside a markdown table cell were not
|
||||
// clickable: the first fragment has no extension and the continuation has no
|
||||
// absolute root, so FILE_PATH_LINK_PATTERN matched neither. In a two-column
|
||||
// table both continuations sit on the same next row, so each path has to be
|
||||
// followed down its OWN cell (terminalCellWrappedPaths in constants.js).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
|
||||
type Row = { text: string; isWrapped?: boolean };
|
||||
type Hit = { path: string; segments: Array<{ row: number; startCol: number; endCol: number }> };
|
||||
type Link = {
|
||||
text: string;
|
||||
range: { start: { x: number; y: number }; end: { x: number; y: number } };
|
||||
activate: (event: unknown, text: string) => void;
|
||||
};
|
||||
|
||||
const COLS = 200;
|
||||
|
||||
function fakeBuffer(rows: Row[], cols: number) {
|
||||
const lines = rows.map((row) => ({
|
||||
isWrapped: row.isWrapped ?? false,
|
||||
translateToString(trimRight?: boolean) {
|
||||
const padded = row.text.padEnd(cols, ' ');
|
||||
return trimRight ? padded.trimEnd() : padded;
|
||||
},
|
||||
getCell(index: number) {
|
||||
const padded = row.text.padEnd(cols, ' ');
|
||||
if (index >= padded.length) return undefined;
|
||||
return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 };
|
||||
},
|
||||
}));
|
||||
return { length: lines.length, getLine: (i: number) => lines[i] };
|
||||
}
|
||||
|
||||
function loadPure() {
|
||||
const context = vm.createContext({ console, window: undefined });
|
||||
vm.runInContext(`${CONSTANTS}\nglobalThis.__cell = terminalCellWrappedPaths;`, context, {
|
||||
filename: 'constants.js',
|
||||
});
|
||||
return (context as unknown as { __cell: (buffer: unknown, row: number, maxRows?: number) => Hit[] }).__cell;
|
||||
}
|
||||
|
||||
// Shape copied from a real codex pane (synthetic paths).
|
||||
const ROW1 =
|
||||
' Before screenshot (/tmp/demo-check/docs/review/ After screenshot (/tmp/demo-check/docs/review/BLDMESH-';
|
||||
const ROW2 = ' BLDMESH-243/before-overview.png) 243/after-overview.png)';
|
||||
const TABLE: Row[] = [
|
||||
{ text: ' No Vision selected Reprocessed' },
|
||||
{ text: ROW1 },
|
||||
{ text: ROW2 },
|
||||
{ text: '' },
|
||||
];
|
||||
const LISTINGS = [
|
||||
['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'],
|
||||
[' ⎿ /home/u/proj/src', ' /home/u/proj/src/a.ts'],
|
||||
['4.0K /tmp/foo/bar/', '8.0K /tmp/foo/baz.png'],
|
||||
['/home/u/proj/docs:', 'guide.md notes.md'],
|
||||
['Created /tmp/out', 'report.md written next to it'],
|
||||
];
|
||||
|
||||
// A directory that is the whole value of its cell, over the next record's file
|
||||
// name in the same column. A wrapper breaks a path after `/` or `-`, never inside
|
||||
// a name, so none of these is one path: each cell keeps its own link.
|
||||
const WHOLE_VALUE_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [
|
||||
{ rows: [' Repo /home/u/proj', ' Config config.yaml'], dir: '/home/u/proj', file: 'config.yaml' },
|
||||
{
|
||||
rows: [' Workspace /home/u/proj/app', ' Entry main.py'],
|
||||
dir: '/home/u/proj/app',
|
||||
file: 'main.py',
|
||||
},
|
||||
{
|
||||
rows: ['│ Workspace │ /home/u/proj/app │', '│ Entry │ main.py │'],
|
||||
dir: '/home/u/proj/app',
|
||||
file: 'main.py',
|
||||
},
|
||||
{ rows: [' Output dir /tmp/build/out', ' Log build.log'], dir: '/tmp/build/out', file: 'build.log' },
|
||||
{
|
||||
rows: [' Path Purpose', ' /home/u/proj/src sources', ' index.ts entry'],
|
||||
dir: '/home/u/proj/src',
|
||||
file: 'index.ts',
|
||||
},
|
||||
];
|
||||
// The same records where the directory DOES end on a break character: the label
|
||||
// on the next row (`Entry`, `│ Helper │`) says it is a new record, since a wrapper
|
||||
// wraps every column of one table row together and leaves the label cell blank.
|
||||
const LABELLED_RECORD_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [
|
||||
{
|
||||
rows: [' Workspace /home/u/proj/app/', ' Entry main.py'],
|
||||
dir: '/home/u/proj/app/',
|
||||
file: 'main.py',
|
||||
},
|
||||
{
|
||||
rows: ['│ Source │ /home/u/proj/src/ │', '│ Helper │ lib/util.ts │'],
|
||||
dir: '/home/u/proj/src/',
|
||||
file: 'lib/util.ts',
|
||||
},
|
||||
{ rows: [' Prefix /tmp/run-', ' Suffix log.txt'], dir: '/tmp/run-', file: 'log.txt' },
|
||||
];
|
||||
const SEPARATE_RECORD_SHAPES = [...WHOLE_VALUE_SHAPES, ...LABELLED_RECORD_SHAPES];
|
||||
// Known trade-off: with no label to tell them apart, a directory written WITH its
|
||||
// trailing slash over a relative path in the same column reads exactly like a
|
||||
// wrap at `/`, so it still joins.
|
||||
const TRAILING_SLASH_TRADE_OFF = [' /home/u/proj/src/ sources', ' lib/util.ts helpers'];
|
||||
|
||||
const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png';
|
||||
const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png';
|
||||
|
||||
describe('terminalCellWrappedPaths', () => {
|
||||
const find = loadPure();
|
||||
|
||||
it('joins each column’s path with its own continuation, from either row', () => {
|
||||
for (const row of [1, 2]) {
|
||||
const hits = find(fakeBuffer(TABLE, COLS), row);
|
||||
expect(hits.map((h) => h.path).sort()).toEqual([AFTER, BEFORE]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(LISTINGS.map((lines) => [lines]))('does not join independent listing rows: %j', (lines) => {
|
||||
const rows = lines.map((text) => ({ text }));
|
||||
for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))(
|
||||
'does not join a directory cell onto the next record: %j',
|
||||
(_rows, shape) => {
|
||||
const rows = shape.rows.map((text) => ({ text }));
|
||||
for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not join a fragment that ends on `_` (codex wraps cells only after `/` or `-`)', () => {
|
||||
const rows = [{ text: ' shot (/tmp/demo/docs/review_' }, { text: ' before.png)' }];
|
||||
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
|
||||
expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still joins an unlabelled trailing-slash directory over a relative path in its column (known trade-off)', () => {
|
||||
const rows = TRAILING_SLASH_TRADE_OFF.map((text) => ({ text }));
|
||||
expect(find(fakeBuffer(rows, COLS), 1).map((h) => h.path)).toEqual(['/home/u/proj/src/lib/util.ts']);
|
||||
});
|
||||
|
||||
it('places the segments on the path cells only', () => {
|
||||
const hits = find(fakeBuffer(TABLE, COLS), 1);
|
||||
const before = hits.find((h) => h.path === BEFORE)!;
|
||||
expect(before.segments).toEqual([
|
||||
{ row: 1, startCol: ROW1.indexOf('/tmp/'), endCol: ROW1.indexOf('/review/') + '/review/'.length },
|
||||
{ row: 2, startCol: 3, endCol: 3 + 'BLDMESH-243/before-overview.png'.length },
|
||||
]);
|
||||
const after = hits.find((h) => h.path === AFTER)!;
|
||||
const afterCell = ROW1.indexOf('After');
|
||||
expect(after.segments[0]).toEqual({ row: 1, startCol: ROW1.lastIndexOf('/tmp/'), endCol: ROW1.length });
|
||||
expect(after.segments[1]).toEqual({
|
||||
row: 2,
|
||||
startCol: afterCell,
|
||||
endCol: afterCell + '243/after-overview.png'.length,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a path already complete on its row', () => {
|
||||
const rows = [{ text: ' see /tmp/demo/report.md' }, { text: ' next.md' }];
|
||||
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not glue unrelated text onto a path that is not followed by a matching continuation', () => {
|
||||
const rows = [{ text: ' saved under /tmp/demo/docs/' }, { text: ' then run the build' }];
|
||||
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
|
||||
expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not join a continuation that is not aligned to the cell', () => {
|
||||
const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png)' }];
|
||||
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not join across a soft wrap (the logical line owns that)', () => {
|
||||
const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png', isWrapped: true }];
|
||||
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows a path across three rows of one cell', () => {
|
||||
const rows = [{ text: ' a (/tmp/demo/very/' }, { text: ' long/nested/' }, { text: ' file.png)' }];
|
||||
const hits = find(fakeBuffer(rows, COLS), 1);
|
||||
expect(hits.map((h) => h.path)).toEqual(['/tmp/demo/very/long/nested/file.png']);
|
||||
expect(hits[0].segments.map((s) => s.row)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('follows a path down a box-drawn table cell (Claude Code renders tables with │ borders)', () => {
|
||||
const rows = [
|
||||
{ text: '│ Before │ /tmp/demo/docs/review/ │ After │ /tmp/demo/docs/review/BLD- │' },
|
||||
{ text: '│ │ before-overview.png │ │ 243/after-overview.png │' },
|
||||
];
|
||||
const hits = find(fakeBuffer(rows, COLS), 1);
|
||||
expect(hits.map((h) => h.path).sort()).toEqual([
|
||||
'/tmp/demo/docs/review/BLD-243/after-overview.png',
|
||||
'/tmp/demo/docs/review/before-overview.png',
|
||||
]);
|
||||
const before = hits.find((h) => h.path.endsWith('before-overview.png'))!;
|
||||
expect(before.segments).toEqual([
|
||||
{ row: 0, startCol: rows[0].text.indexOf('/tmp/'), endCol: rows[0].text.indexOf('/review/') + 8 },
|
||||
{ row: 1, startCol: rows[1].text.indexOf('before'), endCol: rows[1].text.indexOf('.png') + 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not take a box border or the next cell into a path', () => {
|
||||
const rows = [{ text: '│ /tmp/demo/docs/ │ x │' }, { text: '│ │ notes.md │ y │' }];
|
||||
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal link provider with cell-wrapped paths', () => {
|
||||
function harness(rows: Row[]) {
|
||||
const context = vm.createContext({
|
||||
console: { ...console, debug: vi.fn(), log: vi.fn() },
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } },
|
||||
window: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
open: vi.fn(),
|
||||
document: { documentElement: { dataset: {} } },
|
||||
},
|
||||
});
|
||||
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
const proto = (context as unknown as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
|
||||
let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined;
|
||||
const app = {
|
||||
terminal: {
|
||||
cols: COLS,
|
||||
rows: 24,
|
||||
registerLinkProvider(value: typeof provider) {
|
||||
provider = value;
|
||||
},
|
||||
buffer: { active: { viewportY: 0, ...fakeBuffer(rows, COLS) } },
|
||||
},
|
||||
activeSessionId: 's1',
|
||||
sessions: new Map([['s1', { workingDir: '/workspace/project' }]]),
|
||||
openFilePreview: vi.fn(),
|
||||
openLogViewerWindow: vi.fn(),
|
||||
_openTerminalUrl: proto._openTerminalUrl,
|
||||
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
|
||||
_isExternalPreviewPath: () => false,
|
||||
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
|
||||
};
|
||||
app.registerFilePathLinkProvider.call(app);
|
||||
const provide = (line: number): Link[] => {
|
||||
let links: Link[] | undefined;
|
||||
provider!.provideLinks(line, (value) => {
|
||||
links = value;
|
||||
});
|
||||
return links ?? [];
|
||||
};
|
||||
return { provide, app };
|
||||
}
|
||||
|
||||
it.each(LISTINGS.map((lines) => [lines]))('preserves ordinary listing links: %j', (lines) => {
|
||||
const { provide } = harness(lines.map((text) => ({ text })));
|
||||
const expected = [
|
||||
['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'],
|
||||
['/home/u/proj/src', '/home/u/proj/src/a.ts'],
|
||||
['/tmp/foo/bar/', '/tmp/foo/baz.png'],
|
||||
['/home/u/proj/docs', 'guide.md'],
|
||||
['/tmp/out', 'report.md'],
|
||||
][LISTINGS.indexOf(lines)];
|
||||
for (let row = 0; row < lines.length; row++) {
|
||||
expect(provide(row + 1).map((link) => link.text)).toContain(expected[row]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))(
|
||||
'keeps the folder link and the file link of the next record: %j',
|
||||
(_rows, shape) => {
|
||||
const { provide, app } = harness(shape.rows.map((text) => ({ text })));
|
||||
const dirLine = shape.rows.findIndex((text) => text.includes(shape.dir)) + 1;
|
||||
const fileLine = shape.rows.findIndex((text) => text.includes(shape.file)) + 1;
|
||||
const all = [...provide(dirLine), ...provide(fileLine)].map((link) => link.text);
|
||||
expect(all.some((text) => text.includes(shape.dir + shape.file))).toBe(false);
|
||||
const dirLink = provide(dirLine).find((link) => link.text === shape.dir);
|
||||
expect(dirLink, `${shape.dir} on row ${dirLine}`).toBeDefined();
|
||||
expect(dirLink!.range.start.y).toBe(dirLine);
|
||||
const fileLink = provide(fileLine).find((link) => link.text === shape.file);
|
||||
expect(fileLink, `${shape.file} on row ${fileLine}`).toBeDefined();
|
||||
expect(fileLink!.range.start.y).toBe(fileLine);
|
||||
fileLink!.activate(null, fileLink!.text);
|
||||
expect(app.openFilePreview).not.toHaveBeenCalledWith(shape.dir + shape.file, 's1');
|
||||
}
|
||||
);
|
||||
|
||||
it('makes both screenshot paths clickable from either row and opens the whole path', () => {
|
||||
const { provide, app } = harness(TABLE);
|
||||
for (const line of [2, 3]) {
|
||||
const links = provide(line);
|
||||
for (const target of [BEFORE, AFTER]) {
|
||||
const onRow = links.find((l) => l.text === target && l.range.start.y === line);
|
||||
expect(onRow, `${target} on row ${line}`).toBeDefined();
|
||||
onRow!.activate(null, onRow!.text);
|
||||
expect(app.openFilePreview).toHaveBeenLastCalledWith(target, 's1');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no fragment link that would open a partial path', () => {
|
||||
const { provide } = harness(TABLE);
|
||||
for (const line of [2, 3]) {
|
||||
for (const link of provide(line)) expect([BEFORE, AFTER]).toContain(link.text);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,12 @@ describe('terminalLogicalLine', () => {
|
||||
const ROW1 = ' ackage/thing';
|
||||
const HARD = [{ text: ROW0 }, { text: ROW1 }];
|
||||
|
||||
it('keeps full-width dividers out of link and touch-selection text', () => {
|
||||
const rows = buffer([{ text: '─'.repeat(COLS) }, { text: 'https://example.com/docs' }]);
|
||||
expect(terminalLogicalLine(rows, 0, COLS)).toMatchObject({ startRow: 0, endRow: 0, text: '─'.repeat(COLS) });
|
||||
expect(terminalLogicalLine(rows, 1, COLS)).toMatchObject({ startRow: 1, endRow: 1, text: 'https://example.com/docs' });
|
||||
});
|
||||
|
||||
it('stitches a hard wrap and drops the indent the continuation carries', () => {
|
||||
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
|
||||
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
// Port: none (pure functions + the link provider in a vm context — no browser, no server).
|
||||
//
|
||||
// Terminal URL links that stopped short.
|
||||
//
|
||||
// 1. `)` ended every URL, so a path like `…/Team-Space(QA)Archive/…` linked only as
|
||||
// far as `(QA`. The grammar now keeps a `)` that closes a `(` inside the URL and
|
||||
// still leaves out the closing paren of `(https://…)` / `[text](https://…)`.
|
||||
// 2. A CLI that word-wraps inside a table cell (codex) breaks a long URL at `/`, `?`,
|
||||
// `-` well short of the right edge and indents the rest to the cell's column.
|
||||
// Nothing is `isWrapped` and no row is full width, so each row linked a fragment.
|
||||
//
|
||||
// All URLs here are synthetic (example.com); none come from real output.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
|
||||
type UrlRef = { url: string; startIndex: number; endIndex: number; rawEnd: number };
|
||||
type Extent = { startRow: number; endRow: number } | null;
|
||||
type Row = { text: string; isWrapped?: boolean };
|
||||
type Link = {
|
||||
text: string;
|
||||
range: { start: { x: number; y: number }; end: { x: number; y: number } };
|
||||
activate: (event: unknown, text: string) => void;
|
||||
};
|
||||
|
||||
function loadPure() {
|
||||
const context = vm.createContext({ console, window: undefined });
|
||||
vm.runInContext(
|
||||
`${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine;`,
|
||||
context,
|
||||
{ filename: 'constants.js' }
|
||||
);
|
||||
const ctx = context as unknown as {
|
||||
__urls: (text: string) => UrlRef[];
|
||||
__extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent;
|
||||
__logical: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent;
|
||||
};
|
||||
return {
|
||||
findTerminalUrls: ctx.__urls,
|
||||
terminalUrlContinuationExtent: ctx.__extent,
|
||||
terminalLogicalLine: ctx.__logical,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBuffer(rows: Row[], cols: number) {
|
||||
const lines = rows.map((row) => ({
|
||||
isWrapped: row.isWrapped ?? false,
|
||||
translateToString(trimRight?: boolean) {
|
||||
const padded = row.text.padEnd(cols, ' ');
|
||||
return trimRight ? padded.trimEnd() : padded;
|
||||
},
|
||||
getCell(index: number) {
|
||||
const padded = row.text.padEnd(cols, ' ');
|
||||
if (index >= padded.length) return undefined;
|
||||
return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 };
|
||||
},
|
||||
}));
|
||||
return { length: lines.length, getLine: (i: number) => lines[i] };
|
||||
}
|
||||
|
||||
// A codex-style markdown table row: number, reason, then a `name (url)` cell whose
|
||||
// URL the CLI wrapped across three rows, each continuation indented to the cell.
|
||||
const URL =
|
||||
'https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?' +
|
||||
'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&' +
|
||||
'mobileredirect=true';
|
||||
const CELL = 26;
|
||||
const TABLE: Row[] = [
|
||||
{ text: ' 11 Unrelated row plain text only' },
|
||||
{
|
||||
text: ' 12 New to trending Quarterly Plan.xlsx (https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?',
|
||||
},
|
||||
{
|
||||
text:
|
||||
' '.repeat(CELL) +
|
||||
'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&',
|
||||
},
|
||||
{ text: ' '.repeat(CELL) + 'mobileredirect=true)' },
|
||||
{ text: ' 13 Next row another cell' },
|
||||
];
|
||||
const COLS = 160;
|
||||
const TABLE_CELL_START = TABLE[1].text.indexOf('Quarterly');
|
||||
const TABLE_WITH_TRAILING_COLUMNS: Row[] = [
|
||||
TABLE[0],
|
||||
{
|
||||
text: TABLE[1].text + ' 2026-09-16 18:00:54 Document Owner',
|
||||
},
|
||||
{ text: ' '.repeat(TABLE_CELL_START) + TABLE[2].text.slice(CELL) },
|
||||
{ text: ' '.repeat(TABLE_CELL_START) + TABLE[3].text.slice(CELL) },
|
||||
TABLE[4],
|
||||
];
|
||||
const TABLE_COLS = 220;
|
||||
const PROSE_ROWS = [
|
||||
[
|
||||
'⏺ The guide lives at https://example.com/guide/',
|
||||
' self-hosted runners need a token first.',
|
||||
'https://example.com/guide/',
|
||||
],
|
||||
['Docs: https://example.com/guide/', 'Self-hosting is covered in chapter 3.', 'https://example.com/guide/'],
|
||||
['Open http://localhost:3000/', 'e.g. to check the dashboard', 'http://localhost:3000/'],
|
||||
['Endpoint https://api.example.com/v1/', 'user_id is required', 'https://api.example.com/v1/'],
|
||||
['Repo: https://github.com/org/repo/', 'well-known issues are tracked', 'https://github.com/org/repo/'],
|
||||
['Callback https://example.com/cb?code=', 'Re-run the login', 'https://example.com/cb?code='],
|
||||
['Branch https://github.com/o/r/tree/feat-', 'x.y happened', 'https://github.com/o/r/tree/feat-'],
|
||||
];
|
||||
// Rows that carry URL punctuation but are never the rest of the URL above them:
|
||||
// a shell prompt, an HTTP status line, a home path, and column-0 prose. Each
|
||||
// third element is the URL, the fourth the path link expected on the second row.
|
||||
const NOT_CONTINUATION_ROWS: [string, string, string, string | null][] = [
|
||||
['https://registry.npmjs.org/', 'arkon@box:~/proj$', 'https://registry.npmjs.org/', '~/proj'],
|
||||
['https://pypi.org/simple/', 'dev@laptop:/srv/app$', 'https://pypi.org/simple/', '/srv/app'],
|
||||
['$ curl -sI https://example.com/', 'HTTP/2 200', 'https://example.com/', null],
|
||||
['$ curl -i http://localhost:3000/api/', 'HTTP/1.1 200 OK', 'http://localhost:3000/api/', null],
|
||||
['Docs: https://example.com/guide/', '~/notes.md has more', 'https://example.com/guide/', '~/notes.md'],
|
||||
['fetch https://api.example.com/items/', 'status=200 took=12ms', 'https://api.example.com/items/', null],
|
||||
['Status page: https://status.example.com/', '24/7 monitoring is on.', 'https://status.example.com/', null],
|
||||
['Did you mean https://example.com/search?q=', 'really? no.', 'https://example.com/search?q=', null],
|
||||
];
|
||||
// The last column of a space-aligned table: the next row opens the next record
|
||||
// in an earlier column, so its first cell is never the rest of the URL. Each
|
||||
// row is indented two spaces. The URL on each row, then any path link expected
|
||||
// on the second row.
|
||||
const TABLE_LAST_COLUMN_ROWS: [string, string, string, string, string | null][] = [
|
||||
[
|
||||
' auth-api https://auth.example.com/',
|
||||
' billing-api https://billing.example.com/',
|
||||
'https://auth.example.com/',
|
||||
'https://billing.example.com/',
|
||||
null,
|
||||
],
|
||||
[
|
||||
' docs.example.com https://docs.example.com/',
|
||||
' api.example.com https://api.example.com/',
|
||||
'https://docs.example.com/',
|
||||
'https://api.example.com/',
|
||||
null,
|
||||
],
|
||||
[
|
||||
' Homepage: https://example.com/',
|
||||
' Bug-reports: https://github.com/org/repo/issues/',
|
||||
'https://example.com/',
|
||||
'https://github.com/org/repo/issues/',
|
||||
null,
|
||||
],
|
||||
[
|
||||
' user_service https://users.example.com/v1/',
|
||||
' order_service https://orders.example.com/v1/',
|
||||
'https://users.example.com/v1/',
|
||||
'https://orders.example.com/v1/',
|
||||
null,
|
||||
],
|
||||
[
|
||||
' README.md https://example.com/docs/',
|
||||
' CHANGELOG.md https://example.com/changes/',
|
||||
'https://example.com/docs/',
|
||||
'https://example.com/changes/',
|
||||
'CHANGELOG.md',
|
||||
],
|
||||
[
|
||||
' my-service- https://svc.example.com/api/',
|
||||
' long-name https://svc2.example.com/',
|
||||
'https://svc.example.com/api/',
|
||||
'https://svc2.example.com/',
|
||||
null,
|
||||
],
|
||||
];
|
||||
// Known trade-offs: a row end counts as a cell end, so a short indented word
|
||||
// with URL punctuation still continues the URL. Pinned so a change is deliberate.
|
||||
const TRADE_OFF_ROWS: [string, string, string][] = [
|
||||
['⏺ Docs live at https://example.com/guide/', ' self-hosted', 'https://example.com/guide/self-hosted'],
|
||||
[
|
||||
'⏺ The docs are at https://example.com/guide/',
|
||||
' and/or the API reference covers it.',
|
||||
'https://example.com/guide/and/or',
|
||||
],
|
||||
];
|
||||
|
||||
// Indented rows under a URL ending in `/` that carry URL punctuation but are
|
||||
// code, a label, a `name=value` field, a measurement or a clock time, or a word
|
||||
// ending a sentence. Each third element is the URL on the first row.
|
||||
const FIELD_AND_CODE_ROWS: [string, string, string][] = [
|
||||
[' └ Listening on http://127.0.0.1:8080/', ' ready.', 'http://127.0.0.1:8080/'],
|
||||
['⏺ Deployed to https://app.example.com/', ' Done.', 'https://app.example.com/'],
|
||||
['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/'],
|
||||
[' Fetching https://example.com/files/', ' done...', 'https://example.com/files/'],
|
||||
[
|
||||
' ● fetch › handles https://api.example.com/v1/',
|
||||
' expect(received).toBe(expected)',
|
||||
'https://api.example.com/v1/',
|
||||
],
|
||||
[' See https://example.com/api/', ' client.get()', 'https://example.com/api/'],
|
||||
[
|
||||
'/home/u/.venv/lib/urllib3/__init__.py:35: Warning: see https://github.com/urllib3/urllib3/issues/',
|
||||
' warnings.warn(',
|
||||
'https://github.com/urllib3/urllib3/issues/',
|
||||
],
|
||||
[
|
||||
' 1) Client fetches https://api.example.com/v1/',
|
||||
' Failure/Error: expect(res.code).to eq(200)',
|
||||
'https://api.example.com/v1/',
|
||||
],
|
||||
[
|
||||
'Annotations: link.argocd.argoproj.io/external-link: https://grafana.example.com/d/',
|
||||
' prometheus.io/scrape: true',
|
||||
'https://grafana.example.com/d/',
|
||||
],
|
||||
[' BASE_URL=https://api.example.com/', ' API_KEY=your-key-here', 'https://api.example.com/'],
|
||||
[' AUTH_URL=https://auth.example.com/', ' LOG_LEVEL=debug', 'https://auth.example.com/'],
|
||||
[
|
||||
' level=info msg="request" url=https://api.example.com/v1/',
|
||||
' level=info msg="done" status=200',
|
||||
'https://api.example.com/v1/',
|
||||
],
|
||||
[' url=https://api.example.com/v1/', ' request_id=abc-123', 'https://api.example.com/v1/'],
|
||||
[
|
||||
' Callback: https://example.com/oauth/callback/',
|
||||
' state=abc123 must match',
|
||||
'https://example.com/oauth/callback/',
|
||||
],
|
||||
[
|
||||
' Downloading https://huggingface.co/org/model/resolve/main/',
|
||||
' 45%|████▌ | 450M/1.00G [00:10<00:12, 45.0MB/s]',
|
||||
'https://huggingface.co/org/model/resolve/main/',
|
||||
],
|
||||
[' Downloading https://example.com/files/', ' 12.3MB/45.0MB', 'https://example.com/files/'],
|
||||
[' GET https://example.com/api/', ' 12.3ms', 'https://example.com/api/'],
|
||||
[' https://example.com/blog/', ' 10:00:00.123', 'https://example.com/blog/'],
|
||||
];
|
||||
// Continuations at the edge of those rules that are still the rest of the URL:
|
||||
// a wiki title with parens, a file name before a sentence period, a query
|
||||
// parameter after `?`, `&` or `_`, and percent-encoding that starts with digits.
|
||||
const STILL_CONTINUES_ROWS: [string, string, string][] = [
|
||||
[' https://en.wikipedia.org/wiki/', ' Mercury_(planet)', 'https://en.wikipedia.org/wiki/Mercury_(planet)'],
|
||||
['⏺ The guide is at https://example.com/docs/', ' install.html.', 'https://example.com/docs/install.html'],
|
||||
[' https://example.com/oauth?client_', ' id=abc&scope=read', 'https://example.com/oauth?client_id=abc&scope=read'],
|
||||
[' https://example.com/cb?code=abc&', ' state=xyz', 'https://example.com/cb?code=abc&state=xyz'],
|
||||
[' Docs: https://example.com/guide?', ' x=1&y=2 more text', 'https://example.com/guide?x=1&y=2'],
|
||||
[' https://example.com/files/', ' 2026%20Plan.xlsx', 'https://example.com/files/2026%20Plan.xlsx'],
|
||||
];
|
||||
// A URL row that soft-wraps at 41 columns with a table column on its wrapped
|
||||
// part, over an indented row: the column and that row are separate links, never
|
||||
// one glued `/tmp/report.txtccc/x.html`.
|
||||
const SOFT_WRAP_COLS = 41;
|
||||
const SOFT_WRAPPED_URL_ROWS: Row[] = [
|
||||
{ text: ' Doc https://example.com/aaaaaaaaaaaaaa' },
|
||||
{ text: 'bbb/ /tmp/report.txt', isWrapped: true },
|
||||
{ text: ' ccc/x.html' },
|
||||
];
|
||||
|
||||
describe('findTerminalUrls (grammar)', () => {
|
||||
const { findTerminalUrls } = loadPure();
|
||||
const urls = (text: string) => findTerminalUrls(text).map((u) => u.url);
|
||||
|
||||
it('keeps a paren pair that is part of the URL', () => {
|
||||
expect(urls('see https://example.com/wiki/Widget_(tool) now')).toEqual(['https://example.com/wiki/Widget_(tool)']);
|
||||
expect(urls('https://example.com/Team-Space(QA)Archive/x')).toEqual([
|
||||
'https://example.com/Team-Space(QA)Archive/x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves out the paren that wraps the URL', () => {
|
||||
expect(urls('(https://example.com/a(b)c)')).toEqual(['https://example.com/a(b)c']);
|
||||
expect(urls('file (https://example.com/doc?x=1).')).toEqual(['https://example.com/doc?x=1']);
|
||||
});
|
||||
|
||||
it('leaves out the closing paren of a markdown link', () => {
|
||||
expect(urls('[the docs](https://example.com/docs?a=1&b=2) for details')).toEqual([
|
||||
'https://example.com/docs?a=1&b=2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still keeps query ampersands and stops at the shell &&', () => {
|
||||
expect(urls('open https://example.com/p?post=1&action=edit&& echo done')).toEqual([
|
||||
'https://example.com/p?post=1&action=edit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('trims trailing sentence punctuation', () => {
|
||||
expect(urls('Go to https://example.com/path.')).toEqual(['https://example.com/path']);
|
||||
expect(urls('Is it https://example.com/q?')).toEqual(['https://example.com/q']);
|
||||
});
|
||||
|
||||
it('reports where the raw match ended before trimming', () => {
|
||||
const [ref] = findTerminalUrls('x https://example.com/a) y');
|
||||
expect(ref.url).toBe('https://example.com/a');
|
||||
expect(ref.rawEnd).toBe('x https://example.com/a)'.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminalUrlContinuationExtent (wrap joining)', () => {
|
||||
const { terminalUrlContinuationExtent, terminalLogicalLine } = loadPure();
|
||||
|
||||
it('spans all three rows of a URL wrapped inside a table cell, from any of them', () => {
|
||||
const buffer = fakeBuffer(TABLE, COLS);
|
||||
for (const row of [1, 2, 3]) {
|
||||
expect(terminalUrlContinuationExtent(buffer, row, COLS, 12)).toEqual({ startRow: 1, endRow: 3 });
|
||||
}
|
||||
});
|
||||
|
||||
it('spans a table URL even when date and owner columns follow its first fragment', () => {
|
||||
const buffer = fakeBuffer(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS);
|
||||
for (const row of [1, 2, 3]) {
|
||||
expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 });
|
||||
}
|
||||
});
|
||||
|
||||
it('starts at the URL row when a full-width table divider precedes it', () => {
|
||||
const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)];
|
||||
const buffer = fakeBuffer(rows, TABLE_COLS);
|
||||
for (const row of [1, 2, 3]) {
|
||||
expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat the next numbered table row as a URL continuation', () => {
|
||||
const rows = [TABLE_WITH_TRAILING_COLUMNS[1], { text: ' 13 New to trending Another document' }];
|
||||
const buffer = fakeBuffer(rows, TABLE_COLS);
|
||||
expect(terminalUrlContinuationExtent(buffer, 0, TABLE_COLS, 12)).toBeNull();
|
||||
expect(terminalUrlContinuationExtent(buffer, 1, TABLE_COLS, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the neighbouring table rows alone', () => {
|
||||
const buffer = fakeBuffer(TABLE, COLS);
|
||||
expect(terminalUrlContinuationExtent(buffer, 0, COLS, 12)).toBeNull();
|
||||
expect(terminalUrlContinuationExtent(buffer, 4, COLS, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['1. https://example.com/one/', '2. https://example.com/two/'],
|
||||
['Docs: https://docs.example.com/guide/', '1. Install the package'],
|
||||
['Fetching https://registry.example.com/pkg/', 'v1.2.3 is the latest'],
|
||||
['GET http://localhost:3000/api/', '200 OK in 12ms'],
|
||||
])('keeps list, version and status rows separate after %s', (first, next) => {
|
||||
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
|
||||
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(PROSE_ROWS)('does not join prose after %s', (first, next) => {
|
||||
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
|
||||
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(NOT_CONTINUATION_ROWS)('does not join a prompt, status line, home path or prose after %s', (first, next) => {
|
||||
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
|
||||
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
|
||||
});
|
||||
|
||||
// Indented, so only the prompt / home path / status-line rule can refuse them.
|
||||
it.each(['arkon@box:~/proj$', 'dev@laptop:/srv/app$', '~/notes.md', 'HTTP/2 200', 'HTTP/1.1 200 OK'])(
|
||||
'does not join an indented %s either',
|
||||
(next) => {
|
||||
const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100);
|
||||
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(TABLE_LAST_COLUMN_ROWS)('does not join the next table record after %s', (first, next) => {
|
||||
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
|
||||
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(FIELD_AND_CODE_ROWS)(
|
||||
'does not join code, a field, a measurement or a sentence end after %s',
|
||||
(first, next) => {
|
||||
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
|
||||
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(STILL_CONTINUES_ROWS)('still joins the continuation after %s', (first, next) => {
|
||||
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
|
||||
for (const row of [0, 1])
|
||||
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
|
||||
});
|
||||
|
||||
it('does not join an indented email address', () => {
|
||||
const buffer = fakeBuffer([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100);
|
||||
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(TRADE_OFF_ROWS)('still joins the known trade-off after %s', (first, next) => {
|
||||
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
|
||||
for (const row of [0, 1])
|
||||
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
|
||||
});
|
||||
|
||||
it('does not let a URL at the right edge absorb a full-width divider row below it', () => {
|
||||
const cols = 40;
|
||||
const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length);
|
||||
for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) {
|
||||
const buffer = fakeBuffer([{ text: url }, { text: divider }, { text: 'next' }], cols);
|
||||
expect(terminalLogicalLine(buffer, 0, cols, 12)).toMatchObject({ startRow: 0, endRow: 0 });
|
||||
expect(terminalLogicalLine(buffer, 1, cols, 12)).toMatchObject({ startRow: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
// Real continuations are indented (codex cells, Claude's two-space paragraph
|
||||
// indent); a column-0 row is refused, so these fixtures are indented.
|
||||
it.each(['my-post-title', 'user_id', 'x.html', 'my-post-title ', 'my-post-title Other column'])(
|
||||
'keeps a weak-punctuation continuation that ends its cell: %s',
|
||||
(next) => {
|
||||
const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100);
|
||||
for (const row of [0, 1])
|
||||
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['part%20two', 'path/slug', 'query?x=1'])('keeps stronger URL punctuation followed by prose: %s', (token) => {
|
||||
const buffer = fakeBuffer(
|
||||
[{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }],
|
||||
100
|
||||
);
|
||||
expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
|
||||
});
|
||||
|
||||
it('does not glue a following prose line onto a URL that ends a line', () => {
|
||||
const buffer = fakeBuffer(
|
||||
[{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }],
|
||||
80
|
||||
);
|
||||
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
|
||||
expect(terminalUrlContinuationExtent(buffer, 1, 80, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not join a second URL on the next row', () => {
|
||||
const buffer = fakeBuffer([{ text: '- https://example.com/one/' }, { text: ' https://example.com/two' }], 80);
|
||||
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not join a row indented to the right of where the URL started', () => {
|
||||
const buffer = fakeBuffer([{ text: 'x https://example.com/a/b?' }, { text: ' q=1&r=2' }], 80);
|
||||
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not join when the URL line ends on a character a wrapper would not break after', () => {
|
||||
const buffer = fakeBuffer([{ text: 'see https://example.com/page' }, { text: 'v2-notes.txt follows' }], 80);
|
||||
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('stays within the row bound', () => {
|
||||
const rows: Row[] = [{ text: 'u https://example.com/' }];
|
||||
for (let i = 0; i < 20; i++) rows.push({ text: ` seg${i}-` });
|
||||
const extent = terminalUrlContinuationExtent(fakeBuffer(rows, 80), 0, 80, 5);
|
||||
expect(extent).not.toBeNull();
|
||||
expect(extent!.endRow - extent!.startRow + 1).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('does not continue a URL whose row soft-wraps with a column on the wrapped part', () => {
|
||||
const buffer = fakeBuffer(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS);
|
||||
for (const row of [0, 1, 2]) {
|
||||
expect(terminalUrlContinuationExtent(buffer, row, SOFT_WRAP_COLS, 12)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('native OSC 8 terminal URLs', () => {
|
||||
it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => {
|
||||
const open = vi.fn();
|
||||
const confirm = vi.fn(() => true);
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
document: { addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), confirm },
|
||||
});
|
||||
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
const proto = (
|
||||
context as unknown as {
|
||||
__proto: { _terminalOsc8LinkHandler: () => { activate: (event: unknown, url: string) => void } };
|
||||
}
|
||||
).__proto;
|
||||
const handler = proto._terminalOsc8LinkHandler.call({ _openTerminalUrl: open });
|
||||
const url = 'https://docs.example.com/Doc.aspx?file=Q3%20Plan&action=default&mobileredirect=true';
|
||||
handler.activate(null, url);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(url);
|
||||
expect(confirm).toHaveBeenCalledExactlyOnceWith(`Open this link?\n\n${url}`);
|
||||
confirm.mockReturnValue(false);
|
||||
handler.activate(null, 'http://localhost:3000/private');
|
||||
expect(confirm).toHaveBeenLastCalledWith('Open this link?\n\nhttp://localhost:3000/private');
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) {
|
||||
handler.activate(null, unsafe);
|
||||
}
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(confirm).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal link provider with wrapped table URLs', () => {
|
||||
function harness(rows: Row[], cols: number) {
|
||||
const openSpy = vi.fn();
|
||||
const context = vm.createContext({
|
||||
console: { ...console, debug: vi.fn(), log: vi.fn() },
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } },
|
||||
window: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
open: openSpy,
|
||||
document: { documentElement: { dataset: {} } },
|
||||
},
|
||||
});
|
||||
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
const proto = (context as unknown as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
|
||||
let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined;
|
||||
const app = {
|
||||
terminal: {
|
||||
cols,
|
||||
rows: 24,
|
||||
registerLinkProvider(value: typeof provider) {
|
||||
provider = value;
|
||||
},
|
||||
buffer: { active: { viewportY: 0, ...fakeBuffer(rows, cols) } },
|
||||
},
|
||||
activeSessionId: 's1',
|
||||
sessions: new Map([['s1', { workingDir: '/workspace/project' }]]),
|
||||
openFilePreview: vi.fn(),
|
||||
openLogViewerWindow: vi.fn(),
|
||||
_openTerminalUrl: proto._openTerminalUrl,
|
||||
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
|
||||
_isExternalPreviewPath: () => false,
|
||||
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
|
||||
};
|
||||
app.registerFilePathLinkProvider.call(app);
|
||||
const provide = (line: number): Link[] => {
|
||||
let links: Link[] | undefined;
|
||||
provider!.provideLinks(line, (value) => {
|
||||
links = value;
|
||||
});
|
||||
return links ?? [];
|
||||
};
|
||||
return { provide, openSpy };
|
||||
}
|
||||
|
||||
it.each(PROSE_ROWS)('preserves the original clickable URL before prose: %s', (first, next, url) => {
|
||||
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
|
||||
const links = provide(1).filter((link) => /^https?:/.test(link.text));
|
||||
expect(links.map((link) => link.text)).toEqual([url]);
|
||||
links[0].activate(null, links[0].text);
|
||||
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
|
||||
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(NOT_CONTINUATION_ROWS)('keeps the URL and the second row separate: %s', (first, next, url, pathOnNext) => {
|
||||
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
|
||||
const links = provide(1).filter((link) => /^https?:/.test(link.text));
|
||||
expect(links.map((link) => link.text)).toEqual([url]);
|
||||
links[0].activate(null, links[0].text);
|
||||
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
|
||||
const second = provide(2);
|
||||
expect(second.filter((link) => /^https?:/.test(link.text))).toEqual([]);
|
||||
if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext);
|
||||
});
|
||||
|
||||
it.each(TABLE_LAST_COLUMN_ROWS)(
|
||||
'keeps each last-column URL on its own row: %s',
|
||||
(first, next, url, nextUrl, pathOnNext) => {
|
||||
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
|
||||
const links = provide(1).filter((link) => /^https?:/.test(link.text));
|
||||
expect(links.map((link) => link.text)).toEqual([url]);
|
||||
links[0].activate(null, links[0].text);
|
||||
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
|
||||
const second = provide(2);
|
||||
expect(second.filter((link) => /^https?:/.test(link.text)).map((link) => link.text)).toEqual([nextUrl]);
|
||||
if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(FIELD_AND_CODE_ROWS)(
|
||||
'keeps code, a field, a measurement or a sentence end separate: %s',
|
||||
(first, next, url) => {
|
||||
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
|
||||
const links = provide(1).filter((link) => /^https?:/.test(link.text));
|
||||
expect(links.map((link) => link.text)).toEqual([url]);
|
||||
links[0].activate(null, links[0].text);
|
||||
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
|
||||
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(STILL_CONTINUES_ROWS)('links the whole URL from both rows after %s', (first, next, joined) => {
|
||||
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
|
||||
for (const line of [1, 2]) {
|
||||
// One link per row the URL covers, each opening the whole URL.
|
||||
const links = provide(line).filter((link) => /^https?:/.test(link.text));
|
||||
expect(new Set(links.map((link) => link.text))).toEqual(new Set([joined]));
|
||||
}
|
||||
const [link] = provide(2).filter((l) => /^https?:/.test(l.text));
|
||||
link.activate(null, link.text);
|
||||
expect(openSpy).toHaveBeenCalledExactlyOnceWith(joined, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('keeps an indented email address out of the URL above it', () => {
|
||||
const { provide } = harness([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100);
|
||||
expect(
|
||||
provide(1)
|
||||
.filter((link) => /^https?:/.test(link.text))
|
||||
.map((link) => link.text)
|
||||
).toEqual(['https://example.com/']);
|
||||
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(TRADE_OFF_ROWS)('pins the known trade-off link after %s', (first, next, joined) => {
|
||||
const { provide } = harness([{ text: first }, { text: next }], 100);
|
||||
for (const line of [1, 2]) {
|
||||
const links = provide(line).filter((link) => /^https?:/.test(link.text));
|
||||
expect(links.map((link) => link.text)).toContain(joined);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a full-width divider row out of a URL that reaches the right edge', () => {
|
||||
const cols = 40;
|
||||
const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length);
|
||||
for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) {
|
||||
const { provide } = harness([{ text: url }, { text: divider }], cols);
|
||||
expect(
|
||||
provide(1)
|
||||
.filter((link) => /^https?:/.test(link.text))
|
||||
.map((link) => link.text)
|
||||
).toEqual([url]);
|
||||
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the whole URL from any of its rows', () => {
|
||||
const { provide, openSpy } = harness(TABLE, COLS);
|
||||
for (const line of [2, 3, 4]) {
|
||||
const urlLinks = provide(line).filter((link) => link.text.startsWith('https://'));
|
||||
expect(urlLinks.length).toBeGreaterThan(0);
|
||||
for (const link of urlLinks) expect(link.text).toBe(URL);
|
||||
const onRow = urlLinks.find((link) => link.range.start.y === line);
|
||||
expect(onRow).toBeDefined();
|
||||
onRow!.activate(null, onRow!.text);
|
||||
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the whole table URL without including the adjacent date and owner columns', () => {
|
||||
const { provide, openSpy } = harness(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS);
|
||||
for (const line of [2, 3, 4]) {
|
||||
const links = provide(line).filter((link) => link.text.startsWith('https://'));
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
for (const link of links) expect(link.text).toBe(URL);
|
||||
links[0].activate(null, links[0].text);
|
||||
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
const firstRowLink = provide(2).find((link) => link.text === URL && link.range.start.y === 2);
|
||||
expect(firstRowLink?.range.end.x).toBe(TABLE[1].text.length);
|
||||
});
|
||||
|
||||
it('opens every segment after a full-width table divider', () => {
|
||||
const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)];
|
||||
const { provide, openSpy } = harness(rows, TABLE_COLS);
|
||||
for (const line of [2, 3, 4]) {
|
||||
const link = provide(line).find((item) => item.text === URL && item.range.start.y === line);
|
||||
expect(link).toBeDefined();
|
||||
link!.activate(null, link!.text);
|
||||
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
expect(segments.map((link) => link.range.start.y)).toEqual([2, 3, 4]);
|
||||
for (const link of segments) expect(link.range.start.y).toBe(link.range.end.y);
|
||||
expect(segments[0].range.start.x).toBe(TABLE[1].text.indexOf('https://') + 1);
|
||||
expect(segments[0].range.end.x).toBe(TABLE[1].text.length);
|
||||
expect(segments[1].range.start.x).toBe(CELL + 1);
|
||||
expect(segments[2].range.start.x).toBe(CELL + 1);
|
||||
// Ends on `true`, not on the closing paren after it.
|
||||
expect(segments[2].range.end.x).toBe(TABLE[3].text.length - 1);
|
||||
});
|
||||
|
||||
it('links the column on a soft-wrapped URL row and the row below it separately', () => {
|
||||
const { provide } = harness(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS);
|
||||
const texts = [1, 2, 3].flatMap((line) => provide(line).map((link) => link.text));
|
||||
expect(texts.filter((text) => text.includes('report.txtccc'))).toEqual([]);
|
||||
expect(provide(2).map((link) => link.text)).toContain('/tmp/report.txt');
|
||||
expect(provide(3).map((link) => link.text)).toContain('ccc/x.html');
|
||||
});
|
||||
|
||||
it('keeps a single-row URL with a paren pair whole', () => {
|
||||
const line = 'Open https://example.com/wiki/Widget_(tool) please';
|
||||
const { provide } = harness([{ text: line }], 80);
|
||||
const [link] = provide(1).filter((l) => l.text.startsWith('https://'));
|
||||
expect(link.text).toBe('https://example.com/wiki/Widget_(tool)');
|
||||
});
|
||||
});
|
||||
@@ -299,7 +299,10 @@ describe('openLinkThroughWebTabIfLoopback', () => {
|
||||
|
||||
describe('callers consult the hook first', () => {
|
||||
it('terminal URL links try the web tab before window.open', () => {
|
||||
const activate = TERMINAL_UI.indexOf('activate(_event, text) {');
|
||||
// URL links (and OSC 8 links) open through _openTerminalUrl(), so the desktop
|
||||
// click and the mobile touch-tap path both consult the hook. Read that function.
|
||||
expect(TERMINAL_UI).toContain('self._openTerminalUrl(url);');
|
||||
const activate = TERMINAL_UI.indexOf('_openTerminalUrl(text) {');
|
||||
expect(activate).toBeGreaterThan(-1);
|
||||
const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate));
|
||||
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1);
|
||||
|
||||
Reference in new issue
Block a user