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:
tommy0103
2026-07-31 05:03:21 +08:00
co-authored by Claude Opus 5
parent 87ad2892b6
commit c3751f0f6b
17 changed files with 730 additions and 21 deletions
+90
View File
@@ -0,0 +1,90 @@
import fs from 'node:fs';
import path from 'node:path';
const EDITOR_SCHEMES: Record<string, string> = {
vscode: 'vscode',
'vscode-insiders': 'vscode-insiders',
cursor: 'cursor',
windsurf: 'windsurf',
zed: 'zed',
};
const DEFAULT_EDITOR_SCHEME = 'vscode';
interface FileReferenceQuery {
rawPath?: string | null;
cwd?: string | null;
roots?: (string | null | undefined)[];
}
interface EditorUrlOptions {
scheme?: string | null;
filePath: string;
line?: number | null;
column?: number | null;
}
function normalizeRoots(roots: (string | null | undefined)[] = []): string[] {
const seen = new Set<string>();
for (const root of roots) {
if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) continue;
seen.add(path.resolve(root));
}
return [...seen];
}
function isWithinRoots(candidate: string, roots: string[]): boolean {
return roots.some((root) => candidate === root || candidate.startsWith(root + path.sep));
}
// A reference may be absolute, relative to the message cwd, or project-relative with a
// leading slash (`/src/foo.ts`). path.join treats the leading slash as a no-op, so the same
// join covers the last two cases.
function fileReferenceCandidates({ rawPath, cwd, roots = [] }: FileReferenceQuery): string[] {
if (typeof rawPath !== 'string' || !rawPath.trim()) return [];
const cleaned = rawPath.trim();
const bases = normalizeRoots([cwd, ...roots]);
const candidates: string[] = [];
if (path.isAbsolute(cleaned)) candidates.push(path.resolve(cleaned));
for (const base of bases) candidates.push(path.resolve(base, `.${path.sep}${cleaned}`));
return [...new Set(candidates)];
}
// Resolves to an existing file that lives inside one of the session's own roots. Transcript
// text is untrusted input, so containment is checked after realpath — a symlink pointing out
// of the project must not widen what the app will open.
function resolveFileReference(query: FileReferenceQuery): string | null {
const roots = normalizeRoots([query.cwd, ...(query.roots || [])]);
if (!roots.length) return null;
for (const candidate of fileReferenceCandidates(query)) {
if (!isWithinRoots(candidate, roots)) continue;
try {
const real = fs.realpathSync(candidate);
if (!isWithinRoots(real, roots)) continue;
if (fs.statSync(real).isFile()) return real;
} catch {}
}
return null;
}
function buildEditorUrl({ scheme, filePath, line, column }: EditorUrlOptions): string {
const resolved = EDITOR_SCHEMES[String(scheme || '')] || DEFAULT_EDITOR_SCHEME;
let target = `${resolved}://file${encodeURI(filePath)}`;
const lineNumber = Number(line);
if (Number.isInteger(lineNumber) && lineNumber > 0) {
target += `:${lineNumber}`;
const columnNumber = Number(column);
if (Number.isInteger(columnNumber) && columnNumber > 0) target += `:${columnNumber}`;
}
return target;
}
export {
DEFAULT_EDITOR_SCHEME,
EDITOR_SCHEMES,
buildEditorUrl,
fileReferenceCandidates,
isWithinRoots,
normalizeRoots,
resolveFileReference,
};
+78
View File
@@ -9,6 +9,7 @@ import { writeHeartbeat } from './indexer.ts';
import { createIndexerService } from './indexer-service.ts';
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
import { buildRecapExportQuery } from './recap-capture-query.ts';
import { buildEditorUrl, DEFAULT_EDITOR_SCHEME, EDITOR_SCHEMES, resolveFileReference } from './file-reference.ts';
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
@@ -298,6 +299,30 @@ async function stopBackgroundResources({ stopWorker = false } = {}) {
closeDb();
}
function safeProtocol(url: string): string {
try { return new URL(url).protocol; } catch { return ''; }
}
function isSameOrigin(url: string, currentUrl: string): boolean {
try {
return new URL(url).origin === new URL(currentUrl).origin;
} catch {
return false;
}
}
// Reloads and in-app routing keep the same origin and pathname; anything else is a real
// navigation away from the renderer document.
function isSameDocumentNavigation(url: string, currentUrl: string): boolean {
try {
const target = new URL(url);
const current = new URL(currentUrl);
return target.origin === current.origin && target.pathname === current.pathname;
} catch {
return false;
}
}
function createWindow() {
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
const shouldOpenDevTools = process.argv.includes('--devtools');
@@ -325,6 +350,25 @@ function createWindow() {
}
});
// Keep the SPA in place: a Markdown link in a transcript must never replace the running
// renderer. External http(s) targets are handed to the system browser instead.
// Only genuinely external targets go to the browser. A same-origin URL is a local document
// reference — in dev that would hand the system browser a Vite-served source file.
const releaseNavigation = (url: string) => {
if (!/^https?:$/i.test(safeProtocol(url))) return;
if (isSameOrigin(url, win.webContents.getURL())) return;
shell.openExternal(url).catch(() => {});
};
win.webContents.setWindowOpenHandler(({ url }) => {
releaseNavigation(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
if (isSameDocumentNavigation(url, win.webContents.getURL())) return;
event.preventDefault();
releaseNavigation(url);
});
if (isDev) {
win.loadURL(process.env.ELECTRON_RENDERER_URL || process.env.OBELISK_DEV_SERVER_URL || 'http://localhost:5173');
if (shouldOpenDevTools) {
@@ -589,6 +633,39 @@ ipcMain.handle('db:readMemoryFile', (_, filePath) => {
} catch { return null; }
});
// Every root this session actually worked in. A reference is only opened if it lands inside
// one of them, so untrusted transcript text cannot reach a file outside the session's own
// projects. Scoping is deliberately per session, not corpus-wide.
function querySessionFileRoots(sessionId: unknown): string[] {
if (!db || typeof sessionId !== 'string' || !sessionId) return [];
const roots: string[] = [];
try {
const rows = db.prepare(
`SELECT DISTINCT cwd FROM messages WHERE session_id = ? AND cwd IS NOT NULL AND cwd != ''`
).all(sessionId);
for (const row of rows) roots.push(row.cwd);
const session = db.prepare(`SELECT project_path FROM sessions WHERE id = ?`).get(sessionId);
if (session?.project_path) roots.push(session.project_path);
} catch {}
return roots;
}
ipcMain.handle('file-ref:open', async (_, ref) => {
const { sessionId, path: rawPath, cwd, line, column } = ref || {};
const roots = querySessionFileRoots(sessionId);
// The renderer-supplied cwd only counts if this session actually recorded it.
const scopedCwd = typeof cwd === 'string' && roots.includes(cwd) ? cwd : null;
const filePath = resolveFileReference({ rawPath, cwd: scopedCwd, roots });
if (!filePath) return { opened: false };
const { editorScheme } = loadPersistedSettings();
try {
await shell.openExternal(buildEditorUrl({ scheme: editorScheme, filePath, line, column }));
return { opened: true, path: filePath };
} catch {
return { opened: false, path: filePath };
}
});
ipcMain.handle('db:archiveMemory', (_, id, reason) => {
return runAppDbWrite(() => {
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
@@ -814,6 +891,7 @@ ipcMain.handle('settings:get', () => {
dbPath: dbFile,
recapDir,
autoRefresh: persisted.autoRefresh !== false,
editorScheme: persisted.editorScheme || DEFAULT_EDITOR_SCHEME,
sources,
memoryCount,
sessionCount,