mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the session folder, and an outside worktree is published as outsideWorktree and walked only with root=live-any. Refuse a symlinked .git folder as the main worktree, sensitive files inside a followed worktree, and any outside worktree while attachment confinement is on or around a blocked tree. The file routes re-check the guard settings on every request, and a throwing rule check refuses. The panel keeps its Back to worktree button while pinned to the session folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
// 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,
|
||
_isOutsideWorktreePath: () => 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);
|
||
}
|
||
});
|
||
});
|