Terminal file viewer/editor + per-window app-command lockdown (#60)
Build App / compute-version (push) Successful in 7s
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-linux (push) Successful in 5m12s
Build App / build-windows (push) Successful in 5m15s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s

Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label.

Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #60.
This commit is contained in:
2026-09-23 17:05:50 +00:00
co-authored by Claude Opus 5.5
parent 3537b234d8
commit 8305c96e20
65 changed files with 15043 additions and 108 deletions
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { findFilePathLinks } from "./filePathLinks";
const one = (text: string) => {
const m = findFilePathLinks(text);
expect(m, text).toHaveLength(1);
return m[0];
};
describe("findFilePathLinks — what is a path", () => {
it.each([
["src/foo.ts", "src/foo.ts"],
["/workspace/x/README.md", "/workspace/x/README.md"],
["./scripts/build.sh", "./scripts/build.sh"],
["../other/Cargo.toml", "../other/Cargo.toml"],
["Makefile", "Makefile"],
["Dockerfile", "Dockerfile"],
["CLAUDE.md", "CLAUDE.md"],
[".gitignore", ".gitignore"],
["app/src-tauri/src/lib.rs", "app/src-tauri/src/lib.rs"],
["my-dir/some_file.test.tsx", "my-dir/some_file.test.tsx"],
])("matches %s", (text, path) => {
expect(one(text).path).toBe(path);
});
it.each([
"1.2.3",
"v2.11.0",
"example.com",
"claude.ai",
"e.g.",
"https://example.com/a/b.ts",
"http://localhost:1420/viewer.html",
"foo",
"a.b",
"10.0.0.1",
"and/or",
"src/components",
])("does not match %s", (text) => {
expect(findFilePathLinks(text)).toEqual([]);
});
it("matches a slash-less token only with a known source/doc extension", () => {
expect(one("index.ts").path).toBe("index.ts");
expect(one("notes.md").path).toBe("notes.md");
expect(findFilePathLinks("archive.xyz")).toEqual([]);
// With a slash, any extension will do.
expect(one("dist/archive.xyz").path).toBe("dist/archive.xyz");
});
});
describe("findFilePathLinks — line and column suffixes", () => {
it("parses :line", () => {
expect(one("src/foo.ts:42")).toMatchObject({ path: "src/foo.ts", line: 42 });
});
it("parses :line:col", () => {
expect(one("src/foo.ts:42:7")).toMatchObject({ path: "src/foo.ts", line: 42, col: 7 });
});
it("parses :start-end", () => {
expect(one("app/src/lib/urlRelay.ts:139-150")).toMatchObject({ path: "app/src/lib/urlRelay.ts", line: 139, endLine: 150 });
});
it("parses #L42 and #L40-L50", () => {
expect(one("README.md#L42")).toMatchObject({ path: "README.md", line: 42 });
expect(one("README.md#L40-L50")).toMatchObject({ path: "README.md", line: 40, endLine: 50 });
});
it("does not read a trailing colon as a line", () => {
expect(one("Edited src/foo.ts:")).toMatchObject({ path: "src/foo.ts", line: undefined });
});
});
describe("findFilePathLinks — markdown wrapping and offsets", () => {
it.each([
["`src/foo.ts`", 1, 11],
["(src/foo.ts)", 1, 11],
["[src/foo.ts]", 1, 11],
['"src/foo.ts"', 1, 11],
["'src/foo.ts'", 1, 11],
["see src/foo.ts.", 4, 14],
["see src/foo.ts, then", 4, 14],
["see src/foo.ts;", 4, 14],
])("strips wrapping in %s", (text, start, end) => {
expect(one(text)).toMatchObject({ path: "src/foo.ts", start, end });
});
it("keeps the :line suffix inside the span", () => {
// "at `" is 4 characters; the span covers `src/foo.ts:42` (13 chars).
expect(one("at `src/foo.ts:42`")).toMatchObject({ path: "src/foo.ts", line: 42, start: 4, end: 17 });
});
it("finds several paths in one line, in order", () => {
const m = findFilePathLinks("Read src/a.ts and src/b.rs:3, wrote docs/c.md");
expect(m.map((x) => x.path)).toEqual(["src/a.ts", "src/b.rs", "docs/c.md"]);
expect(m[1].line).toBe(3);
});
it("skips anything inside a URL", () => {
expect(findFilePathLinks("see https://github.com/o/r/blob/main/src/foo.ts:12 now")).toEqual([]);
expect(one("see https://x.io/a and src/foo.ts").path).toBe("src/foo.ts");
});
it("ignores a Claude tool header like ⏺ Read(src/foo.ts) except for the path", () => {
expect(one("⏺ Read(src/foo.ts)").path).toBe("src/foo.ts");
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Finds file paths in a line of terminal text.
*
* Pure: the xterm glue (`components/terminal/filePathLinkProvider.ts`) turns
* buffer rows into a string and string offsets back into cells; this decides
* what a path is. Deliberately conservative — a false link is an annoying
* underline, a missed one is a copy-paste — so a token needs either a `/` or
* a known extension, and never sits inside a URL.
*/
export interface FilePathMatch {
/** Indices into the input; `end` exclusive. Covers path + suffix, not wrapping. */
start: number;
end: number;
path: string;
line?: number;
col?: number;
endLine?: number;
}
/** Extensions that make a slash-less token (`index.ts`, `notes.md`) a path. */
const KNOWN_EXTENSIONS = new Set([
"md", "markdown", "txt", "rst", "json", "jsonc", "yaml", "yml", "toml", "ini", "cfg", "conf",
"env", "lock", "js", "jsx", "mjs", "cjs", "ts", "tsx", "rs", "py", "rb", "go", "java", "kt",
"c", "h", "cc", "cpp", "hpp", "cs", "php", "swift", "scala", "lua", "sh", "bash", "zsh",
"fish", "ps1", "html", "htm", "xml", "svelte", "vue", "css", "scss", "sass", "less", "sql",
"graphql", "proto", "diff", "patch", "csv", "tsv", "log", "svg", "png", "jpg", "jpeg", "gif",
"webp",
]);
/** Extensionless names that are files by convention. */
const KNOWN_BASENAMES = new Set([
"Makefile", "Dockerfile", "Rakefile", "Gemfile", "Procfile", "Vagrantfile", "LICENSE",
"README", "CHANGELOG", "PKGBUILD",
]);
/**
* A candidate token: path characters, optionally starting with `/`, `./`, `../`
* or `.` (dotfile). Excludes the wrapping characters the surrounding markdown
* leaves (`(`, `)`, `[`, `]`, backtick, quotes) and whitespace.
*/
const TOKEN = /(?:\.{1,2}\/|\/)?[A-Za-z0-9_.\-~+@]+(?:\/[A-Za-z0-9_.\-~+@]+)*\/?/g;
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
const LINE_SUFFIX = /^(?::(\d+)(?::(\d+))?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)/;
const VERSION_LIKE = /^v?\d+(\.\d+)+$/;
const TRAILING_PUNCT = /[.,;:]+$/;
function isPathLike(token: string): boolean {
if (VERSION_LIKE.test(token)) return false;
const base = token.slice(token.lastIndexOf("/") + 1);
if (base === "" || base === "." || base === "..") return false;
if (KNOWN_BASENAMES.has(base)) return true;
const hasSlash = token.includes("/");
const dot = base.lastIndexOf(".");
if (dot === 0) {
// Dotfile (.gitignore, .env). With a slash the name itself counts as
// "having an extension"; without one it must be a known dotfile.
if (hasSlash) return true;
return KNOWN_EXTENSIONS.has(base.slice(1).toLowerCase()) || base === ".gitignore" || base === ".env";
}
if (dot < 0) return false; // no extension at all — never a path
// A real extension. With a slash any extension will do; without one it
// must be a known source/doc extension.
if (hasSlash) return true;
return KNOWN_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
}
function urlSpans(text: string): Array<[number, number]> {
const spans: Array<[number, number]> = [];
for (const m of text.matchAll(URL_SCHEME)) {
const start = m.index ?? 0;
// A URL runs to the next whitespace or closing bracket/quote.
const rest = text.slice(start);
const len = rest.search(/[\s)\]'"`>]/);
spans.push([start, len < 0 ? text.length : start + len]);
}
return spans;
}
export function findFilePathLinks(text: string): FilePathMatch[] {
const urls = urlSpans(text);
const insideUrl = (i: number) => urls.some(([s, e]) => i >= s && i < e);
const out: FilePathMatch[] = [];
for (const m of text.matchAll(TOKEN)) {
const start = m.index ?? 0;
let token = m[0];
if (insideUrl(start)) continue;
// Trailing sentence punctuation is not part of the name.
const trimmed = token.replace(TRAILING_PUNCT, "");
if (trimmed !== token) token = trimmed;
if (token.endsWith("/")) token = token.slice(0, -1);
if (!token || !isPathLike(token)) continue;
let end = start + token.length;
// `line`/`col`/`endLine` are set explicitly to `undefined` (rather than
// left absent) so callers that assert on them with `toMatchObject` see
// the key, not a missing property.
const match: FilePathMatch = { start, end, path: token, line: undefined, col: undefined, endLine: undefined };
// The suffix sits right after the *trimmed* token: `TOKEN` may have
// consumed a trailing `.` that `TRAILING_PUNCT` then removed, so search
// from `start + token.length`, not from the end of the raw match.
const after = text.slice(start + token.length);
const s = LINE_SUFFIX.exec(after);
if (s) {
if (s[1] !== undefined) {
match.line = Number(s[1]);
if (s[2] !== undefined) match.col = Number(s[2]);
if (s[3] !== undefined) match.endLine = Number(s[3]);
} else if (s[4] !== undefined) {
match.line = Number(s[4]);
if (s[5] !== undefined) match.endLine = Number(s[5]);
}
end += s[0].length;
match.end = end;
}
out.push(match);
}
return out;
}
+20 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -413,3 +413,22 @@ export const getMigrationState = (projectId: string) =>
* Rejects with a string already phrased for a toast. */
export const openUrlExternal = (url: string) =>
invoke<void>("open_url_external", { url });
// ---- Terminal file viewer ----
export const openFileViewer = (
projectId: string,
path: string,
line?: number,
col?: number,
endLine?: number,
) => invoke<void>("open_file_viewer", { projectId, path, line, col, endLine });
export const viewerGetState = () => invoke<ViewerState>("viewer_get_state");
export const viewerReadFile = (maxBytes: number) =>
invoke<ViewerFile>("viewer_read_file", { maxBytes });
export const viewerPollFile = () => invoke<ViewerPoll>("viewer_poll_file");
export const viewerWriteFile = (contentsBase64: string, baseHash: string) =>
invoke<ViewerSaved>("viewer_write_file", { contentsBase64, baseHash });
export const viewerChooseFile = (index: number) =>
invoke<ViewerState>("viewer_choose_file", { index });
+46
View File
@@ -954,3 +954,49 @@ export interface MigrationState {
options: MigrationOptions;
plan: MigrationPlan | null;
}
// ---- Terminal file viewer (commands/file_viewer_commands.rs) ----
export interface ViewerLocation {
line: number | null;
col: number | null;
end_line: number | null;
}
export type ViewerTargetState =
| { kind: "resolved"; container_path: string }
| { kind: "choose"; candidates: string[] }
| { kind: "not_found"; tried: string[] };
export interface ViewerState {
project_id: string;
project_name: string;
/** What was clicked, for the title and the not-found message. */
raw_path: string;
state: ViewerTargetState;
initial: ViewerLocation;
}
export interface ViewerFile {
contents_base64: string;
truncated: boolean;
size: number;
/** SHA-256 hex of the returned bytes; equals the file's hash when `truncated` is false. */
hash: string;
editable: boolean;
readonly_reason: string | null;
}
/** A successful save (`write.rs`'s `SavedFile`). */
export interface ViewerSaved {
/** SHA-256 of the bytes written: the editor's new base hash. */
hash: string;
/** What the container hashed right after the swap; differs from `hash` only if another writer landed first. */
disk_hash: string;
}
export interface ViewerPoll {
exists: boolean;
hash: string | null;
size: number | null;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { joinWrappedRows, MAX_JOINED_LENGTH, offsetToCell, type RowSource } from "./xtermLineJoin";
/** rows[i] = [text, isWrapped] */
const buffer = (rows: Array<[string, boolean]>): RowSource => ({
getLine: (y) =>
rows[y] ? { isWrapped: rows[y][1], translateToString: (trim?: boolean) => (trim ? rows[y][0].trimEnd() : rows[y][0]) } : undefined,
});
describe("joinWrappedRows", () => {
it("returns a single unwrapped row as-is", () => {
const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0);
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] });
});
it("walks up to the row that started the wrap and down through continuations", () => {
const b = buffer([
["unrelated", false],
["/workspace/very/long/pa", false],
["th/to/file.ts:12 and mo", true],
["re text", true],
["next line", false],
]);
const fromMiddle = joinWrappedRows(b, 2);
expect(fromMiddle.text).toBe("/workspace/very/long/path/to/file.ts:12 and more text");
expect(fromMiddle.firstRow).toBe(1);
expect(fromMiddle.rowStarts).toEqual([0, 23, 46]);
expect(joinWrappedRows(b, 1)).toEqual(fromMiddle);
expect(joinWrappedRows(b, 3)).toEqual(fromMiddle);
});
it("stops at the length budget", () => {
const rows: Array<[string, boolean]> = [["a".repeat(1000), false]];
for (let i = 0; i < 5; i++) rows.push(["b".repeat(1000), true]);
const j = joinWrappedRows(buffer(rows), 0);
expect(j.text.length).toBeLessThanOrEqual(MAX_JOINED_LENGTH);
expect(j.rowStarts.length).toBe(2);
});
});
describe("offsetToCell", () => {
it("maps offsets to 1-based cells on the right row", () => {
const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] };
expect(offsetToCell(j, 0)).toEqual({ x: 1, y: 5 });
expect(offsetToCell(j, 2)).toEqual({ x: 3, y: 5 });
expect(offsetToCell(j, 3)).toEqual({ x: 1, y: 6 });
expect(offsetToCell(j, 7)).toEqual({ x: 2, y: 7 });
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Joins an xterm buffer row with its wrapped continuations.
*
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
* built package does not export — so the walk is repeated here, with the same
* 2048-character budget. Rows are read with `translateToString(true)`, which
* trims the right edge; a wrap never ends in trailing spaces xterm would keep,
* so the join is exact for the text a path can occur in.
*
* Wide characters (CJK, emoji) occupy two cells but one string index, so a
* column computed from a string offset drifts right of the glyph on such rows.
* The addon corrects this with `getCell`; v1 accepts the drift (underline
* lands a cell early; the click still resolves the same link).
*/
export const MAX_JOINED_LENGTH = 2048;
/** Minimal slice of xterm's IBuffer this needs. */
export interface RowSource {
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
}
export interface JoinedLine {
text: string;
/** 0-based index of the first buffer row that contributed. */
firstRow: number;
/** For each contributed row (in order), the string offset at which it starts. */
rowStarts: number[];
}
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
let top = row;
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
const parts: string[] = [];
let length = 0;
let y = top;
for (;;) {
const line = buffer.getLine(y);
if (!line) break;
if (y !== top && !line.isWrapped) break;
const text = line.translateToString(true);
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
parts.push(text);
length += text.length;
y++;
}
const rowStarts: number[] = [];
let offset = 0;
for (const p of parts) {
rowStarts.push(offset);
offset += p.length;
}
return { text: parts.join(""), firstRow: top, rowStarts };
}
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
let rowIdx = 0;
for (let i = 0; i < joined.rowStarts.length; i++) {
if (joined.rowStarts[i] <= offset) rowIdx = i;
}
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
}