mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
The merged PRs (mostly #714's git fetch) added about 120 em-dashes in comments, docs, test names and three settings strings. Each one is now a comma, colon, semicolon or parentheses. The English settings strings in index.html and their i18n.js keys change together, and the zh-CN value for the fetch interval uses full-width parentheses instead of a double dash. The one left is the regex in constants.js that matches a dash typed as a word, which is behaviour, not punctuation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
813 lines
39 KiB
TypeScript
813 lines
39 KiB
TypeScript
// 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; globalThis.__linkLine = terminalLinkLine;`,
|
||
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;
|
||
__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,
|
||
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;
|
||
// 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/',
|
||
' 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('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();
|
||
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, app };
|
||
}
|
||
|
||
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('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);
|
||
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)');
|
||
});
|
||
});
|