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
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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" })],
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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/) });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
]),
|
||||
),
|
||||
];
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user