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
@@ -3,6 +3,7 @@ import {
IMAGE_PREVIEW_LIMIT,
TEXT_PREVIEW_LIMIT,
decodeBase64,
encodeBase64,
extensionOf,
imageMimeFor,
looksBinary,
@@ -76,3 +77,22 @@ describe("decodeBase64 / looksBinary", () => {
expect(looksBinary(bytes)).toBe(false);
});
});
describe("encodeBase64", () => {
it("matches btoa on a small input", () => {
expect(encodeBase64(new Uint8Array([0xff, 0xd8, 0x00, 0x41]))).toBe(btoa("\xff\xd8\x00\x41"));
});
it("round-trips 1 MiB without overflowing the call stack", () => {
// Spreading a 1 MiB array into String.fromCharCode throws RangeError in V8.
const bytes = new Uint8Array(TEXT_PREVIEW_LIMIT);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31 + 7) & 0xff;
const back = decodeBase64(encodeBase64(bytes));
expect(back.length).toBe(bytes.length);
expect(back.every((b, i) => b === bytes[i])).toBe(true);
});
it("encodes an empty input as the empty string", () => {
expect(encodeBase64(new Uint8Array(0))).toBe("");
});
});
@@ -96,6 +96,19 @@ export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
return bytes;
}
/**
* Bytes → base64. Built 32 KiB at a time: spreading a whole buffer into
* `String.fromCharCode` overflows the argument limit well below 1 MiB.
*/
export function encodeBase64(bytes: Uint8Array): string {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/**
* The classic heuristic: a NUL byte early on means this is not text. Cheap,
* and it is what `git` and `grep` use to decide the same question.
@@ -5,10 +5,12 @@ import TerminalView, {
createOsc8LinkHandler,
supersedes,
} from "./TerminalView";
import { Terminal } from "@xterm/xterm";
import { useAppState } from "../../store/appState";
import {
uploadHostFileToTerminal,
openUrlExternal,
openFileViewer,
} from "../../lib/tauri-commands";
import {
chooseSignInTarget,
@@ -123,6 +125,7 @@ vi.mock("../../lib/tauri-commands", () => ({
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
openUrlExternal: vi.fn(async () => {}),
openFileViewer: vi.fn(async () => {}),
}));
vi.mock("@tauri-apps/api/event", () => ({
@@ -1606,6 +1609,78 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
expect(hoverCard()).toBeNull();
});
it("routes a file: target to the viewer and never to the opener", () => {
const onOpenFile = vi.fn();
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
h.activate(click(), "file:///workspace/p/src/a.ts", range);
expect(onOpenFile).toHaveBeenCalledWith("/workspace/p/src/a.ts");
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("with non-http targets now delivered, still refuses javascript: and garbage", () => {
const onOpenFile = vi.fn();
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
h.activate(click(), "javascript:alert(1)", range);
h.activate(click(), "not a url", range);
h.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
expect(onOpenFile).not.toHaveBeenCalled();
expect(openUrlExternal).not.toHaveBeenCalled();
// The refusal card (ruling (a)): present, no origin, never the target,
// never an offer to open it.
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
expect(card!.textContent).toContain("will not be opened");
expect(card!.textContent).not.toContain("javascript:");
expect(card!.textContent).not.toContain("Open in viewer");
});
it("refuses a file: target whose escapes do not decode", () => {
const onOpenFile = vi.fn();
const h = createOsc8LinkHandler(() => host, () => state, onOpenFile);
h.activate(click(), "file:///workspace/%E0%A4%A", range);
h.hover?.(new MouseEvent("mousemove"), "file:///workspace/%E0%A4%A", range);
expect(onOpenFile).not.toHaveBeenCalled();
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
expect(card!.textContent).toContain("will not be opened");
expect(card!.textContent).not.toContain("Open in viewer");
});
it("the file: hover card names the viewer and the path", () => {
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
h.hover?.(new MouseEvent("mousemove"), "file:///workspace/p/README.md", range);
expect(hoverCard()?.textContent).toContain("Open in viewer");
expect(hoverCard()?.textContent).toContain("/workspace/p/README.md");
});
it("shows a relative path's card with the path as printed (preflight P6)", () => {
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
h.showFileCard("src/foo.ts");
expect(hoverCard()?.textContent).toContain("Open in viewer");
expect(hoverCard()?.textContent).toContain("src/foo.ts");
h.dismiss();
expect(hoverCard()).toBeNull();
});
it("holds a file-path click to the modifier its card promised", () => {
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
state.mouseTracking = true;
h.showFileCard("src/foo.ts");
expect(hoverCard()?.textContent).toContain("Shift+click");
state.mouseTracking = false;
expect(h.opensFileLink(click())).toBe(false);
expect(h.opensFileLink(click({ shiftKey: true }))).toBe(true);
// A card drawn with nothing tracking promises nothing.
h.showFileCard("src/foo.ts");
expect(h.opensFileLink(click())).toBe(true);
});
it("declares allowNonHttpProtocols so file: targets reach it", () => {
expect(createOsc8LinkHandler(() => host, () => state).allowNonHttpProtocols).toBe(true);
});
it("pushes the shared toast when the host opener fails", async () => {
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
@@ -1656,6 +1731,124 @@ describe("the link handler is wired into the terminal, and reads its live mode",
expect(typeof handler.hover).toBe("function");
});
it("opens a file: target in the viewer against the session's project", async () => {
vi.mocked(openFileViewer).mockClear();
mountSession("bash");
await act(async () => {
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"file:///workspace/api/src/a%20b.ts",
range,
);
await Promise.resolve();
});
expect(openFileViewer).toHaveBeenCalledWith(
"p1", "/workspace/api/src/a b.ts", undefined, undefined, undefined,
);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("registers the file-path provider, which opens the viewer at the matched line", async () => {
vi.mocked(openFileViewer).mockClear();
const register = vi.spyOn(Terminal.prototype, "registerLinkProvider");
try {
mountSession("bash");
const provider = register.mock.calls.at(-1)?.[0];
if (!provider) throw new Error("no link provider was registered");
await write("Edited src/foo.ts:42 today");
const links = vi.fn();
provider.provideLinks(1, links);
const [link] = links.mock.calls[0][0];
expect(link.text).toBe("src/foo.ts:42");
await act(async () => {
link.activate(new MouseEvent("click", { button: 0, detail: 1 }), link.text);
await Promise.resolve();
});
expect(openFileViewer).toHaveBeenCalledWith("p1", "src/foo.ts", 42, undefined, undefined);
} finally {
register.mockRestore();
}
});
it("says so in a toast when the viewer refuses to open", async () => {
vi.mocked(openFileViewer).mockRejectedValueOnce(new Error("No such file: x.ts"));
mountSession("bash");
await act(async () => {
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"file:///x.ts",
range,
);
await Promise.resolve();
await Promise.resolve();
});
const toasts = useAppState.getState().toasts;
expect(toasts.at(-1)?.detail).toContain("No such file: x.ts");
expect(toasts.at(-1)?.dedupeKey).toBe("file-viewer-open");
});
it("holds a plain-text file link to the modifier its hover card promised", async () => {
vi.mocked(openFileViewer).mockClear();
const platform = navigator.platform;
Object.defineProperty(navigator, "platform", { value: "Linux x86_64", configurable: true });
const register = vi.spyOn(Terminal.prototype, "registerLinkProvider");
try {
mountSession("claude");
const provider = register.mock.calls.at(-1)?.[0];
if (!provider) throw new Error("no link provider was registered");
await write("Edited src/foo.ts:42 today");
const links = vi.fn();
provider.provideLinks(1, links);
const [link] = links.mock.calls[0][0];
await write("\x1b[?1002h");
link.hover?.(new MouseEvent("mousemove"), link.text);
expect(document.body.textContent).toContain("Shift+click to open");
await write("\x1b[?1002l");
await act(async () => {
link.activate(new MouseEvent("click", { button: 0, detail: 1 }), link.text);
await Promise.resolve();
});
expect(openFileViewer).not.toHaveBeenCalled();
await act(async () => {
link.activate(
new MouseEvent("click", { button: 0, detail: 1, shiftKey: true }),
link.text,
);
await Promise.resolve();
});
expect(openFileViewer).toHaveBeenCalledWith("p1", "src/foo.ts", 42, undefined, undefined);
} finally {
register.mockRestore();
Object.defineProperty(navigator, "platform", { value: platform, configurable: true });
}
});
it("says the project is not ready rather than doing nothing", async () => {
vi.mocked(openFileViewer).mockClear();
useAppState.setState({ sessions: [] });
render(<TerminalView sessionId="s-unknown" active />);
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"file:///workspace/api/README.md",
range,
);
expect(openFileViewer).not.toHaveBeenCalled();
const toasts = useAppState.getState().toasts;
expect(toasts.at(-1)?.detail).toContain("not ready yet");
expect(toasts.at(-1)?.dedupeKey).toBe("file-viewer-open");
});
// The gate has to ask the terminal, not a boolean captured at construction:
// the mode changes whenever the container prints a DECSET, which is several
// times a second in Claude Code.
@@ -1695,10 +1888,9 @@ describe("the link handler is wired into the terminal, and reads its live mode",
*
* `WebLinksAddon` matches rendered text and activates through the same
* `Linkifier._handleMouseUp`, with the same absence of any check. It also
* picks up links the OSC 8 handler never sees: `OscLinkProvider` drops a
* non-http(s) hyperlink target before `linkHandler` is reached, which leaves
* the addon free to match the *label* — so an OSC 8 with a `javascript:`
* target and an `https://evil.tld/x` label arrives here and nowhere else.
* covers the plain-text URLs that carry no OSC 8 parameter at all. (Since the
* file viewer, `allowNonHttpProtocols` is on, so every OSC 8 target reaches
* the OSC 8 handler, which refuses anything but `file:` and `http(s):`.)
* Both routes end at `openUrlExternal`, so both ask the same question first.
*/
describe("the plain-text URL path is gated the same way", () => {
+213 -40
View File
@@ -9,6 +9,7 @@ import { useAppState } from "../../store/appState";
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import {
awsSsoRefresh,
openFileViewer,
openPageInContainerBrowser,
openUrlExternal,
uploadHostFileToTerminal,
@@ -33,6 +34,8 @@ import UrlToast, {
import { trimSelection } from "./trimSelection";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import TerminalContextMenu from "./TerminalContextMenu";
import { createFilePathLinkProvider } from "./filePathLinkProvider";
import type { FilePathMatch } from "../../lib/filePathLinks";
interface Props {
sessionId: string;
@@ -137,6 +140,40 @@ function reportOpenFailure(e: unknown) {
});
}
/**
* Open a path in the file viewer for this terminal's project, or say why not.
* The session record (and so the project) can arrive after the first render;
* a click in that window gets a toast rather than silently doing nothing.
*/
function openInViewer(
projectId: string | undefined,
path: string,
line?: number,
col?: number,
endLine?: number,
): void {
if (!projectId) {
useAppState.getState().pushToast({
kind: "error",
message: "Could not open the file",
detail: "This terminal's project is not ready yet",
dedupeKey: "file-viewer-open",
});
return;
}
openFileViewer(projectId, path, line, col, endLine).catch(reportViewerFailure);
}
/** Report a file-viewer open that the backend refused (not found, no container, cap). */
function reportViewerFailure(e: unknown): void {
useAppState.getState().pushToast({
kind: "error",
message: "Could not open the file",
detail: e instanceof Error ? e.message : String(e),
dedupeKey: "file-viewer-open",
});
}
/**
* `ILinkHandler`, plus the one thing xterm never asks for.
*
@@ -146,7 +183,50 @@ function reportOpenFailure(e: unknown) {
* pane and is still there when the user comes back. {@link dismiss} is how the
* view says "this pane is gone" without pretending to be a mouse event.
*/
export type Osc8LinkHandler = ILinkHandler & { dismiss(): void };
export type Osc8LinkHandler = ILinkHandler & {
dismiss(): void;
/**
* Draw the "Open in viewer" card for a path matched in plain text by the
* file-path link provider. Takes the raw path (a relative path stays
* relative — `file://src/x` would parse `src` as a host). Records the
* card's modifier promise exactly as `hover` does, so
* {@link opensFileLink} can hold the click to it.
*/
showFileCard(path: string): void;
/**
* The file-path provider's click gate: {@link opensOnClick} with whatever
* the card on screen promised. There is only ever one card, and `clear()`
* resets the promise with it, so sharing the flag with OSC 8 links is exact.
*/
opensFileLink(event: MouseEvent): boolean;
};
/**
* What an OSC 8 target is, now that `allowNonHttpProtocols` delivers every
* scheme here. `null` is anything this pane refuses: unparseable, a scheme
* other than `file:`/`http(s):`, or a `file:` path whose escapes do not decode.
* The host of a `file:` URL is ignored — `ls --hyperlink` writes the machine's
* hostname there, and the viewer only ever reads the container.
*/
function classifyOsc8Target(
text: string,
): { kind: "file"; path: string } | { kind: "web" } | null {
let parsed: URL;
try {
parsed = new URL(text);
} catch {
return null;
}
if (parsed.protocol === "file:") {
try {
return { kind: "file", path: decodeURIComponent(parsed.pathname) };
} catch {
return null;
}
}
if (parsed.protocol === "http:" || parsed.protocol === "https:") return { kind: "web" };
return null;
}
/**
* Is a program holding the mouse?
@@ -397,10 +477,14 @@ function opensOnClick(
* @param readState samples {@link ClickContext} — a getter for the same
* reason, and the *only* reason: every one of those answers changes
* under us, between the hover and the click that follows it.
* @param onOpenFile receives the decoded path of a `file:` target, which
* opens in the file viewer rather than the host browser. Without it a
* `file:` target is inert.
*/
export function createOsc8LinkHandler(
getHost: () => HTMLElement | null,
readState: () => ClickContext,
onOpenFile?: (path: string) => void,
): Osc8LinkHandler {
let card: HTMLDivElement | null = null;
/**
@@ -428,6 +512,64 @@ export function createOsc8LinkHandler(
return el;
};
const makeCard = (): HTMLDivElement => {
const el = document.createElement("div");
el.className = OSC8_HOVER_CLASS;
el.dataset.testid = "osc8-hover";
Object.assign(el.style, {
position: "absolute",
left: "8px",
bottom: "8px",
maxWidth: "calc(100% - 16px)",
boxSizing: "border-box",
zIndex: "30",
// The card lands under the pointer for a link in the bottom rows, and
// it is not a sibling the Linkifier hit-tests around (see
// `OSC8_HOVER_CLASS`). Without this, `screenElement` gets `mouseleave`
// the moment the card appears — card removed, pointer back on the
// link, card back: a flicker loop — and worse, the `mouseup` that
// activates the link lands on the card, so the link cannot be opened
// at all. Nothing here is interactive, so nothing is lost.
pointerEvents: "none",
display: "flex",
alignItems: "baseline",
gap: "6px",
padding: "3px 8px",
fontSize: "12px",
fontFamily: "monospace",
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
// The origin wraps, so the card grows downward rather than sideways;
// this is the backstop for anything that still cannot fit.
overflow: "hidden",
borderRadius: "6px",
boxShadow: "var(--shadow-overlay)",
color: "var(--text-primary)",
} as Partial<CSSStyleDeclaration>);
return el;
};
/** "Open in viewer", the path, and the same hint line as a web link. */
const fillFileCard = (el: HTMLDivElement, path: string, ctx: ClickContext) => {
el.appendChild(span("Open in viewer", { fontWeight: "700", flexShrink: "0" }));
const pathEl = span(path, {
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: "0",
});
pathEl.dataset.testid = "osc8-hover-path";
el.appendChild(pathEl);
el.appendChild(
span(openHintLabel(ctx), {
color: "var(--text-secondary)",
flexShrink: "0",
marginLeft: "4px",
}),
);
};
return {
activate(event, text) {
// Opening the host browser is the one thing in this pane the container
@@ -435,6 +577,16 @@ export function createOsc8LinkHandler(
// may provoke by accident. See `opensOnClick` — including the residual
// it does not close.
if (!opensOnClick(event, readState(), modifierPromised)) return;
const target = classifyOsc8Target(text);
if (!target) {
console.warn("Refusing to open a link with an unsupported or malformed target");
return;
}
if (target.kind === "file") {
if (!onOpenFile) return;
onOpenFile(target.path);
return;
}
// Same sink and same rule as the WebLinksAddon branch: this came off the
// container's output, so it is validated before it reaches the OS
// opener. One implementation — `sanitizeRelayUrl` — on purpose.
@@ -456,39 +608,16 @@ export function createOsc8LinkHandler(
// moved on by the time they click.
modifierPromised = ctx.mouseTracking;
card = document.createElement("div");
card.className = OSC8_HOVER_CLASS;
card.dataset.testid = "osc8-hover";
Object.assign(card.style, {
position: "absolute",
left: "8px",
bottom: "8px",
maxWidth: "calc(100% - 16px)",
boxSizing: "border-box",
zIndex: "30",
// The card lands under the pointer for a link in the bottom rows, and
// it is not a sibling the Linkifier hit-tests around (see
// `OSC8_HOVER_CLASS`). Without this, `screenElement` gets `mouseleave`
// the moment the card appears — card removed, pointer back on the
// link, card back: a flicker loop — and worse, the `mouseup` that
// activates the link lands on the card, so the link cannot be opened
// at all. Nothing here is interactive, so nothing is lost.
pointerEvents: "none",
display: "flex",
alignItems: "baseline",
gap: "6px",
padding: "3px 8px",
fontSize: "12px",
fontFamily: "monospace",
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
// The origin wraps, so the card grows downward rather than sideways;
// this is the backstop for anything that still cannot fit.
overflow: "hidden",
borderRadius: "6px",
boxShadow: "var(--shadow-overlay)",
color: "var(--text-primary)",
} as Partial<CSSStyleDeclaration>);
card = makeCard();
const target = classifyOsc8Target(text);
// Without a viewer to hand it to, a `file:` target falls through to the
// refusal card below rather than offering something the click won't do.
if (target?.kind === "file" && onOpenFile) {
fillFileCard(card, target.path, ctx);
host.appendChild(card);
return;
}
const safe = sanitizeRelayUrl(text);
const origin = safe && urlOrigin(safe);
@@ -549,6 +678,25 @@ export function createOsc8LinkHandler(
leave: clear,
dismiss: clear,
showFileCard(path) {
clear();
const host = getHost();
if (!host) return;
const ctx = readState();
// Same promise as `hover`: the card names a modifier, so the click is
// held to it even if the container drops tracking before it lands.
modifierPromised = ctx.mouseTracking;
card = makeCard();
fillFileCard(card, path, ctx);
host.appendChild(card);
},
opensFileLink(event) {
return opensOnClick(event, readState(), modifierPromised);
},
allowNonHttpProtocols: true,
};
}
@@ -573,6 +721,13 @@ export default function TerminalView({ sessionId, active }: Props) {
const projectId = useAppState(
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
);
// The file viewer opens against the session's project. Read through a ref
// because the link handlers are built in the mount effect, keyed on
// `sessionId` only, and the session record can arrive after the first render.
const projectIdRef = useRef<string | undefined>(projectId);
useEffect(() => {
projectIdRef.current = projectId;
}, [projectId]);
// Which program is on the other end of the PTY. Read through a ref because
// the key handler is registered once, in the mount effect keyed on
@@ -882,6 +1037,7 @@ export default function TerminalView({ sessionId, active }: Props) {
linkHandler: (osc8LinkHandlerRef.current = createOsc8LinkHandler(
() => term.element ?? null,
() => readClickContext(term),
(path) => openInViewer(projectIdRef.current, path),
)),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
theme: {
@@ -924,12 +1080,10 @@ export default function TerminalView({ sessionId, active }: Props) {
// got a browser open on a plain click while it held the mouse, and a
// double-click that merely selected a URL opened it.
//
// It is also the only gate on a real bypass of the OSC 8 one:
// `OscLinkProvider` drops a hyperlink whose target is not http(s)
// *before* `linkHandler` sees it (`allowNonHttpProtocols` is unset), so
// an OSC 8 carrying a `javascript:` target and an `https://evil.tld/x`
// label leaves the addon free to match the label. That click arrives
// here and nowhere else.
// `allowNonHttpProtocols` is now **on**, so `OscLinkProvider` hands every
// OSC 8 target to `createOsc8LinkHandler`, which parses it and refuses
// anything but `file:` (the file viewer) and `http(s):` itself. This
// branch remains the only handler for plain-text URLs.
//
// No `modifierPromised`: this path paints an underline rather than a
// card, so it promises the user nothing to be held to.
@@ -952,6 +1106,24 @@ export default function TerminalView({ sessionId, active }: Props) {
}, { urlRegex });
term.loadAddon(webLinksAddon);
// File paths in plain text open in the file viewer. Registered after the
// addon so URLs are claimed first; the matcher also refuses anything
// inside a `scheme://` span. The hover card is the OSC 8 handler's, fed the
// raw path, and the click gate honours the modifier that card promised.
const filePathLinks = term.registerLinkProvider(
createFilePathLinkProvider(
term,
(m: FilePathMatch) =>
openInViewer(projectIdRef.current, m.path, m.line, m.col, m.endLine),
// Held to what the card promised, like OSC 8 links (see `showFileCard`).
(event) => osc8LinkHandlerRef.current?.opensFileLink(event) ?? false,
{
show: (path) => osc8LinkHandlerRef.current?.showFileCard(path),
hide: () => osc8LinkHandlerRef.current?.dismiss(),
},
),
);
term.open(containerRef.current);
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
@@ -1235,6 +1407,7 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null;
filePathLinks.dispose();
term.dispose();
termRef.current = null;
osc8LinkHandlerRef.current = null;
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { createFilePathLinkProvider } from "./filePathLinkProvider";
const fakeTerm = (rows: Array<[string, boolean]>) => ({
buffer: {
active: {
getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] },
},
},
}) as unknown as Parameters<typeof createFilePathLinkProvider>[0];
describe("createFilePathLinkProvider", () => {
it("reports 1-based inclusive ranges and activates through the gate", () => {
const onOpen = vi.fn();
const gate = vi.fn(() => true);
const provider = createFilePathLinkProvider(fakeTerm([["Edited src/foo.ts:42 today", false]]), onOpen, gate);
const links = vi.fn();
provider.provideLinks(1, links);
const [list] = links.mock.calls[0];
expect(list).toHaveLength(1);
expect(list[0].range).toEqual({ start: { x: 8, y: 1 }, end: { x: 20, y: 1 } });
expect(list[0].text).toBe("src/foo.ts:42");
list[0].activate(new MouseEvent("click"), list[0].text);
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ path: "src/foo.ts", line: 42 }));
});
it("does nothing when the gate refuses", () => {
const onOpen = vi.fn();
const provider = createFilePathLinkProvider(fakeTerm([["src/foo.ts", false]]), onOpen, () => false);
const links = vi.fn();
provider.provideLinks(1, links);
links.mock.calls[0][0][0].activate(new MouseEvent("click"), "src/foo.ts");
expect(onOpen).not.toHaveBeenCalled();
});
it("spans a wrapped path across rows", () => {
const provider = createFilePathLinkProvider(fakeTerm([["see /workspace/p/", false], ["src/foo.ts:7", true]]), vi.fn(), () => true);
const links = vi.fn();
provider.provideLinks(2, links);
expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } });
});
it("answers undefined for a row with nothing", () => {
const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true);
const links = vi.fn();
provider.provideLinks(1, links);
expect(links).toHaveBeenCalledWith(undefined);
});
it("answers undefined for a row past the end of the buffer", () => {
const provider = createFilePathLinkProvider(fakeTerm([["src/foo.ts", false]]), vi.fn(), () => true);
const links = vi.fn();
provider.provideLinks(5, links);
expect(links).toHaveBeenCalledWith(undefined);
});
it("hands the hover the raw path, relative as printed (preflight P6)", () => {
const hover = { show: vi.fn(), hide: vi.fn() };
const provider = createFilePathLinkProvider(
fakeTerm([["Edited src/foo.ts:42 today", false]]), vi.fn(), () => true, hover,
);
const links = vi.fn();
provider.provideLinks(1, links);
const link = links.mock.calls[0][0][0];
link.hover(new MouseEvent("mousemove"), link.text);
expect(hover.show).toHaveBeenCalledWith("src/foo.ts");
link.leave(new MouseEvent("mouseout"), link.text);
expect(hover.hide).toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
/**
* xterm `ILinkProvider` for file paths in the buffer.
*
* Registered after `WebLinksAddon` so URLs are claimed first; `findFilePathLinks`
* also refuses anything inside a `scheme://` span, so the two never overlap.
* Ranges are 1-based on both axes with an *inclusive* end column (xterm's
* contract), and `provideLinks`' row is 1-based while `getLine` is 0-based.
*/
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
import { findFilePathLinks, type FilePathMatch } from "../../lib/filePathLinks";
import { joinWrappedRows, offsetToCell } from "../../lib/xtermLineJoin";
export interface FilePathHover {
/** `path` is the raw matched path — relative paths stay relative. */
show(path: string): void;
hide(): void;
}
export function createFilePathLinkProvider(
term: Pick<Terminal, "buffer">,
onOpen: (match: FilePathMatch) => void,
gate: (event: MouseEvent) => boolean,
hover?: FilePathHover,
): ILinkProvider {
return {
provideLinks(bufferLineNumber, callback) {
const row = bufferLineNumber - 1;
const line = term.buffer.active.getLine(row);
if (!line) return callback(undefined);
const joined = joinWrappedRows(term.buffer.active, row);
// `offsetToCell` has no row to map onto when nothing was joined.
if (joined.rowStarts.length === 0) return callback(undefined);
const matches = findFilePathLinks(joined.text);
if (matches.length === 0) return callback(undefined);
const links: ILink[] = matches
.map((m): ILink => ({
range: { start: offsetToCell(joined, m.start), end: offsetToCell(joined, m.end - 1) },
text: joined.text.slice(m.start, m.end),
decorations: { pointerCursor: true, underline: true },
activate: (event) => {
if (!gate(event)) return;
onOpen(m);
},
hover: () => hover?.show(m.path),
leave: () => hover?.hide(),
}))
// Only links that touch the row being asked about (xterm asks per row).
.filter((l) => l.range.start.y <= bufferLineNumber && l.range.end.y >= bufferLineNumber);
callback(links.length ? links : undefined);
},
};
}
+9
View File
@@ -33,6 +33,15 @@
/* Two radii only: controls and panels. */
--radius-control: 6px;
--radius-panel: 8px;
/* Syntax colours for the file viewer's editor (viewer/viewerTheme.ts). Same
GitHub-dark palette TerminalView.tsx already uses for ANSI, expressed as
tokens rather than hard-coded hex per the styling convention above. */
--syntax-keyword: #ff7b72;
--syntax-string: #a5d6ff;
--syntax-number: #79c0ff;
--syntax-function: #d2a8ff;
--syntax-type: #ffa657;
--syntax-property: #7ee787;
color-scheme: dark;
}
+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 };
}
+472
View File
@@ -0,0 +1,472 @@
import { describe, it, expect } from "vitest";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { dirname, join, relative, resolve, sep } from "path";
import { builtinModules } from "module";
import ts from "typescript";
/**
* The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only
* invoke the app commands its file grants; `build.rs` proves every command is granted in the
* file its name says it belongs to. This proves the other half: the code that *runs* in each
* window imports only wrappers that window is granted. A wrapper imported on the wrong side
* fails here, not with `Command … not allowed by ACL` in a release build.
*
* It works on imports rather than `invoke(` literals because the viewer never calls invoke:
* everything goes through `lib/tauri-commands.ts`, which is the only file allowed to import
* `@tauri-apps/api/core` (that rule is what makes this test complete).
*
* Every file is parsed with the TypeScript compiler (`ts.createSourceFile`), not scanned with
* regexes, so comments, strings, template substitutions and regex literals are the parser's
* problem rather than ours. Anything the walk below cannot account for — a computed `import()`,
* a path alias, a namespace of the wrappers handed around as a value — throws (fail-closed);
* nothing is ever skipped quietly.
*/
const srcDir = resolve(__dirname, "..");
const capDir = resolve(srcDir, "../src-tauri/capabilities");
const nodeModulesDir = resolve(srcDir, "../node_modules");
const WRAPPERS = resolve(srcDir, "lib/tauri-commands.ts");
const VIEWER_ENTRY = resolve(srcDir, "viewer/main.tsx");
const toPermission = (command: string) => `allow-${command.replace(/_/g, "-")}`;
function readCapability(file: string) {
const cap = JSON.parse(readFileSync(resolve(capDir, file), "utf-8")) as {
windows: string[];
permissions: (string | { identifier: string })[];
};
const ids = cap.permissions.map((p) => (typeof p === "string" ? p : p.identifier));
return {
windows: cap.windows,
bare: ids.filter((id) => !id.includes(":")).sort(),
prefixed: ids.filter((id) => id.includes(":")).sort(),
};
}
/** A code extension: anything Vite would run as a module rather than serve as an asset. */
const CODE_EXTENSION = /\.(mjs|js|mts|ts|jsx|tsx|cjs|cts)$/;
/** Every code file under src/, tests and src/test included. */
function codeFiles(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) codeFiles(path, out);
else if (CODE_EXTENSION.test(name)) out.push(path);
}
return out;
}
/** Code that ships in a window: not under src/test, not a `*.test.*`, not a declaration file. */
const isAppSource = (file: string) =>
!relative(srcDir, file).startsWith(`test${sep}`) && !/\.test\.[^./]+$/.test(file) && !/\.d\.[cm]?ts$/.test(file);
const rel = (file: string) => relative(srcDir, file);
function fail(file: string, node: ts.Node | undefined, message: string): never {
const where = node
? `:${node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line + 1}`
: "";
throw new Error(`${rel(file)}${where}: ${message}`);
}
function parse(file: string): ts.SourceFile {
const kind = /\.[jt]sx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
return ts.createSourceFile(file, readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true, kind);
}
/** Depth-first visit of every node (JSDoc is not a child, so comments never show up). */
function walk(node: ts.Node, visit: (n: ts.Node) => void) {
visit(node);
ts.forEachChild(node, (child) => walk(child, visit));
}
/** Specifiers that reach Tauri's raw `invoke`: `core` itself, and the package root, which
* re-exports it as `core`. Only `lib/tauri-commands.ts` may use either. */
const INVOKE_SPECIFIER = /^@tauri-apps\/api(\/(core|index)(\.[cm]?js)?)?\/?$/;
/** Vite 6's default `resolve.extensions`, in its order (vite.config.ts does not override it). */
const VITE_EXTENSIONS = [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"];
/** A bare npm package name (optionally scoped) followed by an optional subpath. */
const PACKAGE_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(\/.*)?$/i;
const isFile = (p: string) => existsSync(p) && statSync(p).isFile();
const isDir = (p: string) => existsSync(p) && statSync(p).isDirectory();
/**
* Vite 6's `tryCleanFsResolve` for a relative path, step for step, so the file analysed is the
* file Vite would load: the exact path if it is a file; else a `.js`/`.mjs`/`.cjs`/`.jsx` path's
* TypeScript twin (`.js` → `.ts`, then `.tsx`); else `path + ext` over VITE_EXTENSIONS in order
* (so `shadow.mjs` beats `shadow.ts`, and `./evil.impl` finds `evil.impl.ts`); else, for a
* directory, `index + ext` in the same order. A directory with a package.json would switch Vite to
* package-entry resolution, which this test does not model, so it fails closed.
*/
function viteResolveRelative(path: string, from: string, node: ts.Node): string | undefined {
if (isFile(path)) return path;
if (/\.(?:js|mjs|cjs|jsx)$/.test(path)) {
const ext = path.slice(path.lastIndexOf("."));
const stem = path.slice(0, -ext.length);
const twin = [stem + ext.replace("js", "ts"), ...(ext === ".js" ? [`${stem}.tsx`] : [])].find(isFile);
if (twin) return twin;
}
const withExt = VITE_EXTENSIONS.map((e) => path + e).find(isFile);
if (withExt) return withExt;
if (isDir(path)) {
if (existsSync(join(path, "package.json"))) {
fail(from, node, `imports directory ${rel(path)}, which has a package.json this test does not model`);
}
return VITE_EXTENSIONS.map((e) => join(path, `index${e}`)).find(isFile);
}
return undefined;
}
/**
* Resolves a module specifier to the source file it names, or `null` for something that is not
* part of `src/`'s module graph (a real package, a node builtin, an asset). Everything else
* throws: a path alias, a Vite query suffix (`?worker`, `?raw`), a relative path that leaves
* `src/` or names nothing.
*/
function resolveSpecifier(from: string, spec: string, node: ts.Node): string | null {
if (spec.includes("?") || spec.includes("#")) {
fail(from, node, `import "${spec}" carries a query/fragment suffix this test cannot audit`);
}
if (spec.startsWith("./") || spec.startsWith("../")) {
const found = viteResolveRelative(resolve(dirname(from), spec), from, node);
if (!found) fail(from, node, `cannot resolve import "${spec}"`);
if (rel(found).startsWith("..")) fail(from, node, `import "${spec}" resolves outside src/ (${found})`);
return CODE_EXTENSION.test(found) ? found : null; // css, svg, json, … — an asset, not a module
}
if (spec.startsWith("node:") || builtinModules.includes(spec)) return null;
const pkg = PACKAGE_NAME.exec(spec)?.[1];
if (pkg && existsSync(join(nodeModulesDir, pkg, "package.json"))) return null;
fail(
from,
node,
`import "${spec}" is neither relative nor an installed package — likely a path alias. This test ` +
`only understands relative imports and real dependencies; teach it the alias rather than letting ` +
`the file drop out of the closure.`,
);
}
const stringLiteralText = (node: ts.Node | undefined) =>
node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined;
interface ModuleFacts {
/** Every module specifier the file names: static imports, `export … from`, literal `import()`. */
specifiers: string[];
/** The source files those specifiers resolve to (packages and assets excluded). */
targets: string[];
/** Wrapper names the file reaches from `lib/tauri-commands.ts`. */
wrapperNames: string[];
}
/**
* Parses one file and returns its module edges and the wrappers it reaches. Wrapper usage is:
* named imports and named re-exports (by their exported name), and `X.name` / `X?.name` (or
* `typeof X.name` in a type) where `X` is a namespace import of the wrappers. Fails closed on
* everything else that could carry a wrapper: a default import, `export *` / `export * as` of the wrappers, `import()` of them (it
* resolves to the namespace object), a computed `import()`, `require`, `import X = require`,
* `import.meta.glob`, and any reference to a namespace alias other than `X.name`.
*
* Namespace references are matched by identifier text, not symbol: every Identifier spelled `X`
* anywhere in the file must be the object of a property access (or the name *of* one, `o.X`,
* which is not a reference). A local that shadows `X` needs a declaration spelled `X`, and that
* declaration is itself such an Identifier, so shadowing fails closed rather than confusing it.
*/
function analyzeModule(file: string): ModuleFacts {
const sf = parse(file);
const facts: ModuleFacts = { specifiers: [], targets: [], wrapperNames: [] };
const namespaceAliases = new Map<string, ts.Identifier>();
const edge = (spec: string, node: ts.Node) => {
facts.specifiers.push(spec);
const target = resolveSpecifier(file, spec, node);
if (target) facts.targets.push(target);
return target;
};
for (const stmt of sf.statements) {
if (ts.isImportDeclaration(stmt)) {
const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt);
const clause = stmt.importClause;
if (target !== WRAPPERS || !clause) continue;
if (clause.name) fail(file, stmt, "default-imports tauri-commands.ts, which has no default export");
const bindings = clause.namedBindings;
if (bindings && ts.isNamespaceImport(bindings)) namespaceAliases.set(bindings.name.text, bindings.name);
if (bindings && ts.isNamedImports(bindings)) {
for (const el of bindings.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text);
}
} else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt);
if (target !== WRAPPERS) continue;
const clause = stmt.exportClause;
if (!clause || ts.isNamespaceExport(clause)) {
fail(
file,
stmt,
`re-exports tauri-commands.ts with "export *${clause ? " as …" : ""}", which cannot be audited — ` +
`re-export wrappers by name (export { wrapperName } from "…/tauri-commands")`,
);
}
for (const el of clause.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text);
} else if (ts.isImportEqualsDeclaration(stmt) && ts.isExternalModuleReference(stmt.moduleReference)) {
fail(file, stmt, `"import … = require(…)" is not followed by this test; use an ES import`);
}
}
walk(sf, (node) => {
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
const spec = stringLiteralText(node.arguments[0]);
if (spec === undefined || node.arguments.length === 0) {
fail(file, node, "import() with a computed specifier cannot be followed; use a string literal");
}
if (edge(spec, node) === WRAPPERS) {
fail(file, node, "import() of tauri-commands.ts yields the whole namespace object; import wrappers by name");
}
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") {
fail(file, node, "require() is not followed by this test; use an ES import");
} else if (
ts.isPropertyAccessExpression(node) &&
ts.isMetaProperty(node.expression) &&
node.expression.keywordToken === ts.SyntaxKind.ImportKeyword &&
node.name.text.startsWith("glob")
) {
fail(file, node, "import.meta.glob pulls in modules this test cannot enumerate");
} else if (ts.isIdentifier(node) && namespaceAliases.has(node.text)) {
if (node === namespaceAliases.get(node.text)) return; // the `import * as X` binding itself
const parent = node.parent;
if (ts.isPropertyAccessExpression(parent) && parent.name === node) return; // `o.X` — not a reference
if (ts.isPropertyAccessExpression(parent) && parent.expression === node && ts.isIdentifier(parent.name)) {
facts.wrapperNames.push(parent.name.text);
return;
}
// `typeof X.name` in a type: a QualifiedName, type-only, counted anyway (the safe direction).
if (ts.isQualifiedName(parent) && parent.left === node && ts.isTypeQueryNode(parent.parent)) {
facts.wrapperNames.push(parent.right.text);
return;
}
fail(
file,
node,
`"${node.text}" (a namespace import of tauri-commands.ts) is used in \`${parent.getText().slice(0, 60)}\` ` +
`rather than as "${node.text}.wrapperName" — only direct member access can be audited; import ` +
`the wrappers by name instead`,
);
}
});
return facts;
}
/**
* `export const NAME = … invoke<T>("command", …)` → NAME → command, read from the AST: each exported
* const calls `invoke` exactly once, with a string literal. `invoke` must be imported by name from
* `@tauri-apps/api/core` and appear nowhere except as the callee of such a call.
*/
function wrapperCommands(): Map<string, string> {
const sf = parse(WRAPPERS);
const map = new Map<string, string>();
const callsByWrapper = new Map<string, ts.CallExpression[]>();
for (const stmt of sf.statements) {
if (ts.isImportDeclaration(stmt) && INVOKE_SPECIFIER.test(stringLiteralText(stmt.moduleSpecifier)!)) {
const b = stmt.importClause?.namedBindings;
const onlyInvoke =
!stmt.importClause?.name &&
b !== undefined &&
ts.isNamedImports(b) &&
b.elements.every((el) => !el.propertyName && el.name.text === "invoke");
if (!onlyInvoke) fail(WRAPPERS, stmt, `must import exactly { invoke } from ${stringLiteralText(stmt.moduleSpecifier)}`);
}
if (
ts.isVariableStatement(stmt) &&
stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
stmt.declarationList.flags & ts.NodeFlags.Const
) {
for (const decl of stmt.declarationList.declarations) {
if (!ts.isIdentifier(decl.name)) fail(WRAPPERS, decl, "an exported wrapper must be a plain `export const NAME`");
callsByWrapper.set(decl.name.text, []);
}
}
}
walk(sf, (node) => {
if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
) {
fail(WRAPPERS, node, "tauri-commands.ts may not load modules dynamically (import()/require())");
}
if (!ts.isIdentifier(node) || node.text !== "invoke" || ts.isImportSpecifier(node.parent)) return;
const call = node.parent;
if (!ts.isCallExpression(call) || call.expression !== node) {
fail(WRAPPERS, node, "invoke is referenced other than as a direct call");
}
let decl: ts.Node = call;
while (!(ts.isVariableDeclaration(decl) && decl.parent.parent.parent === sf)) {
decl = decl.parent;
if (decl === sf) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
}
const calls = callsByWrapper.get((decl as ts.VariableDeclaration).name.getText());
if (!calls) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
// Only inside the wrapper's own function body: anything else (`export const x = invoke(…)`, an
// IIFE, a default argument) runs at module load in every window that imports this file.
const init = (decl as ts.VariableDeclaration).initializer;
const inBody =
init !== undefined &&
(ts.isArrowFunction(init) || ts.isFunctionExpression(init)) &&
call.pos >= init.body.pos &&
call.end <= init.body.end &&
!enclosedInIife(call, init);
if (!inBody) fail(WRAPPERS, call, "invoke must be called inside the wrapper's function body, not at module load");
calls.push(call);
});
for (const [name, calls] of callsByWrapper) {
expect(calls, `${name} must call invoke exactly once`).toHaveLength(1);
const command = stringLiteralText(calls[0].arguments[0]) ?? "<not a string literal>";
expect(command, `${name} must invoke a string literal (a computed name cannot be audited)`).toMatch(
/^[a-z0-9_]+$/,
);
map.set(name, command);
}
expect(map.size).toBeGreaterThan(100);
return map;
}
/** Whether `node` sits in a function expression that is called on the spot, between it and `outer`. */
function enclosedInIife(node: ts.Node, outer: ts.Node): boolean {
for (let n = node.parent; n !== outer; n = n.parent) {
let fn: ts.Node = n;
if (!(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) continue;
while (ts.isParenthesizedExpression(fn.parent)) fn = fn.parent;
if (ts.isCallExpression(fn.parent) && fn.parent.expression === fn) return true;
}
return false;
}
/** Module specifiers a file names — static imports, `export … from`, `import … = require`, and the
* argument of `import()`/`require()` — without resolving anything or applying the closure rules,
* so it can run over test files too. A computed `import()`/`require()` argument yields `null`. */
function namedSpecifiers(file: string): (string | null)[] {
const out: (string | null)[] = [];
walk(parse(file), (node) => {
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
out.push(stringLiteralText(node.moduleSpecifier) ?? null);
} else if (ts.isExternalModuleReference(node)) {
out.push(stringLiteralText(node.expression) ?? null);
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
) {
out.push(stringLiteralText(node.arguments[0]) ?? null);
}
});
return out;
}
const factsCache = new Map<string, ModuleFacts>();
function factsOf(file: string): ModuleFacts {
let facts = factsCache.get(file);
if (!facts) {
facts = analyzeModule(file);
factsCache.set(file, facts);
}
return facts;
}
/** Transitive closure from the viewer entry over static imports, `export … from` and `import()`. */
function viewerClosure(): Set<string> {
const seen = new Set<string>();
const queue = [VIEWER_ENTRY];
while (queue.length > 0) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
for (const target of factsOf(file).targets) if (!seen.has(target)) queue.push(target);
}
return seen;
}
describe("capability files match the code each window runs", () => {
const defaultCap = readCapability("default.json");
const viewerCap = readCapability("file-viewer.json");
const allCode = codeFiles(srcDir);
const files = allCode.filter(isAppSource);
it("only lib/tauri-commands.ts imports @tauri-apps/api/core", () => {
// Every code file, tests included: the viewer closure can reach anything a relative import can.
const offenders = allCode
.filter((f) => f !== WRAPPERS)
.filter((f) => namedSpecifiers(f).some((s) => s === null || INVOKE_SPECIFIER.test(s)))
.map(rel);
expect(offenders, "imports @tauri-apps/api(/core), or loads a computed specifier").toEqual([]);
});
it("the windows lists are the reviewed ones", () => {
expect(defaultCap.windows).toEqual(["main"]);
expect(viewerCap.windows).toEqual(["file-viewer-*"]);
});
it("the plugin/core grants are the reviewed ones", () => {
expect(defaultCap.prefixed).toEqual([
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"dialog:allow-open",
"dialog:allow-save",
]);
expect(viewerCap.prefixed).toEqual([
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"core:window:allow-destroy",
]);
});
it("the viewer window imports exactly the wrappers file-viewer.json grants", () => {
const wrappers = wrapperCommands();
const closure = viewerClosure();
expect(closure.has(WRAPPERS), "the viewer reaches tauri-commands.ts").toBe(true);
const viewerCommands = new Set<string>();
for (const file of closure) {
for (const name of factsOf(file).wrapperNames) {
const command = wrappers.get(name);
expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined();
viewerCommands.add(command!);
}
}
const granted = [...viewerCommands].map(toPermission).sort();
expect(granted).toEqual(viewerCap.bare);
});
it("the main window imports only wrappers default.json grants, and none of the viewer's", () => {
const wrappers = wrapperCommands();
const closure = viewerClosure();
const mainCommands = new Set<string>();
for (const file of files) {
if (closure.has(file)) continue;
for (const name of factsOf(file).wrapperNames) {
const command = wrappers.get(name);
expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined();
mainCommands.add(command!);
}
}
expect(mainCommands.size).toBeGreaterThan(50);
const ungranted = [...mainCommands].map(toPermission).filter((p) => !defaultCap.bare.includes(p)).sort();
expect(ungranted, "main-window code imports wrappers default.json does not grant").toEqual([]);
const crossed = [...mainCommands].filter((c) => viewerCap.bare.includes(toPermission(c))).sort();
expect(crossed, "main-window code imports viewer-only wrappers").toEqual([]);
});
it("every wrapper's command is granted in exactly one capability file", () => {
const wrappers = wrapperCommands();
const both: string[] = [];
const neither: string[] = [];
for (const command of new Set(wrappers.values())) {
const p = toPermission(command);
const inDefault = defaultCap.bare.includes(p);
const inViewer = viewerCap.bare.includes(p);
if (inDefault && inViewer) both.push(command);
if (!inDefault && !inViewer) neither.push(command);
}
expect(both).toEqual([]);
expect(neither, "granted nowhere — cargo check would fail too, but you may not have run it").toEqual([]);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { createRef } from "react";
import { EditorView } from "@codemirror/view";
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
beforeAll(() => {
// P17: CodeMirror's measure pass calls Range geometry, which jsdom lacks.
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () => ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }) as DOMRect;
});
const mount = (initialDoc: string) => {
const ref = createRef<CodeEditorHandle>();
const onDocChanged = vi.fn();
const utils = render(
<CodeEditor
ref={ref}
initialDoc={initialDoc}
readOnly={false}
language={null}
lineWrapping={false}
initialLocation={{ line: null, col: null, end_line: null }}
onDocChanged={onDocChanged}
onSave={() => {}}
/>,
);
const view = EditorView.findFromDOM(utils.container.querySelector(".cm-editor") as HTMLElement)!;
return { ref, view, onDocChanged };
};
describe("CodeEditor.setDoc (a reload)", () => {
it("keeps the cursor and the scroll position, and is not an edit", () => {
const { ref, view, onDocChanged } = mount("one\ntwo\nthree\nfour\n");
act(() => { view.dispatch({ selection: { anchor: 9 } }); }); // inside "three"
// jsdom has no layout, so give the scroller a real, settable scrollTop.
let top = 0;
Object.defineProperty(view.scrollDOM, "scrollTop", { configurable: true, get: () => top, set: (v: number) => { top = v; } });
view.scrollDOM.scrollTop = 120;
act(() => { ref.current!.setDoc("one\ntwo\nTHREE\nfour\nfive\n"); });
expect(view.state.doc.toString()).toBe("one\ntwo\nTHREE\nfour\nfive\n");
expect(view.state.selection.main.head).toBe(9);
expect(view.scrollDOM.scrollTop).toBe(120);
expect(onDocChanged).not.toHaveBeenCalled();
});
it("clamps the cursor when the new text is shorter", () => {
const { ref, view } = mount("a long first line\n");
act(() => { view.dispatch({ selection: { anchor: 15 } }); });
act(() => { ref.current!.setDoc("short"); });
expect(view.state.selection.main.head).toBe(5);
});
it("a user edit is reported as a change", () => {
const { view, onDocChanged } = mount("x");
act(() => { view.dispatch({ changes: { from: 1, insert: "y" } }); });
expect(onDocChanged).toHaveBeenCalledTimes(1);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { Annotation, EditorState, Compartment, EditorSelection, type Extension } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { search, searchKeymap } from "@codemirror/search";
import { bracketMatching, indentOnInput } from "@codemirror/language";
import type { ViewerLocation } from "../lib/types";
import { viewerTheme } from "./viewerTheme";
import { highlightExtension, setHighlight } from "./highlightLine";
export interface CodeEditorHandle {
getDoc(): string;
/** Replace the whole document, keeping scroll and a clamped cursor. Does not mark dirty. */
setDoc(text: string): void;
goTo(loc: ViewerLocation): void;
focus(): void;
}
export interface CodeEditorProps {
initialDoc: string;
readOnly: boolean;
language: Extension | null;
lineWrapping: boolean;
initialLocation: ViewerLocation;
onDocChanged(): void;
onSave(): void;
}
/** A `dispatch` from `setDoc` is a reload, not a user edit; the listener must not mark it dirty. */
const reloadTag = Annotation.define<boolean>();
function readOnlyExt(readOnly: boolean): Extension[] {
return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)];
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor(props, ref) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const readOnlyCompartment = useRef(new Compartment());
const languageCompartment = useRef(new Compartment());
const wrapCompartment = useRef(new Compartment());
const callbacks = useRef(props);
callbacks.current = props;
useEffect(() => {
if (!host.current) return;
const v = new EditorView({
parent: host.current,
state: EditorState.create({
doc: props.initialDoc,
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSpecialChars(),
drawSelection(),
history(),
bracketMatching(),
indentOnInput(),
search({ top: true }),
highlightExtension(),
viewerTheme,
keymap.of([
{ key: "Mod-s", run: () => { callbacks.current.onSave(); return true; } },
...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab,
]),
readOnlyCompartment.current.of(readOnlyExt(props.readOnly)),
languageCompartment.current.of(props.language ?? []),
wrapCompartment.current.of(props.lineWrapping ? EditorView.lineWrapping : []),
EditorView.updateListener.of((u) => {
if (u.docChanged && !u.transactions.some((tr) => tr.annotation(reloadTag))) callbacks.current.onDocChanged();
}),
],
}),
});
view.current = v;
goTo(v, props.initialLocation);
return () => { v.destroy(); view.current = null; };
// The editor is created once per mount; later prop changes go through compartments below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
view.current?.dispatch({ effects: readOnlyCompartment.current.reconfigure(readOnlyExt(props.readOnly)) });
}, [props.readOnly]);
useEffect(() => {
view.current?.dispatch({ effects: languageCompartment.current.reconfigure(props.language ?? []) });
}, [props.language]);
useEffect(() => {
view.current?.dispatch({ effects: wrapCompartment.current.reconfigure(props.lineWrapping ? EditorView.lineWrapping : []) });
}, [props.lineWrapping]);
useImperativeHandle(ref, () => ({
getDoc: () => view.current?.state.doc.toString() ?? "",
setDoc: (text) => {
const v = view.current;
if (!v) return;
const scrollTop = v.scrollDOM.scrollTop;
const head = Math.min(v.state.selection.main.head, text.length);
v.dispatch({
changes: { from: 0, to: v.state.doc.length, insert: text },
selection: EditorSelection.single(head),
annotations: reloadTag.of(true),
});
v.scrollDOM.scrollTop = scrollTop;
},
goTo: (loc) => { if (view.current) goTo(view.current, loc); },
focus: () => view.current?.focus(),
}));
return <div ref={host} className="h-full min-h-0" data-testid="code-editor" />;
});
function goTo(v: EditorView, loc: ViewerLocation): void {
if (loc.line === null) return;
const from = loc.line;
const to = loc.end_line ?? loc.line;
const lineNo = Math.min(Math.max(1, from), v.state.doc.lines);
const line = v.state.doc.line(lineNo);
const pos = Math.min(line.from + Math.max(0, (loc.col ?? 1) - 1), line.to);
v.dispatch({
selection: EditorSelection.cursor(pos),
effects: [setHighlight.of({ from, to }), EditorView.scrollIntoView(pos, { y: "center" })],
});
}
+408
View File
@@ -0,0 +1,408 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, fireEvent } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
import EditorPane from "./EditorPane";
import { encodeBase64 } from "../components/projects/home/filePreview";
import type { ViewerState } from "../lib/types";
const H1 = "1".repeat(64);
const H2 = "2".repeat(64);
const H3 = "3".repeat(64);
const b64 = (s: string) => btoa(s);
const commands = vi.hoisted(() => ({
viewerReadFile: vi.fn(),
viewerPollFile: vi.fn(),
viewerWriteFile: vi.fn(),
}));
vi.mock("../lib/tauri-commands", () => commands);
const windowApi = vi.hoisted(() => ({ closeRequested: null as null | ((e: { preventDefault(): void }) => Promise<void> | void), destroy: vi.fn(), listeners: new Map<string, (e: { payload: unknown }) => void>() }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({
onCloseRequested: async (cb: typeof windowApi.closeRequested) => { windowApi.closeRequested = cb; return () => {}; },
listen: async (name: string, cb: (e: { payload: unknown }) => void) => { windowApi.listeners.set(name, cb); return () => {}; },
destroy: windowApi.destroy,
}),
}));
const state: ViewerState = {
project_id: "p", project_name: "Demo", raw_path: "notes.md",
state: { kind: "resolved", container_path: "/workspace/demo/notes.md" },
initial: { line: 1, col: null, end_line: null },
};
const textFile = (text: string, hash: string, extra: Partial<{ truncated: boolean; editable: boolean }> = {}) => ({
contents_base64: b64(text), truncated: false, size: text.length, hash, editable: true, readonly_reason: null, ...extra,
});
/** Mark the buffer dirty through the pane's test hook (jsdom cannot drive CodeMirror's contenteditable). */
const edit = () => fireEvent(document, new CustomEvent("triple-c-test-edit"));
const clickSave = async () => { await act(async () => { fireEvent.click(screen.getByRole("button", { name: /^save$/i })); }); };
/** A real edit through CodeMirror, so the saved bytes carry it. */
const typeInto = (from: number, to: number, insert: string) => {
const view = EditorView.findFromDOM(document.querySelector(".cm-editor") as HTMLElement);
if (!view) throw new Error("no editor");
act(() => { view.dispatch({ changes: { from, to, insert } }); });
};
const bytesB64 = (bytes: number[]) => encodeBase64(new Uint8Array(bytes));
const utf8 = (s: string) => Array.from(new TextEncoder().encode(s));
const READ_ONLY = "Could not save the file: The file is read-only for the container user.";
const NOT_RUNNING = "Start the project before checking this file for changes — it runs inside the running container.";
const saved = (hash: string, diskHash = hash) => ({ hash, disk_hash: diskHash });
const poll = async (ms = 2100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); };
describe("EditorPane", () => {
beforeAll(() => {
// P17: CodeMirror's measure pass calls Range geometry, which jsdom lacks.
const rect = () => ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }) as DOMRect;
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = rect;
});
beforeEach(() => {
// Only the poll's interval is faked. Testing Library's async utilities
// settle through a real setTimeout(0), which fully faked timers freeze.
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
commands.viewerReadFile.mockReset().mockResolvedValue(textFile("hello\n", H1));
commands.viewerPollFile.mockReset().mockResolvedValue({ exists: true, hash: H1, size: 6 });
commands.viewerWriteFile.mockReset().mockResolvedValue(saved(H2));
windowApi.destroy.mockReset();
});
afterEach(() => vi.useRealTimers());
it("loads the file and shows the path", async () => {
render(<EditorPane state={state} />);
expect(await screen.findByText("/workspace/demo/notes.md")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledWith(1024 * 1024);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("a changed poll on a clean document reloads silently", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
commands.viewerReadFile.mockResolvedValue(textFile("changed\n", H2));
await poll();
expect(await screen.findByText(/Reloaded/)).toBeInTheDocument();
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByTestId("code-editor")).toHaveTextContent("changed");
});
it("a changed poll on a dirty document shows the banner instead of reloading", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
await poll();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
});
it("a truncated file reloads once per change, not on every poll", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("big", "a".repeat(64), { truncated: true }));
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
await poll(); // seeds diskHash = H1 from the poll
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 9 });
commands.viewerReadFile.mockResolvedValue(textFile("bigger", "b".repeat(64), { truncated: true }));
await poll();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
await poll(2000);
await poll(2000);
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
});
it("a gone file shows the banner and disables Save", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
commands.viewerPollFile.mockResolvedValue({ exists: false, hash: null, size: null });
await poll();
expect(await screen.findByText(/in the container/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("saves the buffer against the loaded hash", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("hello\n"), H1);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("a save conflict shows the Changed on disk banner with both choices", async () => {
commands.viewerWriteFile.mockRejectedValue(new Error("conflict: the file changed on disk since it was loaded."));
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Overwrite on save/ })).toBeInTheDocument();
});
it("after a conflict, Overwrite on save saves against the freshly polled hash", async () => {
// A string rejection, as Tauri's invoke delivers it.
commands.viewerWriteFile.mockRejectedValueOnce("conflict: the file changed on disk since it was loaded.");
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H3, size: 7 });
await clickSave();
const overwrite = await screen.findByRole("button", { name: /Overwrite on save/ });
await act(async () => { fireEvent.click(overwrite); });
commands.viewerWriteFile.mockResolvedValue(saved(H2));
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenLastCalledWith(b64("hello\n"), H3);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("Reload (discard mine) replaces the buffer with the disk copy", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
commands.viewerReadFile.mockResolvedValue(textFile("theirs\n", H2));
await poll();
const reload = await screen.findByRole("button", { name: /Reload/ });
await act(async () => { fireEvent.click(reload); });
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByTestId("code-editor")).toHaveTextContent("theirs");
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("a save refused because the file is read-only says so and keeps the buffer", async () => {
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText(/read-only for the container user/)).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.getByText("Unsaved")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
});
it("any other save failure is shown as it came", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: disk full");
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
await clickSave();
expect(await screen.findByText("Could not save the file: disk full")).toBeInTheDocument();
});
it("a poll refused because the container is down shows Container not running and disables Save", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockRejectedValue(NOT_RUNNING);
await poll();
expect(await screen.findByText(/until the project starts again/)).toBeInTheDocument();
expect(screen.getByText("Container not running")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
});
it("any other poll failure says what failed, not that the container is down, and clears on a good poll", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
commands.viewerPollFile.mockRejectedValue("Could not check the file: sha256sum: Permission denied");
await poll();
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not check the file: sha256sum: Permission denied/);
expect(screen.getByText("Could not check for changes")).toBeInTheDocument();
expect(screen.queryByText(/Container not running/)).toBeNull();
// The write re-checks the hash itself, so saving stays possible.
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H1, size: 6 });
await poll(2000);
expect(screen.queryByText(/Permission denied/)).toBeNull();
expect(screen.getByText("Unsaved")).toBeInTheDocument();
});
it("a save that another writer overtook shows Changed on disk instead of Saved", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
commands.viewerWriteFile.mockResolvedValue(saved(H2, H3));
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H3, size: 7 });
await clickSave();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
expect(screen.getByText("Changed on disk")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeDisabled();
// The next poll sees the same foreign hash: the banner stays, nothing is reloaded over the buffer.
await poll();
expect(screen.getByText(/while you were editing/)).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
// Overwrite now saves against what is actually on disk.
await act(async () => { fireEvent.click(screen.getByRole("button", { name: /Overwrite on save/ })); });
commands.viewerWriteFile.mockResolvedValue(saved(H2));
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenLastCalledWith(b64("hello\n"), H3);
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("Save and close does not close when another writer overtook the save", async () => {
commands.viewerWriteFile.mockResolvedValue(saved(H2, H3));
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
expect(windowApi.destroy).not.toHaveBeenCalled();
expect(await screen.findByText(/while you were editing/)).toBeInTheDocument();
});
it("a failed first read offers Retry, which loads the file", async () => {
commands.viewerReadFile.mockRejectedValueOnce(NOT_RUNNING.replace("checking this file for changes", "opening files"));
render(<EditorPane state={state} />);
expect(await screen.findByText(/Start the project before opening files/)).toBeInTheDocument();
const retry = screen.getByRole("button", { name: "Retry" });
await act(async () => { fireEvent.click(retry); });
expect(await screen.findByText("Saved")).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("a failed first read is retried by the poll until it succeeds", async () => {
commands.viewerReadFile.mockRejectedValueOnce("Docker is busy").mockRejectedValueOnce("Docker is still busy");
render(<EditorPane state={state} />);
expect(await screen.findByText("Docker is busy")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(1);
await poll();
expect(await screen.findByText("Docker is still busy")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(2);
expect(commands.viewerPollFile).not.toHaveBeenCalled();
await poll(2000);
expect(await screen.findByText("Saved")).toBeInTheDocument();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(3);
// Loaded: the poll is back to polling, not re-reading.
await poll(2000);
expect(commands.viewerPollFile).toHaveBeenCalled();
expect(commands.viewerReadFile).toHaveBeenCalledTimes(3);
});
it("closing with unsaved edits is intercepted", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
edit();
const prevent = vi.fn();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: prevent }); });
expect(prevent).toHaveBeenCalled();
expect(await screen.findByText(/Unsaved changes/)).toBeInTheDocument();
await act(async () => { fireEvent.click(screen.getByRole("button", { name: /Discard/ })); });
expect(windowApi.destroy).toHaveBeenCalled();
});
it("closing a clean document is not intercepted", async () => {
render(<EditorPane state={state} />);
await screen.findByText("/workspace/demo/notes.md");
const prevent = vi.fn();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: prevent }); });
expect(prevent).not.toHaveBeenCalled();
expect(screen.queryByText(/Unsaved changes/)).toBeNull();
});
it("a one-character edit to a CRLF file saves with every CRLF intact", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("a\r\nb\r\nc\r\n", H1));
render(<EditorPane state={state} />);
await screen.findByText("Saved");
typeInto(2, 3, "B"); // the editor holds "a\nb\nc\n"
expect(screen.getByText("Unsaved")).toBeInTheDocument();
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("a\r\nB\r\nc\r\n"), H1);
});
it("a file with a UTF-8 BOM keeps its BOM on save", async () => {
const BOM = [0xef, 0xbb, 0xbf];
commands.viewerReadFile.mockResolvedValue({ ...textFile("", H1), contents_base64: bytesB64([...BOM, ...utf8("hi\n")]) });
render(<EditorPane state={state} />);
await screen.findByText("Saved");
typeInto(2, 2, "!");
await clickSave();
expect(commands.viewerWriteFile).toHaveBeenCalledWith(bytesB64([...BOM, ...utf8("hi!\n")]), H1);
});
it("a reload that fails is retried on the next poll", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: H2, size: 8 });
commands.viewerReadFile.mockRejectedValueOnce("Could not read the file: I/O error").mockResolvedValue(textFile("changed\n", H2));
await poll();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
await poll(2000);
expect(screen.getByTestId("code-editor")).toHaveTextContent("changed");
expect(screen.getByText("Reloaded")).toBeInTheDocument();
});
it("ignores a poll issued before a save completed", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
let answer: (p: { exists: boolean; hash: string; size: number }) => void = () => {};
commands.viewerPollFile.mockImplementationOnce(() => new Promise((r) => { answer = r; }));
await poll(); // this poll is now in flight, carrying the pre-save hash
await clickSave(); // lands as H2
edit();
await act(async () => { answer({ exists: true, hash: H1, size: 6 }); });
expect(screen.queryByText(/while you were editing/)).toBeNull();
expect(screen.getByText("Unsaved")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).toBeEnabled();
});
it("a conflict whose follow-up poll has no hash shows an error instead of offering an overwrite", async () => {
commands.viewerWriteFile.mockRejectedValue("conflict: the file changed on disk since it was loaded.");
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
commands.viewerPollFile.mockResolvedValue({ exists: true, hash: null, size: 7 });
await clickSave();
expect(await screen.findByRole("alert")).toHaveTextContent(/could not be checked/);
expect(screen.queryByRole("button", { name: /Overwrite on save/ })).toBeNull();
expect(screen.getByRole("button", { name: /Reload/ })).toBeInTheDocument();
});
it("states why a file is read-only as visible text", async () => {
commands.viewerReadFile.mockResolvedValue(textFile("big", H1, { truncated: true }));
render(<EditorPane state={state} />);
expect(await screen.findByText("Read-only")).toBeInTheDocument();
expect(screen.getByText("Files over 1 MiB are read-only.")).toBeVisible();
});
it("a save error is announced as an alert", async () => {
commands.viewerWriteFile.mockRejectedValue("Could not save the file: disk full");
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await clickSave();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not save the file: disk full");
});
it("Save and close saves, then closes the window", async () => {
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
expect(commands.viewerWriteFile).toHaveBeenCalledWith(b64("hello\n"), H1);
expect(windowApi.destroy).toHaveBeenCalled();
});
it("a save that fails while closing keeps the window open and the buffer", async () => {
commands.viewerWriteFile.mockRejectedValue(READ_ONLY);
render(<EditorPane state={state} />);
await screen.findByText("Saved");
edit();
await act(async () => { await windowApi.closeRequested?.({ preventDefault: () => {} }); });
await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Save and close" })); });
expect(windowApi.destroy).not.toHaveBeenCalled();
expect(screen.getByText(/Unsaved changes/)).toBeInTheDocument();
expect(await screen.findByText(/read-only for the container user/)).toBeInTheDocument();
expect(screen.getByTestId("code-editor")).toHaveTextContent("hello");
expect(screen.getByText("Unsaved")).toBeInTheDocument();
});
});
+343
View File
@@ -0,0 +1,343 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Extension } from "@codemirror/state";
import Button from "../components/ui/Button";
import StatusIndicator, { type StatusTone } from "../components/ui/StatusIndicator";
import { decodeBase64, encodeBase64, imageMimeFor, previewLimit } from "../components/projects/home/filePreview";
import { viewerPollFile, viewerReadFile, viewerWriteFile } from "../lib/tauri-commands";
import type { ViewerFile, ViewerLocation, ViewerState } from "../lib/types";
import { CodeEditor, type CodeEditorHandle } from "./CodeEditor";
import { CONFLICT_PREFIX, GONE_PREFIX, READ_ONLY_MESSAGE } from "./ipcMessages";
import { classifyViewerFile, type Editability } from "./editability";
import { languageFor, wrapsLines } from "./languages";
import { decodeViewerText, encodeViewerText, type TextFormat } from "./textFormat";
import { useViewerPolling } from "./useViewerPolling";
import { canSave, initialViewerState, pollEffect, reduceViewer } from "./viewerState";
const POLL_MS = 2000;
export const GOTO_EVENT = "file-viewer-goto";
const READ_ONLY_SAVE =
"This file is read-only for the container user, so it was not saved. Your text is kept: change the file's permissions in the container and save again, or copy your text out.";
const CONFLICT_UNCHECKED =
"The file changed on disk, but its new version could not be checked, so it cannot be overwritten safely. Copy your text out if you need it, then reload.";
/** A banner-worthy save failure; `reload` adds a "Reload (discard mine)" button. */
interface SaveError { text: string; reload?: boolean }
type View =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "text"; doc: string; editability: Editability }
| { kind: "image"; url: string; editability: Editability }
| { kind: "binary"; editability: Editability };
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** `write.rs`'s refusal to replace a file the container user may not write. */
const isReadOnlyRefusal = (msg: string) => msg.includes(READ_ONLY_MESSAGE);
export default function EditorPane({ state }: { state: ViewerState }) {
const path = state.state.kind === "resolved" ? state.state.container_path : "";
const [view, setView] = useState<View>({ kind: "loading" });
const [language, setLanguage] = useState<Extension | null>(null);
const [doc, dispatch] = useReducer(reduceViewer, initialViewerState);
const [closing, setClosing] = useState(false);
const [saveError, setSaveError] = useState<SaveError | null>(null);
const editor = useRef<CodeEditorHandle>(null);
const docRef = useRef(doc);
docRef.current = doc;
const closingRef = useRef(closing);
closingRef.current = closing;
/** Bumped synchronously on every user edit, so async work can tell an edit happened meanwhile. */
const editGen = useRef(0);
const saving = useRef(false);
/** Bumped when a save's write settles; a poll issued before that is stale. */
const saveGen = useRef(0);
/** Line ending and BOM of the loaded text, restored on save. */
const textFormat = useRef<TextFormat>({ bom: false, eol: "\n" });
const imageUrl = useRef<string | null>(null);
const markEdited = useCallback(() => {
editGen.current += 1;
dispatch({ type: "edited" });
}, []);
/** Put a freshly read file on screen: text into the editor, or an image/binary view. */
const show = useCallback((file: ViewerFile) => {
const bytes = decodeBase64(file.contents_base64);
const classified = classifyViewerFile(path, file, bytes);
if (imageUrl.current) { URL.revokeObjectURL(imageUrl.current); imageUrl.current = null; }
if (classified.kind === "image") {
const url = URL.createObjectURL(new Blob([bytes], { type: imageMimeFor(path) ?? "application/octet-stream" }));
imageUrl.current = url;
setView({ kind: "image", url, editability: classified });
} else if (classified.kind === "binary") {
setView({ kind: "binary", editability: classified });
} else {
const { text, editability, format } = decodeViewerText(bytes, classified);
textFormat.current = format;
setView({ kind: "text", doc: text, editability });
editor.current?.setDoc(text);
}
}, [path]);
useEffect(() => () => { if (imageUrl.current) URL.revokeObjectURL(imageUrl.current); }, []);
/** Bumped per initial-load attempt (and on unmount/path change); a stale attempt's result is dropped. */
const loadGen = useRef(0);
/**
* The initial read. Re-run by "Retry" and by the poll while the window shows
* a load error, so a window opened while the container was restarting
* recovers on its own instead of staying dead.
*/
const load = useCallback(async () => {
const gen = ++loadGen.current;
try {
const file = await viewerReadFile(previewLimit(path));
if (loadGen.current !== gen) return;
show(file);
dispatch({ type: "loaded", hash: file.hash, truncated: file.truncated });
} catch (e) {
if (loadGen.current === gen) setView({ kind: "error", message: errorText(e) });
}
}, [path, show]);
useEffect(() => {
void load();
return () => { loadGen.current += 1; };
}, [load]);
const retryLoad = useCallback(() => {
setView({ kind: "loading" });
void load();
}, [load]);
// The language loads lazily and separately, so the text is on screen (and
// polling runs) without waiting for a grammar chunk.
useEffect(() => {
let cancelled = false;
languageFor(path).then((l) => { if (!cancelled) setLanguage(l); }, () => {});
return () => { cancelled = true; };
}, [path]);
/**
* The one reload path (P3/P14), for a clean poll-driven reload and for
* "Reload (discard mine)". `polledHash` is the poll's full-file hash, which
* a truncated read's own (prefix) hash can never equal. With `onlyIfClean`,
* an edit made while the read was in flight wins: nothing is replaced, and
* the next poll shows the banner instead.
*/
const reloadFromDisk = useCallback(async (polledHash: string | null, onlyIfClean: boolean) => {
const gen = editGen.current;
const file = await viewerReadFile(previewLimit(path));
if (onlyIfClean && editGen.current !== gen) return;
show(file);
dispatch({ type: "reloaded", hash: file.hash, truncated: file.truncated, polledHash });
}, [path, show]);
// Poll (spec §5). A reload replaces the document only when the reducer says so.
// While the first read has failed, each tick retries that read instead.
// Always enabled, so a loading -> error flip does not fire an immediate extra read.
useViewerPolling(POLL_MS, async () => {
if (view.kind === "loading") return;
if (view.kind === "error") { await load(); return; }
// A poll that overlaps a save can carry the pre-save hash; skip it (M2).
if (saving.current) return;
const gen = saveGen.current;
let poll;
try {
poll = await viewerPollFile();
} catch (e) {
if (saveGen.current === gen) dispatch({ type: "poll_failed", message: errorText(e) });
return;
}
if (saveGen.current !== gen) return;
const before = docRef.current;
const after = reduceViewer(before, { type: "polled", poll });
dispatch({ type: "polled", poll });
if (pollEffect(before, after) === "reload") {
try { await reloadFromDisk(after.diskHash, true); } catch (e) { dispatch({ type: "poll_failed", message: errorText(e) }); }
}
}, true);
const editable = view.kind === "text" && view.editability.editable;
const saveEnabled = canSave(doc, editable);
const save = useCallback(async () => {
const handle = editor.current;
const baseHash = docRef.current.baseHash;
if (!saveEnabled || !handle || !baseHash || saving.current) return;
saving.current = true;
setSaveError(null);
const gen = editGen.current;
try {
const bytes = encodeViewerText(handle.getDoc(), textFormat.current);
const result = await viewerWriteFile(encodeBase64(bytes), baseHash).then(
(saved) => ({ ok: true as const, saved }),
(e: unknown) => ({ ok: false as const, msg: errorText(e) }),
);
saveGen.current += 1;
if (result.ok) {
const { hash, disk_hash: diskHash } = result.saved;
dispatch({ type: "saved", hash, diskHash });
if (editGen.current !== gen) dispatch({ type: "edited" }); // typed while the save was in flight
// Another writer landed right after ours: the reducer shows "Changed on
// disk", and the window stays open so the user can decide.
else if (closingRef.current && diskHash === hash) await getCurrentWindow().destroy();
} else if (result.msg.startsWith(CONFLICT_PREFIX)) {
await adoptConflict();
} else if (result.msg.startsWith(GONE_PREFIX)) {
dispatch({ type: "save_gone" });
} else if (isReadOnlyRefusal(result.msg)) {
setSaveError({ text: READ_ONLY_SAVE });
} else {
setSaveError({ text: result.msg });
}
} finally {
saving.current = false;
}
}, [saveEnabled]);
/**
* The disk changed between polls. Poll now (P4), so "Overwrite on save"
* adopts the current hash rather than the stale one. With no hash to adopt,
* an overwrite would only conflict again, so say so instead (M3).
*/
async function adoptConflict() {
let poll;
try {
poll = await viewerPollFile();
} catch (e) {
dispatch({ type: "poll_failed", message: errorText(e) });
dispatch({ type: "save_conflict" });
return;
}
if (poll.exists && poll.hash === null) { setSaveError({ text: CONFLICT_UNCHECKED, reload: true }); return; }
dispatch({ type: "polled", poll });
if (poll.exists) dispatch({ type: "save_conflict" });
}
// Ctrl/Cmd+S outside the editor; the editor's own keymap handles it inside
// (and prevents the default, which is how this listener knows to skip it).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || !(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "s") return;
e.preventDefault();
void save();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [save]);
// Close guard + goto (spec §3/§5).
useEffect(() => {
const win = getCurrentWindow();
let disposed = false;
const unlisten: Array<() => void> = [];
const keep = (u: () => void) => { if (disposed) u(); else unlisten.push(u); };
void win.onCloseRequested((event) => {
if (docRef.current.doc === "dirty") { event.preventDefault(); setClosing(true); }
}).then(keep);
void win.listen<ViewerLocation>(GOTO_EVENT, (e) => editor.current?.goTo(e.payload)).then(keep);
return () => { disposed = true; unlisten.forEach((u) => u()); };
}, []);
useEffect(() => {
if (import.meta.env.MODE !== "test") return;
document.addEventListener("triple-c-test-edit", markEdited);
return () => document.removeEventListener("triple-c-test-edit", markEdited);
}, [markEdited]);
const reloadDiscarding = useCallback(async () => {
setSaveError(null);
try { await reloadFromDisk(docRef.current.diskHash, false); } catch (e) { setSaveError({ text: errorText(e) }); }
}, [reloadFromDisk]);
const badge = useMemo((): { tone: StatusTone; label: string; detail?: string } | null => {
if (view.kind === "loading" || view.kind === "error") return null;
if (doc.containerDown) return { tone: "error", label: "Container not running" };
if (doc.pollError) return { tone: "error", label: "Could not check for changes" };
if (doc.disk === "gone") return { tone: "error", label: "File no longer exists" };
if (!view.editability.editable) return { tone: "off", label: "Read-only", detail: view.editability.reason ?? undefined };
if (doc.disk === "changed") return { tone: "busy", label: "Changed on disk" };
if (doc.doc === "dirty") return { tone: "busy", label: "Unsaved" };
if (doc.justReloaded) return { tone: "ok", label: "Reloaded" };
return { tone: "ok", label: "Saved" };
}, [doc, view]);
return (
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)]">
<header className="flex items-center gap-3 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono" title={path}>{path}</span>
<span className="text-[var(--text-secondary)]">{state.project_name}</span>
<span className="ml-auto flex items-center" aria-live="polite">
{badge && <StatusIndicator tone={badge.tone} label={badge.label} />}
{badge?.detail && <span className="ml-2 text-[var(--text-secondary)]">{badge.detail}</span>}
</span>
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save</Button>
</header>
{doc.containerDown && <Banner tone="error" text="Container not running — the file cannot be read or saved until the project starts again." />}
{doc.pollError && <Banner tone="error" text={`${doc.pollError} — changes on disk go undetected until this clears; the viewer keeps trying.`} />}
{doc.disk === "gone" && <Banner tone="error" text="This file no longer exists in the container. Your text is kept so you can copy it; saving is disabled." />}
{doc.disk === "changed" && doc.doc === "dirty" && (
<Banner text="Changed on disk while you were editing.">
<Button size="sm" onClick={() => void reloadDiscarding()}>Reload (discard mine)</Button>
<Button size="sm" onClick={() => dispatch({ type: "overwrite_on_save" })}>Overwrite on save</Button>
</Banner>
)}
{saveError && (
<Banner tone="error" text={saveError.text}>
{saveError.reload && <Button size="sm" onClick={() => void reloadDiscarding()}>Reload (discard mine)</Button>}
</Banner>
)}
{closing && (
<Banner text="Unsaved changes — save before closing?">
<Button variant="primary" size="sm" onClick={() => void save()} disabled={!saveEnabled}>Save and close</Button>
<Button variant="danger" size="sm" onClick={() => void getCurrentWindow().destroy()}>Discard</Button>
<Button size="sm" onClick={() => setClosing(false)}>Cancel</Button>
</Banner>
)}
<main className="min-h-0 flex-1">
{view.kind === "loading" && <p className="p-4 text-sm text-[var(--text-secondary)]">Loading</p>}
{view.kind === "error" && (
<div className="flex flex-col items-start gap-2 p-4 text-sm">
<p>{view.message}</p>
<p className="text-[var(--text-secondary)]">The viewer retries every few seconds.</p>
<Button size="sm" onClick={retryLoad}>Retry</Button>
</div>
)}
{view.kind === "binary" && <p className="p-4 text-sm">{view.editability.reason}</p>}
{view.kind === "image" && <img src={view.url} alt={path} className="max-h-full max-w-full object-contain p-4" />}
{view.kind === "text" && (
<CodeEditor
ref={editor}
initialDoc={view.doc}
readOnly={!view.editability.editable}
language={language}
lineWrapping={wrapsLines(path)}
initialLocation={state.initial}
onDocChanged={markEdited}
onSave={() => void save()}
/>
)}
</main>
</div>
);
}
/** A warning is a polite status; an error (a failed save, a lost file or container) is an alert. */
function Banner({ text, tone = "warning", children }: { text: string; tone?: "warning" | "error"; children?: ReactNode }) {
const colours = tone === "error"
? "border-[var(--error)] bg-[var(--error-muted)]"
: "border-[var(--warning)] bg-[var(--warning-muted)]";
return (
<div role={tone === "error" ? "alert" : "status"} className={`flex flex-wrap items-center gap-2 border-b px-3 py-2 text-xs ${colours}`}>
<span>{text}</span>
{children}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ViewerState } from "../lib/types";
import ViewerApp from "./ViewerApp";
const commands = vi.hoisted(() => ({
viewerGetState: vi.fn(),
viewerChooseFile: vi.fn(),
}));
vi.mock("../lib/tauri-commands", () => commands);
// The editor itself is covered by EditorPane.test; here only the routing matters.
vi.mock("./EditorPane", () => ({
default: ({ state }: { state: ViewerState }) => (
<p>editor for {state.state.kind === "resolved" ? state.state.container_path : "?"}</p>
),
}));
const base = { project_id: "p", project_name: "Demo", raw_path: "foo.ts", initial: { line: 3, col: null, end_line: null } };
describe("ViewerApp", () => {
beforeEach(() => {
commands.viewerGetState.mockReset();
commands.viewerChooseFile.mockReset();
});
it("opens the editor for a resolved file", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "resolved", container_path: "/workspace/a/foo.ts" } });
render(<ViewerApp />);
expect(await screen.findByText("editor for /workspace/a/foo.ts")).toBeInTheDocument();
});
it("lists every path it tried when the file is not found", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "not_found", tried: ["/workspace/a/foo.ts", "/workspace/b/foo.ts"] } });
render(<ViewerApp />);
expect(await screen.findByText(/Could not find/)).toBeInTheDocument();
expect(screen.getByText("/workspace/a/foo.ts")).toBeInTheDocument();
expect(screen.getByText("/workspace/b/foo.ts")).toBeInTheDocument();
});
it("choosing a candidate asks the backend by index and opens the result", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "choose", candidates: ["/workspace/a/foo.ts", "/workspace/b/foo.ts"] } });
commands.viewerChooseFile.mockResolvedValue({ ...base, state: { kind: "resolved", container_path: "/workspace/b/foo.ts" } });
render(<ViewerApp />);
const second = await screen.findByRole("button", { name: "/workspace/b/foo.ts" });
await act(async () => { fireEvent.click(second); });
expect(commands.viewerChooseFile).toHaveBeenCalledWith(1);
expect(await screen.findByText("editor for /workspace/b/foo.ts")).toBeInTheDocument();
});
it("shows a failure to load the state", async () => {
commands.viewerGetState.mockRejectedValue("This window is not a file viewer.");
render(<ViewerApp />);
expect(await screen.findByText("This window is not a file viewer.")).toBeInTheDocument();
});
it("keeps the choice list when choosing fails, and says why", async () => {
commands.viewerGetState.mockResolvedValue({ ...base, state: { kind: "choose", candidates: ["/workspace/a/foo.ts"] } });
commands.viewerChooseFile.mockRejectedValue("That choice is no longer available.");
render(<ViewerApp />);
const only = await screen.findByRole("button", { name: "/workspace/a/foo.ts" });
await act(async () => { fireEvent.click(only); });
expect(await screen.findByText("That choice is no longer available.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "/workspace/a/foo.ts" })).toBeInTheDocument();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import Button from "../components/ui/Button";
import { viewerChooseFile, viewerGetState } from "../lib/tauri-commands";
import type { ViewerState } from "../lib/types";
import EditorPane from "./EditorPane";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
export default function ViewerApp() {
const [state, setState] = useState<ViewerState | { error: string } | null>(null);
const [chooseError, setChooseError] = useState<string | null>(null);
useEffect(() => {
viewerGetState().then(setState, (e) => setState({ error: errorText(e) }));
}, []);
if (state === null) return <p className="p-4 text-sm text-[var(--text-secondary)]">Loading</p>;
if ("error" in state) return <p className="p-4 text-sm">{state.error}</p>;
const choose = (index: number) => {
setChooseError(null);
viewerChooseFile(index).then(setState, (e) => setChooseError(errorText(e)));
};
switch (state.state.kind) {
case "resolved":
return <EditorPane state={state} />;
case "not_found":
return (
<div className="p-4 text-sm">
<p>Could not find <span className="font-mono">{state.raw_path}</span> in the container. Looked in:</p>
<ul className="mt-2 list-disc pl-6 font-mono text-xs text-[var(--text-secondary)]">
{state.state.tried.map((p) => <li key={p}>{p}</li>)}
</ul>
</div>
);
case "choose":
return (
<div className="p-4 text-sm">
<p>Several files match <span className="font-mono">{state.raw_path}</span>. Open which?</p>
<ul className="mt-2 flex flex-col items-start gap-1">
{state.state.candidates.map((p, i) => (
<li key={p}>
<Button size="sm" onClick={() => choose(i)}>
<span className="font-mono">{p}</span>
</Button>
</li>
))}
</ul>
{chooseError && <p role="alert" className="mt-2">{chooseError}</p>}
</div>
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { classifyViewerFile } from "./editability";
import type { ViewerFile } from "../lib/types";
const file = (over: Partial<ViewerFile> = {}): ViewerFile => ({
contents_base64: "", truncated: false, size: 10, hash: "0".repeat(64), editable: true, readonly_reason: null, ...over,
});
const text = new TextEncoder().encode("hello\n");
describe("classifyViewerFile", () => {
it("text in a write root is editable", () => {
expect(classifyViewerFile("/workspace/a/x.md", file(), text)).toEqual({ kind: "text", editable: true, reason: null });
});
it("a truncated file is read-only and says why", () => {
const r = classifyViewerFile("/workspace/a/big.log", file({ truncated: true }), text);
expect(r.editable).toBe(false);
expect(r.reason).toMatch(/1 MiB/);
});
it("Rust's refusal wins and is quoted", () => {
const r = classifyViewerFile("/etc/hosts", file({ editable: false, readonly_reason: "Only /workspace, /home/claude and /tmp can be written." }), text);
expect(r).toEqual({ kind: "text", editable: false, reason: "Only /workspace, /home/claude and /tmp can be written." });
});
it("images and binaries are never editable", () => {
expect(classifyViewerFile("/workspace/a/x.png", file(), new Uint8Array([137, 80]))).toMatchObject({ kind: "image", editable: false });
expect(classifyViewerFile("/workspace/a/x.bin", file(), new Uint8Array([0, 1, 2]))).toMatchObject({ kind: "binary", editable: false });
});
});
+15
View File
@@ -0,0 +1,15 @@
import { imageMimeFor, looksBinary, TEXT_PREVIEW_LIMIT } from "../components/projects/home/filePreview";
import type { ViewerFile } from "../lib/types";
export type ViewerKind = "text" | "image" | "binary";
export interface Editability { kind: ViewerKind; editable: boolean; reason: string | null }
const MIB = TEXT_PREVIEW_LIMIT / (1024 * 1024);
export function classifyViewerFile(path: string, file: ViewerFile, bytes: Uint8Array): Editability {
if (imageMimeFor(path)) return { kind: "image", editable: false, reason: "Images are shown, not edited." };
if (looksBinary(bytes)) return { kind: "binary", editable: false, reason: "This file is not text." };
if (file.truncated) return { kind: "text", editable: false, reason: `Files over ${MIB} MiB are read-only.` };
if (!file.editable) return { kind: "text", editable: false, reason: file.readonly_reason ?? "This location is read-only." };
return { kind: "text", editable: true, reason: null };
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { highlightExtension, highlightLineField, lineRangeToPositions, setHighlight } from "./highlightLine";
describe("lineRangeToPositions", () => {
const doc = Text.of(["one", "two", "three"]);
it("maps 1-based inclusive lines to document offsets", () => {
expect(lineRangeToPositions(doc, 2, 2)).toEqual({ from: 4, to: 4 });
expect(lineRangeToPositions(doc, 1, 3)).toEqual({ from: 0, to: 8 });
});
it("clamps past the end and refuses nonsense", () => {
expect(lineRangeToPositions(doc, 2, 99)).toEqual({ from: 4, to: 8 });
expect(lineRangeToPositions(doc, 99, 100)).toEqual({ from: 8, to: 8 });
expect(lineRangeToPositions(doc, 0, 1)).toEqual({ from: 0, to: 0 });
expect(lineRangeToPositions(doc, 3, 1)).toEqual({ from: 8, to: 8 });
});
});
describe("highlightLineField", () => {
it("decorates every line in the range and clears on null", () => {
let state = EditorState.create({ doc: "a\nb\nc\nd", extensions: [highlightExtension()] });
state = state.update({ effects: setHighlight.of({ from: 2, to: 3 }) }).state;
let count = 0;
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
expect(count).toBe(2);
state = state.update({ effects: setHighlight.of(null) }).state;
count = 0;
state.field(highlightLineField).between(0, state.doc.length, () => { count++; });
expect(count).toBe(0);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
export const setHighlight = StateEffect.define<{ from: number; to: number } | null>();
const lineMark = Decoration.line({ class: "cm-triple-c-target" });
export function lineRangeToPositions(doc: Text, from: number, to: number): { from: number; to: number } | null {
const clamp = (n: number) => Math.min(Math.max(1, Math.floor(n)), doc.lines);
const a = clamp(from);
const b = Math.max(a, clamp(to));
return { from: doc.line(a).from, to: doc.line(b).from };
}
export const highlightLineField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
let next = value.map(tr.changes);
for (const e of tr.effects) {
if (!e.is(setHighlight)) continue;
if (e.value === null) { next = Decoration.none; continue; }
const range = lineRangeToPositions(tr.state.doc, e.value.from, e.value.to);
if (!range) { next = Decoration.none; continue; }
const marks = [];
for (let pos = range.from; pos <= range.to; ) {
const line = tr.state.doc.lineAt(pos);
marks.push(lineMark.range(line.from));
if (line.to + 1 > tr.state.doc.length) break;
pos = line.to + 1;
}
next = Decoration.set(marks, true);
}
return next;
},
provide: (f) => EditorView.decorations.from(f),
});
export function highlightExtension(): Extension {
return [highlightLineField];
}
+21
View File
@@ -0,0 +1,21 @@
/**
* The error strings the Rust side of the viewer produces and this side matches
* on. This file is the one TypeScript copy; the Rust originals are
*
* - `CONFLICT_PREFIX`, `GONE_PREFIX`, `READ_ONLY_MESSAGE` in
* `src-tauri/src/file_viewer/write.rs` (`viewer_write_file` errors), and
* - `NOT_RUNNING_PREFIX` in `src-tauri/src/commands/file_commands.rs`
* (`require_running` and the viewer's "no container" refusal).
*
* `write.rs`'s test `the_frontend_copies_of_the_ipc_messages_match` reads this
* file and fails if any literal here drifts from its Rust original.
*/
/** A save refused because the file changed on disk since its base hash. */
export const CONFLICT_PREFIX = "conflict:";
/** A save refused because the file no longer exists. */
export const GONE_PREFIX = "gone:";
/** A save refused because the container user may not write the file. */
export const READ_ONLY_MESSAGE = "The file is read-only for the container user.";
/** Any command refused because the project's container is not running. */
export const NOT_RUNNING_PREFIX = "Start the project before";
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { languageFor, wrapsLines } from "./languages";
describe("languageFor", () => {
it.each(["a.ts", "a.tsx", "a.js", "a.jsx", "a.mjs", "a.rs", "a.py", "a.json", "a.yaml", "a.yml", "a.toml", "a.sh", "a.bash", "a.css", "a.html", "a.md", "Dockerfile", "Cargo.lock", "README"])(
"resolves %s without throwing", async (name) => {
const result = await languageFor(`/workspace/${name}`);
if (name === "README") {
expect(result).toBeNull();
} else {
expect(result).not.toBeNull();
}
});
it("returns null for an unknown extension", async () => {
await expect(languageFor("/workspace/x.xyz")).resolves.toBeNull();
});
it("returns an extension for markdown", async () => {
await expect(languageFor("/workspace/x.md")).resolves.not.toBeNull();
});
});
describe("wrapsLines", () => {
it("wraps prose, not code", () => {
expect(wrapsLines("x.md")).toBe(true);
expect(wrapsLines("x.txt")).toBe(true);
expect(wrapsLines("x.rs")).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Extension → CodeMirror language, loaded on demand so a window only pays for
* the grammar it shows. Dynamic `import()` becomes a same-origin chunk, fine
* under `script-src 'self'`.
*/
import type { Extension } from "@codemirror/state";
import { extensionOf } from "../components/projects/home/filePreview";
type Loader = () => Promise<Extension>;
const BY_EXTENSION: Record<string, Loader> = {
md: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
markdown: () => import("@codemirror/lang-markdown").then((m) => m.markdown()),
js: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
mjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
cjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })),
ts: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })),
tsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true, typescript: true })),
rs: () => import("@codemirror/lang-rust").then((m) => m.rust()),
py: () => import("@codemirror/lang-python").then((m) => m.python()),
json: () => import("@codemirror/lang-json").then((m) => m.json()),
jsonc: () => import("@codemirror/lang-json").then((m) => m.json()),
yaml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
yml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()),
css: () => import("@codemirror/lang-css").then((m) => m.css()),
html: () => import("@codemirror/lang-html").then((m) => m.html()),
htm: () => import("@codemirror/lang-html").then((m) => m.html()),
toml: () => stream("toml"),
lock: () => stream("toml"),
sh: () => stream("shell"),
bash: () => stream("shell"),
zsh: () => stream("shell"),
};
const BY_BASENAME: Record<string, Loader> = {
dockerfile: () => stream("shell"),
makefile: () => stream("shell"),
};
async function stream(mode: "toml" | "shell"): Promise<Extension> {
const { StreamLanguage } = await import("@codemirror/language");
const parser = mode === "toml"
? (await import("@codemirror/legacy-modes/mode/toml")).toml
: (await import("@codemirror/legacy-modes/mode/shell")).shell;
return StreamLanguage.define(parser);
}
export function languageFor(path: string): Promise<Extension | null> {
const ext = extensionOf(path);
const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
const loader = BY_EXTENSION[ext] ?? BY_BASENAME[base];
return loader ? loader() : Promise.resolve(null);
}
const PROSE = new Set(["md", "markdown", "txt", "rst", "log", ""]);
export function wrapsLines(path: string): boolean {
return PROSE.has(extensionOf(path));
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import ViewerApp from "./ViewerApp";
import "../index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ViewerApp />
</React.StrictMode>,
);
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { decodeViewerText, encodeViewerText } from "./textFormat";
import type { Editability } from "./editability";
const editable: Editability = { kind: "text", editable: true, reason: null };
const bytes = (s: string) => new TextEncoder().encode(s);
const BOM = [0xef, 0xbb, 0xbf];
/** What CodeMirror hands back: every line break normalised to "\n". */
const asEditorText = (s: string) => s.replace(/\r\n?/g, "\n");
describe("decodeViewerText / encodeViewerText", () => {
it("round-trips an LF file byte for byte", () => {
const d = decodeViewerText(bytes("a\nb\n"), editable);
expect(d.format).toEqual({ bom: false, eol: "\n" });
expect(Array.from(encodeViewerText(asEditorText(d.text), d.format))).toEqual(Array.from(bytes("a\nb\n")));
});
it("keeps CRLF line endings through the editor's LF buffer", () => {
const d = decodeViewerText(bytes("a\r\nb\r\nc"), editable);
expect(d.format.eol).toBe("\r\n");
const edited = asEditorText(d.text).replace("b", "B");
expect(new TextDecoder().decode(encodeViewerText(edited, d.format))).toBe("a\r\nB\r\nc");
});
it("uses the dominant separator for a mixed file", () => {
expect(decodeViewerText(bytes("a\r\nb\r\nc\nd"), editable).format.eol).toBe("\r\n");
expect(decodeViewerText(bytes("a\nb\nc\r\nd"), editable).format.eol).toBe("\n");
expect(decodeViewerText(bytes("a\rb\rc"), editable).format.eol).toBe("\r");
});
it("strips a UTF-8 BOM from the text and puts it back on save", () => {
const d = decodeViewerText(new Uint8Array([...BOM, ...bytes("hi\n")]), editable);
expect(d.text).toBe("hi\n");
expect(d.format.bom).toBe(true);
expect(Array.from(encodeViewerText("hi\n", d.format))).toEqual([...BOM, ...bytes("hi\n")]);
});
it("makes invalid UTF-8 read-only rather than rewriting it", () => {
const d = decodeViewerText(new Uint8Array([0x61, 0xff, 0x62]), editable);
expect(d.editability).toMatchObject({ editable: false, reason: expect.stringMatching(/not valid UTF-8/) });
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Byte-faithful text for the editor: a save must change only what the user
* edited. CodeMirror normalises every line break to "\n" and the UTF-8
* decoder drops a BOM, so both are recorded on load and restored on save.
*/
import type { Editability } from "./editability";
export type LineEnding = "\n" | "\r\n" | "\r";
export interface TextFormat { bom: boolean; eol: LineEnding }
const BOM = [0xef, 0xbb, 0xbf];
const NOT_UTF8 = "This file is not valid UTF-8, so it is read-only.";
/** The most common separator in the text; "\n" on a tie or with no breaks. */
function dominantEol(text: string): LineEnding {
let crlf = 0, lf = 0, cr = 0;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (c === 13) {
if (text.charCodeAt(i + 1) === 10) { crlf++; i++; } else cr++;
} else if (c === 10) lf++;
}
if (crlf > lf && crlf >= cr) return "\r\n";
if (cr > lf && cr > crlf) return "\r";
return "\n";
}
export function decodeViewerText(
bytes: Uint8Array,
editability: Editability,
): { text: string; editability: Editability; format: TextFormat } {
const bom = bytes.length >= 3 && BOM.every((b, i) => bytes[i] === b);
const body = bom ? bytes.subarray(3) : bytes;
let text: string;
if (!editability.editable) {
text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(body);
} else {
// An editable file must round-trip, so invalid UTF-8 (which the lenient
// decoder would turn into U+FFFD, and a save would write back) is read-only.
try {
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body);
} catch {
text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(body);
editability = { kind: "text", editable: false, reason: NOT_UTF8 };
}
}
return { text, editability, format: { bom, eol: dominantEol(text) } };
}
/** The editor's "\n"-joined text back to the file's bytes. */
export function encodeViewerText(text: string, format: TextFormat): Uint8Array {
const body = new TextEncoder().encode(format.eol === "\n" ? text : text.split("\n").join(format.eol));
if (!format.bom) return body;
const out = new Uint8Array(body.length + 3);
out.set(BOM, 0);
out.set(body, 3);
return out;
}
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useViewerPolling } from "./useViewerPolling";
describe("useViewerPolling", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const setVisibility = (state: DocumentVisibilityState) => {
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
};
it("ticks on the interval only while visible, and once immediately on becoming visible", async () => {
setVisibility("visible");
const tick = vi.fn(async () => {});
renderHook(() => useViewerPolling(2000, tick, true));
expect(tick).toHaveBeenCalledTimes(1); // initial
await vi.advanceTimersByTimeAsync(4000);
expect(tick).toHaveBeenCalledTimes(3);
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(6000);
expect(tick).toHaveBeenCalledTimes(3);
setVisibility("visible");
expect(tick).toHaveBeenCalledTimes(4);
});
it("does not overlap ticks and stops when disabled", async () => {
setVisibility("visible");
let resolve: () => void = () => {};
const tick = vi.fn(() => new Promise<void>((r) => { resolve = r; }));
const { rerender } = renderHook(({ on }) => useViewerPolling(1000, tick, on), { initialProps: { on: true } });
await vi.advanceTimersByTimeAsync(3000);
expect(tick).toHaveBeenCalledTimes(1);
resolve();
await vi.advanceTimersByTimeAsync(1000);
expect(tick).toHaveBeenCalledTimes(2);
rerender({ on: false });
resolve();
await vi.advanceTimersByTimeAsync(5000);
expect(tick).toHaveBeenCalledTimes(2);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useRef } from "react";
/** A visibility-gated interval that never overlaps its own ticks (spec §5). */
export function useViewerPolling(intervalMs: number, tick: () => Promise<void>, enabled: boolean): void {
const tickRef = useRef(tick);
tickRef.current = tick;
useEffect(() => {
if (!enabled) return;
let disposed = false;
let inFlight = false;
let timer: ReturnType<typeof setInterval> | null = null;
const run = async () => {
if (disposed || inFlight || document.visibilityState !== "visible") return;
inFlight = true;
try { await tickRef.current(); } finally { inFlight = false; }
};
const start = () => { if (timer === null) timer = setInterval(run, intervalMs); };
const stop = () => { if (timer !== null) { clearInterval(timer); timer = null; } };
const onVisibility = () => {
if (document.visibilityState === "visible") { void run(); start(); } else { stop(); }
};
document.addEventListener("visibilitychange", onVisibility);
onVisibility();
return () => {
disposed = true;
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [intervalMs, enabled]);
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { canSave, initialViewerState, pollEffect, reduceViewer, type ViewerDocState } from "./viewerState";
const H1 = "1".repeat(64);
const H2 = "2".repeat(64);
const H3 = "3".repeat(64);
const loaded = (truncated = false): ViewerDocState =>
reduceViewer(initialViewerState, { type: "loaded", hash: H1, truncated });
const poll = (s: ViewerDocState, hash: string | null, exists = true) =>
reduceViewer(s, { type: "polled", poll: { exists, hash, size: exists ? 1 : null } });
describe("reduceViewer", () => {
it("seeds both hashes from an untruncated load", () => {
expect(loaded()).toMatchObject({ doc: "clean", disk: "same", baseHash: H1, diskHash: H1 });
});
it("leaves diskHash unknown after a truncated load, so the first poll seeds it silently", () => {
const s = loaded(true);
expect(s.diskHash).toBeNull();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "same", diskHash: H2 });
expect(pollEffect(s, after)).toBe("none");
});
it("an unchanged poll is a no-op", () => {
const s = loaded();
expect(pollEffect(s, poll(s, H1))).toBe("none");
});
it("a changed poll on a clean doc reloads", () => {
const s = loaded();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "changed", diskHash: H2, doc: "clean" });
expect(pollEffect(s, after)).toBe("reload");
const reloaded = reduceViewer(after, { type: "reloaded", hash: H2, truncated: false, polledHash: H2 });
expect(reloaded).toMatchObject({ disk: "same", baseHash: H2, diskHash: H2, justReloaded: true });
});
it("a truncated reload adopts the polled hash, not the prefix hash; the next identical poll is a no-op", () => {
// A truncated load never gets a comparable full-file hash of its own, so a
// poll-driven reload of a large file must seed diskHash from the poll's
// hash (spec Decision 2) -- otherwise every poll re-triggers a reload.
const seeded = poll(loaded(true), H2);
const changed = poll(seeded, H3);
expect(pollEffect(seeded, changed)).toBe("reload");
const reloaded = reduceViewer(changed, { type: "reloaded", hash: H1, truncated: true, polledHash: changed.diskHash });
expect(reloaded).toMatchObject({ disk: "same", diskHash: H3, baseHash: H1, justReloaded: true });
expect(pollEffect(reloaded, poll(reloaded, H3))).toBe("none");
});
it("a clean doc still marked changed (its reload failed) retries on the next identical poll", () => {
const changed = poll(loaded(), H2);
expect(pollEffect(changed, poll(changed, H2))).toBe("reload");
});
it("a changed poll on a dirty doc shows the banner and never reloads", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const after = poll(s, H2);
expect(after).toMatchObject({ doc: "dirty", disk: "changed" });
expect(pollEffect(s, after)).toBe("banner");
expect(pollEffect(after, poll(after, H2))).toBe("none");
});
it("overwrite-on-save adopts the disk hash as the base", () => {
const s = poll(reduceViewer(loaded(), { type: "edited" }), H2);
const o = reduceViewer(s, { type: "overwrite_on_save" });
expect(o).toMatchObject({ baseHash: H2, disk: "same", overwrite: true, doc: "dirty" });
expect(canSave(o, true)).toBe(true);
});
it("a save clears dirty and aligns hashes; a conflict marks disk changed", () => {
const s = reduceViewer(loaded(), { type: "edited" });
expect(reduceViewer(s, { type: "saved", hash: H2, diskHash: H2 })).toMatchObject({ doc: "clean", disk: "same", baseHash: H2, diskHash: H2, overwrite: false });
expect(reduceViewer(s, { type: "save_conflict" })).toMatchObject({ doc: "dirty", disk: "changed" });
expect(reduceViewer(s, { type: "save_gone" })).toMatchObject({ disk: "gone" });
});
it("a save another writer overtook keeps our base but shows Changed on disk (M2)", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const raced = reduceViewer(s, { type: "saved", hash: H2, diskHash: H3 });
expect(raced).toMatchObject({ doc: "dirty", disk: "changed", baseHash: H2, diskHash: H3, overwrite: false });
expect(canSave(raced, true)).toBe(false);
// The next poll reporting that same foreign hash is quiet: the banner stays up.
const next = poll(raced, H3);
expect(next).toMatchObject({ disk: "changed", doc: "dirty" });
expect(pollEffect(raced, next)).toBe("none");
// Overwrite adopts what is on disk, not our own hash.
expect(reduceViewer(next, { type: "overwrite_on_save" })).toMatchObject({ baseHash: H3, disk: "same" });
});
it("a gone file disables saving but keeps the buffer state", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const gone = poll(s, null, false);
expect(gone).toMatchObject({ disk: "gone", doc: "dirty" });
expect(canSave(gone, true)).toBe(false);
expect(pollEffect(s, gone)).toBe("banner");
});
it("a poll refused as not running flags the container down and a good one clears it", () => {
// Regression: start from a dirty doc, not a clean one -- otherwise
// canSave(down, true) is false purely because doc !== "dirty", and the
// assertion never actually exercises containerDown.
const dirty = reduceViewer(loaded(), { type: "edited" });
const down = reduceViewer(dirty, {
type: "poll_failed",
message: "Start the project before checking this file for changes — it runs inside the running container.",
});
expect(down).toMatchObject({ containerDown: true, pollError: null });
expect(canSave(down, true)).toBe(false);
expect(poll(down, H1).containerDown).toBe(false);
});
it("any other poll failure is kept as its own message, does not claim the container is down, and clears on a good poll", () => {
const dirty = reduceViewer(loaded(), { type: "edited" });
const down = reduceViewer(dirty, { type: "poll_failed", message: "Start the project before checking this file for changes — files live in its container." });
const failed = reduceViewer(down, { type: "poll_failed", message: "Could not check the file: Permission denied" });
expect(failed).toMatchObject({ containerDown: false, pollError: "Could not check the file: Permission denied" });
expect(canSave(failed, true)).toBe(true);
expect(poll(failed, H1)).toMatchObject({ pollError: null, containerDown: false });
expect(poll(failed, null, false)).toMatchObject({ pollError: null, disk: "gone" });
});
it("a hash-less poll and a gone file reappearing both clear the flags; the reappeared file is same", () => {
const gone = poll(loaded(), null, false);
expect(poll(gone, H1)).toMatchObject({ disk: "same" });
expect(poll(gone, null)).toMatchObject({ disk: "gone", containerDown: false, pollError: null });
});
it("canSave needs dirty + editable + disk in sync", () => {
expect(canSave(loaded(), true)).toBe(false);
const dirty = reduceViewer(loaded(), { type: "edited" });
expect(canSave(dirty, true)).toBe(true);
expect(canSave(dirty, false)).toBe(false);
expect(canSave(poll(dirty, H2), true)).toBe(false);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* The viewer's reload/dirty/conflict rules as a pure reducer (spec §5).
*
* Two hashes, deliberately: `baseHash` is what the buffer was loaded from or
* last saved as -- the save's precondition. `diskHash` is the last full-file
* hash the poll reported. They differ only for a truncated (read-only) load,
* where the read's hash covers a prefix and can never equal `sha256sum`; the
* poll then seeds `diskHash` without triggering a reload.
*
* The same prefix-vs-full-file split applies to a poll-driven reload of a
* truncated file: the fresh read's hash is still only a prefix hash, so a
* `reloaded` action for a truncated file adopts the *polled* hash as the new
* `diskHash` rather than the read's own hash. Without this, a large file
* would re-download on every poll tick forever (spec Decision 2).
*/
import type { ViewerPoll } from "../lib/types";
import { NOT_RUNNING_PREFIX } from "./ipcMessages";
export type DocStatus = "clean" | "dirty";
export type DiskStatus = "same" | "changed" | "gone";
export interface ViewerDocState {
doc: DocStatus;
disk: DiskStatus;
/** Hash the buffer was loaded from / last saved as. */
baseHash: string | null;
/** Last known full-file hash on disk (null until known). */
diskHash: string | null;
/** The last poll was refused because the project's container is not running. */
containerDown: boolean;
/**
* The last poll failed for any other reason (an unreadable file, a Docker
* hiccup), with the backend's sentence. Changes on disk go unseen until a
* poll succeeds, but saving stays possible: the write re-checks the hash.
*/
pollError: string | null;
/** Set for one render after a clean reload; UI shows "Reloaded". */
justReloaded: boolean;
/** True when the user chose "Overwrite on save" after a disk change. */
overwrite: boolean;
}
export type ViewerAction =
| { type: "loaded"; hash: string; truncated: boolean }
| { type: "edited" }
| { type: "polled"; poll: ViewerPoll }
| { type: "poll_failed"; message: string }
| { type: "reloaded"; hash: string; truncated: boolean; polledHash: string | null }
| { type: "overwrite_on_save" }
| { type: "saved"; hash: string; diskHash: string }
| { type: "save_conflict" }
| { type: "save_gone" };
export const initialViewerState: ViewerDocState = {
doc: "clean",
disk: "same",
baseHash: null,
diskHash: null,
containerDown: false,
pollError: null,
justReloaded: false,
overwrite: false,
};
export function reduceViewer(state: ViewerDocState, action: ViewerAction): ViewerDocState {
const s = { ...state, justReloaded: false };
switch (action.type) {
case "loaded":
return { ...initialViewerState, baseHash: action.hash, diskHash: action.truncated ? null : action.hash };
case "edited":
return { ...s, doc: "dirty" };
case "polled": {
const ok = { ...s, containerDown: false, pollError: null };
if (!action.poll.exists) return { ...ok, disk: "gone" };
const hash = action.poll.hash;
if (hash === null) return ok;
if (ok.diskHash === null) return { ...ok, diskHash: hash, disk: ok.disk === "gone" ? "same" : ok.disk };
if (hash === ok.diskHash) return { ...ok, disk: ok.disk === "gone" ? "same" : ok.disk };
// Changed on disk. "Overwrite on save" adopted a base; a further change
// on disk invalidates it again.
return { ...ok, diskHash: hash, disk: "changed", overwrite: false };
}
case "poll_failed":
// Only the backend's "Start the project before …" refusal means the
// container is down; anything else is reported as what it says.
return action.message.startsWith(NOT_RUNNING_PREFIX)
? { ...s, containerDown: true, pollError: null }
: { ...s, containerDown: false, pollError: action.message };
case "reloaded":
return {
...s,
doc: "clean",
disk: "same",
baseHash: action.hash,
diskHash: action.truncated ? action.polledHash : action.hash,
justReloaded: true,
overwrite: false,
};
case "overwrite_on_save":
return { ...s, baseHash: s.diskHash, disk: "same", overwrite: true };
case "saved":
// The base is always the hash of the bytes written. If the disk already
// held something else right after the swap, another writer landed after
// us: the buffer is not what is on disk, so say "Changed on disk" (with
// Reload / Overwrite) rather than adopt the other writer's hash (M2).
if (action.diskHash !== action.hash) {
return { ...s, doc: "dirty", disk: "changed", baseHash: action.hash, diskHash: action.diskHash, overwrite: false };
}
return { ...s, doc: "clean", disk: "same", baseHash: action.hash, diskHash: action.hash, overwrite: false };
case "save_conflict":
return { ...s, disk: "changed", overwrite: false };
case "save_gone":
return { ...s, disk: "gone" };
}
}
/** What EditorPane does after a poll: nothing, reload silently, or show the banner. */
export function pollEffect(before: ViewerDocState, after: ViewerDocState): "none" | "reload" | "banner" {
if (after.disk === "gone") return before.disk === "gone" ? "none" : "banner";
if (after.disk !== "changed") return "none";
// A clean doc still marked "changed" means its reload failed; retry it
// rather than leave stale text under a "Changed on disk" badge.
if (after.doc === "clean") return "reload";
return after.diskHash === before.diskHash ? "none" : "banner";
}
export function canSave(state: ViewerDocState, editable: boolean): boolean {
return editable && state.doc === "dirty" && state.disk === "same" && !state.containerDown;
}
+42
View File
@@ -0,0 +1,42 @@
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import type { Extension } from "@codemirror/state";
// Syntax colours come from the `--syntax-*` tokens in index.css (P12), not
// hard-coded hex, even though the values match the GitHub-dark ANSI palette
// TerminalView.tsx already uses.
export const viewerTheme: Extension = [
EditorView.theme(
{
"&": { backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", height: "100%", fontSize: "13px" },
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", caretColor: "var(--accent)" },
".cm-scroller": { overflow: "auto" },
".cm-gutters": { backgroundColor: "var(--bg-secondary)", color: "var(--text-secondary)", borderRight: "1px solid var(--border-color)" },
".cm-activeLine": { backgroundColor: "var(--accent-muted)" },
".cm-activeLineGutter": { backgroundColor: "var(--accent-muted)" },
".cm-triple-c-target": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "var(--accent-muted)" },
".cm-panels": { backgroundColor: "var(--bg-secondary)", color: "var(--text-primary)", borderBottom: "1px solid var(--border-color)" },
".cm-searchMatch": { backgroundColor: "var(--warning-muted)", outline: "1px solid var(--warning)" },
".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "var(--success-muted)" },
},
{ dark: true },
),
syntaxHighlighting(
HighlightStyle.define([
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: "var(--syntax-keyword)" },
{ tag: [t.string, t.special(t.string)], color: "var(--syntax-string)" },
{ tag: [t.comment, t.lineComment, t.blockComment], color: "var(--text-secondary)", fontStyle: "italic" },
{ tag: [t.number, t.bool, t.null, t.atom], color: "var(--syntax-number)" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--syntax-function)" },
{ tag: [t.typeName, t.className, t.namespace], color: "var(--syntax-type)" },
{ tag: [t.propertyName, t.attributeName], color: "var(--syntax-property)" },
{ tag: t.heading, fontWeight: "bold", color: "var(--accent)" },
{ tag: t.emphasis, fontStyle: "italic" },
{ tag: t.strong, fontWeight: "bold" },
{ tag: t.link, color: "var(--accent)", textDecoration: "underline" },
{ tag: t.invalid, color: "var(--syntax-keyword)", textDecoration: "underline wavy" },
]),
),
];
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />