feat(app): open local file references at the referenced line
Mark absolute Markdown links and inline-code relative paths as file references, resolve them against the originating message cwd, and open them in the configured editor. Local links no longer navigate the SPA away from the running renderer. The canonical transcript assembly projected messages down to seven fields, so cwd and session_id never reached the renderer. Both are added to SessionDetailMessage: cwd because the working directory can change mid-session, session_id because it scopes which roots a reference may resolve inside. The main process derives those roots from the database rather than trusting anything the renderer sends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
87ad2892b6
commit
c3751f0f6b
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildEditorUrl,
|
||||
fileReferenceCandidates,
|
||||
normalizeRoots,
|
||||
resolveFileReference,
|
||||
} from '../app/src/main/file-reference.ts';
|
||||
|
||||
function tempProject() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-file-ref-'));
|
||||
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'src', 'app.ts'), 'export const a = 1;\n');
|
||||
return fs.realpathSync(root);
|
||||
}
|
||||
|
||||
test('normalizeRoots keeps absolute paths and drops the rest', () => {
|
||||
assert.deepEqual(normalizeRoots(['/tmp/a', 'relative', '', null, undefined, '/tmp/a']), ['/tmp/a']);
|
||||
});
|
||||
|
||||
test('candidates cover absolute, cwd-relative and project-relative shapes', () => {
|
||||
const candidates = fileReferenceCandidates({ rawPath: 'src/app.ts', cwd: '/tmp/proj' });
|
||||
assert.ok(candidates.includes(path.join('/tmp/proj', 'src/app.ts')));
|
||||
|
||||
// A leading slash in transcripts usually means "project root", not filesystem root.
|
||||
const rooted = fileReferenceCandidates({ rawPath: '/src/app.ts', cwd: '/tmp/proj' });
|
||||
assert.ok(rooted.includes('/src/app.ts'));
|
||||
assert.ok(rooted.includes(path.join('/tmp/proj', 'src/app.ts')));
|
||||
});
|
||||
|
||||
test('resolves a relative reference against the message cwd', () => {
|
||||
const root = tempProject();
|
||||
assert.equal(
|
||||
resolveFileReference({ rawPath: 'src/app.ts', cwd: root, roots: [] }),
|
||||
path.join(root, 'src', 'app.ts'),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses paths outside the session roots', () => {
|
||||
const root = tempProject();
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-outside-'));
|
||||
fs.writeFileSync(path.join(outside, 'secret.txt'), 'nope\n');
|
||||
assert.equal(resolveFileReference({ rawPath: path.join(outside, 'secret.txt'), cwd: root }), null);
|
||||
assert.equal(resolveFileReference({ rawPath: '../../etc/hosts', cwd: root }), null);
|
||||
});
|
||||
|
||||
test('refuses a symlink that escapes the session roots', () => {
|
||||
const root = tempProject();
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-outside-'));
|
||||
const secret = path.join(outside, 'secret.txt');
|
||||
fs.writeFileSync(secret, 'nope\n');
|
||||
fs.symlinkSync(secret, path.join(root, 'escape.txt'));
|
||||
assert.equal(resolveFileReference({ rawPath: 'escape.txt', cwd: root }), null);
|
||||
});
|
||||
|
||||
test('refuses directories, missing files and rootless queries', () => {
|
||||
const root = tempProject();
|
||||
assert.equal(resolveFileReference({ rawPath: 'src', cwd: root }), null);
|
||||
assert.equal(resolveFileReference({ rawPath: 'src/nope.ts', cwd: root }), null);
|
||||
assert.equal(resolveFileReference({ rawPath: 'src/app.ts', cwd: null, roots: [] }), null);
|
||||
});
|
||||
|
||||
test('builds editor URLs with line and column', () => {
|
||||
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 42 }), 'vscode://file/p/a.ts:42');
|
||||
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 42, column: 7 }), 'vscode://file/p/a.ts:42:7');
|
||||
assert.equal(buildEditorUrl({ filePath: '/p/a.ts' }), 'vscode://file/p/a.ts');
|
||||
assert.equal(buildEditorUrl({ scheme: 'cursor', filePath: '/p/a.ts', line: 3 }), 'cursor://file/p/a.ts:3');
|
||||
});
|
||||
|
||||
test('falls back to the default scheme and encodes spaces', () => {
|
||||
assert.equal(buildEditorUrl({ scheme: 'evil:', filePath: '/p/a.ts' }), 'vscode://file/p/a.ts');
|
||||
assert.equal(buildEditorUrl({ filePath: '/p/a b.ts', line: 1 }), 'vscode://file/p/a%20b.ts:1');
|
||||
});
|
||||
|
||||
test('ignores a zero or negative line number', () => {
|
||||
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 0 }), 'vscode://file/p/a.ts');
|
||||
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: -3 }), 'vscode://file/p/a.ts');
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
hrefToPath,
|
||||
mayContainFileReference,
|
||||
isInlineCodeReference,
|
||||
isLocalHref,
|
||||
parseFileReference,
|
||||
} from '../app/src/renderer/src/file-references.mjs';
|
||||
|
||||
test('splits the Codex line and column suffix', () => {
|
||||
assert.deepEqual(parseFileReference('/Users/me/proj/a.ts:162'), {
|
||||
path: '/Users/me/proj/a.ts', line: 162, column: null, endLine: null,
|
||||
});
|
||||
assert.deepEqual(parseFileReference('/Users/me/proj/a.ts:42:7'), {
|
||||
path: '/Users/me/proj/a.ts', line: 42, column: 7, endLine: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('splits a line range', () => {
|
||||
assert.deepEqual(parseFileReference('taskTree.ts:182-185'), {
|
||||
path: 'taskTree.ts', line: 182, column: null, endLine: 185,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps a reference without a suffix intact', () => {
|
||||
assert.deepEqual(parseFileReference('/Users/me/proj/README.md'), {
|
||||
path: '/Users/me/proj/README.md', line: null, column: null, endLine: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects empty input', () => {
|
||||
assert.equal(parseFileReference(''), null);
|
||||
assert.equal(parseFileReference(null), null);
|
||||
});
|
||||
|
||||
test('recognises local hrefs only', () => {
|
||||
assert.ok(isLocalHref('/Users/me/a.ts'));
|
||||
assert.ok(isLocalHref('file:///Users/me/a.ts'));
|
||||
assert.ok(isLocalHref('C:/proj/a.ts'));
|
||||
assert.ok(!isLocalHref('https://example.com/a.ts'));
|
||||
assert.ok(!isLocalHref('#anchor'));
|
||||
assert.ok(!isLocalHref('mailto:x@y.z'));
|
||||
});
|
||||
|
||||
test('inline code becomes a reference only with an extension and a line', () => {
|
||||
assert.ok(isInlineCodeReference('src/hooks/hook.ts:40'));
|
||||
assert.ok(isInlineCodeReference('packages/kernel/src/router.ts:32'));
|
||||
assert.ok(isInlineCodeReference('taskTree.ts:182-185'));
|
||||
assert.ok(isInlineCodeReference('/src/tools/openai-categories.ts:198-201'));
|
||||
});
|
||||
|
||||
test('ordinary inline code is never turned into a link', () => {
|
||||
assert.ok(!isInlineCodeReference('package.json'));
|
||||
assert.ok(!isInlineCodeReference('useState'));
|
||||
assert.ok(!isInlineCodeReference('npm run build'));
|
||||
assert.ok(!isInlineCodeReference('a.ts'));
|
||||
assert.ok(!isInlineCodeReference('12:30'));
|
||||
assert.ok(!isInlineCodeReference('const x = obj.a[0]:1'));
|
||||
});
|
||||
|
||||
test('decodes file:// URLs but leaves plain paths byte-for-byte', () => {
|
||||
assert.equal(hrefToPath('file:///Users/me/a%20file.ts'), '/Users/me/a file.ts');
|
||||
// A literal percent in a filename must survive: decoding every href would corrupt it.
|
||||
assert.equal(hrefToPath('/Users/me/100%.md'), '/Users/me/100%.md');
|
||||
assert.equal(hrefToPath('/Users/me/a%20b.md'), '/Users/me/a%20b.md');
|
||||
});
|
||||
|
||||
test('returns empty string for a malformed file URL', () => {
|
||||
assert.equal(hrefToPath('file://%E0%A4%A'), '');
|
||||
});
|
||||
|
||||
test('pre-filter skips markup that cannot hold a reference', () => {
|
||||
assert.ok(!mayContainFileReference('<p>plain prose with no markup</p>'));
|
||||
assert.ok(!mayContainFileReference(''));
|
||||
assert.ok(!mayContainFileReference(null));
|
||||
});
|
||||
|
||||
test('pre-filter admits anything with a link or inline code', () => {
|
||||
assert.ok(mayContainFileReference('<p><a href="/a.ts:1">x</a></p>'));
|
||||
assert.ok(mayContainFileReference('<p><code>src/a.ts:1</code></p>'));
|
||||
assert.ok(mayContainFileReference('<pre><code class="language-ts">x</code></pre>'));
|
||||
});
|
||||
@@ -145,6 +145,8 @@ async function loadMainForWindowFlags(flags) {
|
||||
this.devToolsOpened = false;
|
||||
this.webContents = {
|
||||
on() {},
|
||||
setWindowOpenHandler() {},
|
||||
getURL() { return ''; },
|
||||
setZoomLevel() {},
|
||||
openDevTools: () => { this.devToolsOpened = true; },
|
||||
send() {},
|
||||
@@ -222,7 +224,7 @@ test('main process watches every root declared by the built-in provider registry
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -295,7 +297,7 @@ test('main process forwards committed IDs without reopening after a deferred bui
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() { notifications += 1; } };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() { notifications += 1; } };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -374,7 +376,7 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -456,7 +458,7 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -540,7 +542,7 @@ test('main process migrates an existing app database before source-filtered IPC
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -606,7 +608,7 @@ test('main process keeps schema and memory mutations behind the writer lease', a
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -676,7 +678,7 @@ test('closing the last macOS window releases background resources until activati
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
windows.push(this);
|
||||
}
|
||||
loadFile() {}
|
||||
@@ -801,7 +803,7 @@ test('settings rebuild reopens the database from the configured Claude path', as
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -929,7 +931,7 @@ test('settings rebuild keeps the existing database after a worker failure', asyn
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
@@ -1033,7 +1035,7 @@ test('settings rebuild cancels an in-flight background build instead of waiting
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
|
||||
Reference in New Issue
Block a user