feat(terminal): link URLs and paths that wrap across rows or sit in table cells

A CLI that word-wraps inside a markdown table cell or an indented paragraph
breaks a long URL or path short of the right edge and indents the rest, so
nothing is flagged isWrapped and no row is full width. Each row linked only
a fragment, and the fragment opened the wrong target.

- findTerminalUrls() (constants.js) is the URL grammar for the link provider:
  a ")" that closes a "(" inside the URL is kept, an unmatched one still ends
  it, so (https://...) and [text](https://...) stay clean.
- terminalUrlContinuationExtent() joins rows the CLI wrapped a URL across,
  only when the fragment ends on a wrap-break character, the next row is
  indented no further right than the URL started, and its first token looks
  like URL material. Table columns after the first fragment are left out.
- terminalLinkLine() widens terminalPathLine() to that extent; a URL that
  spans rows is emitted as one link per row, each opening the whole URL, so
  the indent and other columns between the pieces are not underlined.
- terminalCellWrappedPaths() follows an absolute path down its own table
  cell (space-separated or box-drawn), so two-column tables link both paths.
- A full-width table divider no longer counts as a hard-wrapped line.
- OSC 8 hyperlinks open through the same opener, web URLs only.
This commit is contained in:
Aamer Akhter committed 2026-10-22 17:44:46 -04:00
1 parent 002de32a08
commit 9f62da9ac3
6 files changed
+1018 -83

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
// 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 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('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('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);
}
});
});
+337
View File
@@ -0,0 +1,337 @@
// 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;`,
context,
{ filename: 'constants.js' }
);
const ctx = context as unknown as {
__urls: (text: string) => UrlRef[];
__extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent;
};
return { findTerminalUrls: ctx.__urls, terminalUrlContinuationExtent: ctx.__extent };
}
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;
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 } = 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('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);
});
});
describe('native OSC 8 terminal URLs', () => {
it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => {
const open = vi.fn();
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
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);
for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) {
handler.activate(null, unsafe);
}
expect(open).toHaveBeenCalledTimes(1);
});
});
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('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('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)');
});
});
+4 -1
View File
@@ -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);