feat(file-panels): panels read and write through a file handle

The file tab stats the file and mounts its panel without reading it. Every
panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url,
version-checked writeText/writeBytes (PluginFileConflictError unless force),
and watch. useFileText, useFileBytes and useFileUrl read through it, and
Loading joins @getpaseo/plugin/client/ui. Panels own their loading state.

The host document pipeline, the explorer preview cache and the preview
lifecycle are deleted. The daemon classifies a file version from a bounded
sniff and writes base64 content as bytes behind fileByteWrites.

A deleted file keeps its panel mounted; a read confirms the deletion first,
so a remove-and-rewrite is not reported as one. Native Source decodes with
builtins and highlights in idle-time slices, the lines around the opening
line first, so a large file no longer blocks the JS thread.
This commit is contained in:
Mohamed Boudra
2026-10-20 08:02:49 +02:00
parent 970b41cb24
commit dfb6215de0
103 changed files with 3432 additions and 4264 deletions
+14 -7
View File
@@ -558,13 +558,20 @@ one section's order from built-ins, plugin groups, and the section's preference
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab, rendering host-owned text or
bytes. The tab's bar owns the document's status (size, lines, save state, conflicts) and switches
between the file's panels; Source, Paseo's editor, is a built-in panel for every text file and
always the last choice. Keep editing in the host document so autosave and conflicts have one
owner. See the
[public file panel reference](../public-docs/plugins/reference.md#file-panels) for selectors,
selection, document props, `DocumentFrame`, and the version requirement. The examples live in
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the
same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
make that panel faster instead. Source highlights a large file on native in idle-time slices,
the lines around the opening line first.
The handle checks every write against the version it last read or wrote, so two panels or a panel
and an agent cannot silently overwrite each other. The daemon reports the file's kind (text,
image, binary) with its stat from a bounded sniff, which is how Source claims text files without a
read. See the [public file panel reference](../public-docs/plugins/reference.md#file-panels) for
the author API, selection, `DocumentFrame`, and the version requirement. The examples live in
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
## Contribute buttons
@@ -56,45 +56,12 @@ async function replaceEditorText(page: Page, content: string): Promise<void> {
await editor.type(content);
}
async function replaceFileAfterDeletionWasObserved(input: {
page: Page;
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
content: string;
}): Promise<void> {
const { page, gate, filePath, relativePath, content } = input;
// Recreate the observed race without faking file state: the daemon publishes the
// deletion, then a real read succeeds after the replacement while its ready event waits.
await gate.waitForFileSubscription(relativePath);
gate.holdFileReads(relativePath);
await unlink(filePath);
await gate.waitForHeldFileRead();
await expect(fileCallout(page)).toHaveCount(0);
gate.releaseHeldFileRead();
await expectOnlyFileCallout(page, "File deleted on disk");
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(filePath, content, "utf8");
await gate.waitForHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
}
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
await expect(fileCallout(page)).toHaveCount(1);
await expect(fileCallout(page)).toContainText(title);
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
}
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
}
async function expectDeletedFileNotice(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "File deleted on disk");
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
@@ -140,7 +107,7 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const preserved = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(visibleEditor(page)).toContainText("const preserved = true;");
await recordFileCallouts(page);
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
@@ -189,162 +156,79 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const before = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(visibleEditor(page)).toContainText("const before = true;");
await recordFileCallouts(page);
gate.holdFileReads(relativePath);
gate.holdNextReadyFileUpdate(relativePath);
await replaceEditorText(page, "const saved = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
await gate.waitForHeldFileRead();
await gate.waitForHeldReadyFileUpdate();
gate.releaseHeldReadyFileUpdate();
await expect(visibleEditor(page)).toContainText("const saved = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
gate.releaseHeldFileRead();
});
test("a clean file replaced on disk offers one working reload action", async ({
test("a viewed file deleted and written back shows the new file", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-clean-replacement-",
relativePath: "inventory.md",
content: "# Before\n",
});
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await replaceFileAfterDeletionWasObserved({
page,
gate,
filePath,
relativePath: "inventory.md",
content: "# After\n",
});
gate.releaseHeldReadyFileUpdate();
await expectCleanReplacementCanReload(page);
await unlink(filePath);
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
await writeFile(filePath, "# After\n", "utf8");
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
});
test("a deleted file shows one explanatory notice and no resolution actions", async ({
test("a deleted file keeps the open copy with one notice and no resolution actions", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-deleted-",
relativePath: "deleted.md",
content: "# Present\n",
relativePath: "deleted.ts",
content: "const present = true;\n",
});
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
await expect(visibleEditor(page)).toContainText("const present = true;");
await recordFileCallouts(page);
await unlink(filePath);
await expectDeletedFileNotice(page);
await expectFileCalloutWasRendered(page, "File deleted on disk");
await expect(visibleEditor(page)).toContainText("const present = true;");
});
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-retry-",
relativePath,
content: "# Before\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "# After\n", "utf8");
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
gate.releaseHeldFileRead();
await expectCleanReplacementCanReload(page);
gate.releaseHeldReadyFileUpdate();
});
test("retry clears a transient error when the file is unchanged", async ({
test("a path that stops being a file keeps the open copy and follows the file back", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "unchanged.md";
const relativePath = "retry.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-unchanged-",
prefix: "file-check-error-",
relativePath,
content: "# Unchanged\n",
content: "const before = true;\n",
});
const parkedPath = `${filePath}.parked`;
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
await expect(visibleEditor(page)).toContainText("const before = true;");
await rename(filePath, parkedPath);
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await gate.waitForFileUpdate(relativePath, "error");
await expect(visibleEditor(page)).toContainText("const before = true;");
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await rename(parkedPath, filePath);
await gate.waitForHeldReadyFileUpdate();
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await writeFile(filePath, "const after = true;\n", "utf8");
await expect(visibleEditor(page)).toContainText("const after = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a read-only file error preserves its message and offers retry", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "artifact.bin";
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-read-only-retry-",
relativePath,
content: binaryContent,
});
await gate.waitForFileSubscription(relativePath);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toBeVisible();
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, binaryContent);
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await filePane(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldFileRead();
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a changed file with local edits offers overwrite and a working reload", async ({
@@ -130,7 +130,7 @@ test.describe("CodeMirror workspace file editing", () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
@@ -319,11 +319,7 @@ test.describe("CodeMirror workspace file editing", () => {
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
await writeFile(path.join(session.cwd, "too-large.txt"), "x".repeat(51 * 1024 * 1024), "utf8");
try {
await openAgentRoute(page, session);
@@ -127,7 +127,7 @@ async function expectMarkdownEditor(page: Page, text: string) {
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and edit Markdown through Paseo's document", async ({
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
@@ -204,26 +204,26 @@ test("directory examples mount the PDF frame and edit Markdown through Paseo's d
"Markdown panel example",
);
gate.holdNextClientRequest("fs.file.write.request");
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n");
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await capture(page, testInfo, "markdown-example-dirty-wide");
gate.releaseHeldClientRequest();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// Source is Paseo's editor; its cursor and vim mode join the bar's status.
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
await selectPanel(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
const bar = page.getByTestId("file-source-status").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
@@ -245,7 +245,7 @@ test("directory examples mount the PDF frame and edit Markdown through Paseo's d
);
await replaceMarkdown(page, "# Local edit\n");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible();
await capture(page, testInfo, "markdown-example-conflict-wide");
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect
@@ -17,39 +17,61 @@ const PLUGIN_ID = "file-panel-e2e";
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
const SOURCE = `import React, { useEffect, useState } from "react";
import { Text, View, Pressable } from "react-native";
import { usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import {
PluginFileConflictError,
useFileBytes,
useFileUrl,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin/client";
import { useQueryClient } from "@tanstack/react-query";
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
function Pdf({ document, file, workspaceId }) {
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
function Pdf({ file, workspaceId }) {
usePaseo();
useQueryClient();
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
const bytes = useFileBytes(file);
const url = useFileUrl(file);
const [urlBytes, setUrlBytes] = useState(-1);
const href = url.status === "ready" ? url.url : null;
useEffect(() => {
fetch(document.url).then(response => response.arrayBuffer()).then(bytes => setUrlBytes(bytes.byteLength));
}, [document.url]);
if (href) fetch(href).then(response => response.arrayBuffer()).then(body => setUrlBytes(body.byteLength));
}, [href]);
if (bytes.status !== "ready" || !href) return <Loading />;
return <View style={{ flex: 1 }}>
<Text>PDF plugin: {file.name}</Text>
<Text>Path {file.path}</Text>
<Text>Bytes {document.bytes.length}; URL bytes {urlBytes}</Text>
<Text>Bytes {bytes.bytes.length}; URL bytes {urlBytes}</Text>
<Text>Workspace {workspace?.id}</Text>
<DocumentFrame url={document.url} testID="plugin-pdf-frame" />
<DocumentFrame url={href} testID="plugin-pdf-frame" />
</View>;
}
function Markdown({ document }) {
// Reads once and writes on demand, so a change on disk since that read rejects the write.
function Markdown({ file, tab }) {
const [text, setText] = useState(null);
const [status, setStatus] = useState("");
useEffect(() => { file.readText().then(setText); }, [file]);
const save = (next) => {
tab.setModified(true);
file.writeText(next).then(
() => { tab.setModified(false); setText(next); },
(error) => setStatus(error instanceof PluginFileConflictError ? "Write rejected: changed on disk" : String(error)),
);
};
if (text === null) return <Loading />;
return <View>
<Text>Plugin Markdown: {document.text}</Text>
<Text>{document.editable ? "Plugin document editable" : "Plugin document readonly"}</Text>
<Pressable accessibilityRole="button" onPress={() => document.setText("# Saved by plugin\\n")}>
<Text>Plugin Markdown: {text}</Text>
<Pressable accessibilityRole="button" onPress={() => save("# Saved by plugin\\n")}>
<Text>Edit Markdown through plugin</Text>
</Pressable>
<Text>{status}</Text>
</View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
files: { extensions: ["pdf", "txt"] }, document: "bytes", Component: Pdf });
files: { extensions: ["pdf", "txt"] }, Component: Pdf });
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
files: { extensions: ["md"] }, document: "text", Component: Markdown });
files: { extensions: ["md"] }, Component: Markdown });
return () => {};
}`;
@@ -77,7 +99,7 @@ async function selectSource(page: Page) {
.click();
}
test("directory plugins render PDF bytes and edit associated Markdown through the host document", async ({
test("directory plugins render PDF bytes and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
@@ -151,7 +173,9 @@ test("directory plugins render PDF bytes and edit associated Markdown through th
).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
await associateMarkdownWithPlugin(page);
await expect(page.getByText("Plugin document editable", { exact: true })).toBeVisible();
await expect(
page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }),
).toBeVisible();
gate.holdNextClientRequest("fs.file.write.request");
await editMarkdownThroughPlugin(page);
await gate.waitForHeldClientRequest();
@@ -163,10 +187,33 @@ test("directory plugins render PDF bytes and edit associated Markdown through th
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Saved by plugin\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
// The disk changed since the plugin's last read or write: the write is rejected, not applied.
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Changed outside\n");
await editMarkdownThroughPlugin(page);
await expect(page.getByText("Write rejected: changed on disk", { exact: true })).toBeVisible();
expect(await readFile(path.join(workspace.repoPath, "notes.md"), "utf8")).toBe(
"# Changed outside\n",
);
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
let closePrompt = "";
page.once("dialog", async (dialog) => {
closePrompt = dialog.message();
await dialog.dismiss();
});
await page
.getByTestId("workspace-tab-file_notes.md")
.filter({ visible: true })
.first()
.click({ button: "right" });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect.poll(() => closePrompt).toContain("discard");
await selectSource(page);
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Saved by plugin");
).toHaveText("# Changed outside");
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
await page.screenshot({ path: markdownScreenshot });
await testInfo.attach("plugin-markdown-source-web", {
@@ -20,7 +20,7 @@ function RichMarkdown({ file }) {
}
export default function contribute(client) {
client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType",
files: { extensions: ["md"] }, document: "text", Component: RichMarkdown });
files: { extensions: ["md"] }, Component: RichMarkdown });
return () => {};
}`;
@@ -56,10 +56,7 @@ export type { DraftInput } from "@/stores/draft-store";
export type {
Agent,
ExplorerEntry,
ExplorerFile,
ExplorerEntryKind,
ExplorerFileKind,
ExplorerEncoding,
AgentFileExplorerState,
} from "@/stores/session-store";
@@ -1,34 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { describe, expect, it } from "vitest";
import { explorerFileFromReadResult } from "./read-result";
function textRead(bytes: Uint8Array): FileReadResult {
return {
bytes,
mime: "text/plain",
size: bytes.byteLength,
path: "notes.txt",
kind: "text",
modifiedAt: "2026-07-21T00:00:00.000Z",
};
}
describe("explorerFileFromReadResult", () => {
it("records and hides a leading UTF-8 BOM", () => {
const file = explorerFileFromReadResult(
textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])),
);
expect(file).toMatchObject({ content: "hi", hasBom: true });
});
it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => {
const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi")));
const embedded = explorerFileFromReadResult(
textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])),
);
expect(plain.hasBom).toBe(false);
expect(embedded.hasBom).toBe(false);
});
});
@@ -1,21 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { ExplorerFile } from "@/stores/session-store";
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
const isText = file.kind === "text";
return {
path: file.path,
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
hasBom: isText && hasUtf8Bom(file.bytes),
mimeType: file.mime,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
function hasUtf8Bom(bytes: Uint8Array): boolean {
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
}
+23 -126
View File
@@ -1,125 +1,42 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { FilePanelBarMenu, FilePanelSwitcher, type FilePanelChoice } from "@/file-panels";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
import {
FilePanelBarMenu,
FilePanelSwitcher,
useFilePanelChoiceSnapshot,
type FilePanelChoice,
} from "@/file-panels";
/**
* The file tab's bar: the document's status, which the host owns whatever panel shows it, and the
* switcher between the file's panels. `trailing` is the active panel's own status.
* The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel
* has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest.
*/
export function FilePanelBar({
size,
lineCount,
editorStatus,
conflict,
choice,
trailing,
}: {
size: number;
lineCount?: number;
editorStatus?: FileEditorStatus;
conflict?: FileConflictAlertState;
choice: FilePanelChoice;
trailing?: ReactNode;
}) {
export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) {
const { t } = useTranslation();
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
return (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
{lineCount !== undefined ? (
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
) : null}
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus
? t("panels.file.editor.editorStatus", { status: editorStatus })
: undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{trailing}
</View>
<View style={styles.spacer} />
{modified ? (
<View
style={styles.modifiedDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
testID="file-panel-modified"
/>
) : null}
<FilePanelSwitcher choice={choice} />
<FilePanelBarMenu choice={choice} />
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
</View>
);
}
/** Source's status in the bar: the vim mode and the cursor position. */
export function FileSourceStatus({
cursor,
vimMode,
}: {
cursor: { line: number; column: number };
vimMode: string | null;
}) {
const { t } = useTranslation();
return (
<>
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
Ln {cursor.line}, Col {cursor.column}
</Text>
</>
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
@@ -131,31 +48,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
dirtyDot: {
spacer: { flex: 1 },
modifiedDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
status: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
vim: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));
@@ -1,652 +0,0 @@
import { describe, expect, test } from "vitest";
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
import {
FileEditorModel,
getFileConflictCallout,
type FileEditorClock,
type FileEditorFile,
type FileEditorObservation,
type FileEditorSession,
type FileObservationSource,
} from "./model";
class TestClock implements FileEditorClock {
private callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout(): void {
this.callback = null;
}
fire(): void {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
class FileSession implements FileEditorSession {
file: FileEditorFile;
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
nextWrite: FileWriteResult | Error | null = null;
private pendingWrite: Promise<FileWriteResult> | null = null;
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
constructor(file: FileEditorFile) {
this.file = file;
}
async write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
this.writes.push(input);
if (this.pendingWrite) return this.pendingWrite;
if (this.nextWrite instanceof Error) throw this.nextWrite;
if (this.nextWrite) return this.nextWrite;
return {
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: input.content.length,
};
}
holdNextWrite(): void {
this.pendingWrite = new Promise((resolve) => {
this.resolvePendingWrite = resolve;
});
}
finishHeldWrite(result: FileWriteResult): void {
this.resolvePendingWrite?.(result);
this.pendingWrite = null;
this.resolvePendingWrite = null;
}
}
class ObservationSource implements FileObservationSource {
observation: FileEditorObservation | null;
refreshes = 0;
private readonly listeners = new Set<() => void>();
constructor(observation: FileEditorObservation | null) {
this.observation = observation;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
getObservation(): FileEditorObservation | null {
return this.observation;
}
refresh = (): void => {
this.refreshes += 1;
};
emit(observation: FileEditorObservation): void {
this.observation = observation;
for (const listener of this.listeners) listener();
}
}
function ready(
modifiedAt = "2026-07-18T00:00:00.000Z",
size = 3,
): Extract<FileVersion, { status: "ready" }> {
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
}
interface MakeModelInput {
content?: string;
hasBom?: boolean;
}
function makeModel(input: MakeModelInput = {}) {
const file = {
content: input.content ?? "one",
hasBom: input.hasBom ?? false,
version: ready() as Extract<FileVersion, { status: "ready" }>,
};
const session = new FileSession(file);
const clock = new TestClock();
return { model: new FileEditorModel({ file, session, clock }), session, clock };
}
function observeFile(model: FileEditorModel, file: FileEditorFile): void {
model.receiveFileObservation({ status: "ready", file });
}
function observeVersion(model: FileEditorModel, version: FileEditorObservation): void {
model.receiveFileObservation(version);
}
describe("FileEditorModel", () => {
test("tracks whether the current buffer differs from persisted content", async () => {
const { model } = makeModel();
expect(model.getSnapshot().modified).toBe(false);
model.edit("two");
expect(model.getSnapshot().modified).toBe(true);
model.edit("one");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
model.edit("saved");
await model.save();
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
const { model } = makeModel();
observeFile(model, {
content: "one",
hasBom: false,
version: { ...ready(), revision: "precise-revision" },
});
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
});
test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "one",
hasBom: false,
version: { ...ready(), revision: "replacement-revision" },
});
expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" });
await model.save();
expect(session.writes).toEqual([
{
content: "local",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
expectedRevision: "replacement-revision",
},
]);
});
test("adopts an external BOM change before saving a dirty buffer", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "one",
hasBom: true,
version: { ...ready(), revision: "replacement-revision" },
});
await model.save();
expect(session.writes).toEqual([
{
content: "\uFEFFlocal",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
expectedRevision: "replacement-revision",
},
]);
});
test("conflicts when a same-content observation changes the BOM during a save", async () => {
const { model, session } = makeModel();
session.holdNextWrite();
model.edit("saved");
const save = model.save();
observeFile(model, {
content: "saved",
hasBom: true,
version: ready("2026-07-18T00:00:02.000Z", 6),
});
session.finishHeldWrite({
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: 5,
});
await save;
expect(model.getSnapshot()).toMatchObject({
status: "conflict",
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
});
});
test("ignores a settled observation that was already consumed", () => {
const { model } = makeModel();
const observation: FileEditorObservation = {
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
};
let emissions = 0;
model.subscribe(() => {
emissions += 1;
});
model.receiveFileObservation(observation);
const emissionsAfterFirstDelivery = emissions;
model.receiveFileObservation(observation);
expect(emissions).toBe(emissionsAfterFirstDelivery);
});
test("does not replay the pre-save observation while its refresh is pending", async () => {
const { model } = makeModel();
const observation: FileEditorObservation = {
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
};
model.receiveFileObservation(observation);
model.edit("saved");
await model.save();
model.receiveFileObservation(observation);
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" });
});
test("does not reload stale bytes after a write conflict", async () => {
const { model, session } = makeModel();
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 8),
};
model.edit("important local work");
await model.save();
await model.reload();
expect(model.getSnapshot()).toMatchObject({
status: "conflict",
content: "important local work",
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
});
});
test("reloads a write conflict only after refreshed bytes arrive", async () => {
const { model, session } = makeModel();
const source = new ObservationSource({
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
});
model.connectFileObservations(source);
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 4),
};
model.edit("local");
await model.save();
await model.reload();
expect(source.refreshes).toBe(1);
expect(model.getSnapshot().content).toBe("local");
source.emit({
status: "ready",
file: {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
},
});
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("abandons a deferred reload when its refresh fails", async () => {
const { model, session } = makeModel();
const source = new ObservationSource({
status: "ready",
file: { content: "one", hasBom: false, version: ready() },
});
model.connectFileObservations(source);
session.nextWrite = {
status: "conflict",
version: ready("2026-07-18T00:00:02.000Z", 4),
};
model.edit("local");
await model.save();
await model.reload();
source.emit({
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "File unavailable.",
});
model.edit("new local work");
source.emit({
status: "ready",
file: {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:03.000Z", 4),
},
});
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" });
});
test("keeps a newer edit modified when an older save finishes", async () => {
const { model, session } = makeModel();
session.holdNextWrite();
model.edit("saving");
const save = model.save();
model.edit("newer edit");
session.finishHeldWrite({
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: 6,
});
await save;
expect(model.getSnapshot()).toMatchObject({
status: "dirty",
content: "newer edit",
modified: true,
});
});
test("autosaves the latest edit after inactivity", async () => {
const { model, session, clock } = makeModel();
model.edit("two");
model.edit("three");
clock.fire();
await Promise.resolve();
expect(session.writes).toEqual([
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("keeps CRLF content in file form", async () => {
const { model, session } = makeModel({ content: "one\r\ntwo\r\n" });
expect(model.getSnapshot()).toMatchObject({
content: "one\r\ntwo\r\n",
lineSeparator: "\r\n",
});
model.edit("one\r\ntwo\r\nthree\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "one\r\ntwo\r\nthree\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
]);
});
test("restores a UTF-8 BOM before writing a CRLF file", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("saved\r\n");
await model.save();
model.edit("saved again\r\n");
await model.save();
expect(session.writes).toEqual([
{
content: "\uFEFFsaved\r\n",
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
},
{
content: "\uFEFFsaved again\r\n",
expectedModifiedAt: "2026-07-18T00:00:01.000Z",
},
]);
});
test("uses the first line separator when a file mixes styles", () => {
const { model } = makeModel({ content: "one\r\ntwo\nthree\r" });
expect(model.getSnapshot().lineSeparator).toBe("\r\n");
});
test("reloads a clean editor when the disk version changes", async () => {
const { model, session } = makeModel();
session.file = {
content: "external",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
};
observeFile(model, session.file);
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
});
test("adopts the format from a clean remote refresh", async () => {
const { model, session } = makeModel({ content: "local\r\n", hasBom: true });
session.file = {
content: "remote\n",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 7) as Extract<FileVersion, { status: "ready" }>,
};
observeFile(model, session.file);
expect(model.getSnapshot().lineSeparator).toBe("\n");
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
test("applies consecutive clean disk observations", () => {
const { model } = makeModel();
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
observeFile(model, { content: "first", hasBom: false, version: firstVersion });
observeFile(model, { content: "latest", hasBom: false, version: latestVersion });
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
});
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
const { model, session } = makeModel();
model.edit("local");
observeFile(model, {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
});
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
await model.overwrite();
expect(session.writes).toEqual([
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("keeps the local CRLF and BOM when overwriting a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
observeFile(model, {
content: "disk",
hasBom: false,
version: ready("2026-07-18T00:00:02.000Z", 4),
});
await model.overwrite();
expect(session.writes).toEqual([
{
content: "\uFEFFlocal\r\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
},
]);
});
test("reload discards a conflicted local buffer for the disk contents", async () => {
const { model, session } = makeModel();
model.edit("local");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk", hasBom: false, version: diskVersion };
observeFile(model, session.file);
await model.reload();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("adopts the remote format when reloading a conflict", async () => {
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
model.edit("local\r\n");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
observeFile(model, session.file);
await model.reload();
model.edit("saved\n");
await model.save();
expect(session.writes.at(-1)).toEqual({
content: "saved\n",
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
});
});
test("reports failed saves without losing the local buffer", async () => {
const { model, session } = makeModel();
session.nextWrite = new Error("disk full");
model.edit("important local work");
await model.save();
expect(model.getSnapshot()).toMatchObject({
status: "error",
content: "important local work",
error: "disk full",
});
});
test("a deletion conflicts with local changes and stops autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" });
clock.fire();
expect(model.getSnapshot().status).toBe("conflict");
expect(session.writes).toEqual([]);
});
test("clears a transient check error when the recovered file is unchanged", () => {
const { model } = makeModel();
observeVersion(model, {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
observeFile(model, { content: "one", hasBom: false, version: ready() });
expect(model.getSnapshot()).toMatchObject({
status: "clean",
modified: false,
observedVersion: { status: "ready" },
error: null,
});
});
test("resumes autosave when a dirty file recovers unchanged from a check error", async () => {
const { model, session, clock } = makeModel();
model.edit("local");
observeVersion(model, {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
observeFile(model, { content: "one", hasBom: false, version: ready() });
clock.fire();
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
expect(session.writes).toEqual([
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
]);
});
test("dispose cancels pending autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
model.dispose();
clock.fire();
expect(session.writes).toEqual([]);
});
test("suspends a pending autosave while close confirmation is active", async () => {
const { model, session, clock } = makeModel();
model.edit("local");
const resume = model.suspendAutosave();
clock.fire();
expect(session.writes).toEqual([]);
resume();
clock.fire();
await Promise.resolve();
expect(session.writes).toHaveLength(1);
});
test("maps conflict versions to one exhaustive callout state", () => {
const { model } = makeModel();
const snapshot = model.getSnapshot();
expect(getFileConflictCallout(snapshot)).toBeNull();
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
modified: true,
observedVersion: ready("2026-07-18T00:00:01.000Z"),
}),
).toEqual({ kind: "changed", canOverwrite: true });
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" },
}),
).toEqual({ kind: "deleted" });
expect(
getFileConflictCallout({
...snapshot,
status: "conflict",
observedVersion: {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "unreadable",
},
}),
).toEqual({ kind: "checkFailed" });
});
});
-402
View File
@@ -1,402 +0,0 @@
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error";
export type FileLineSeparator = "\n" | "\r\n" | "\r";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
lineSeparator: FileLineSeparator;
modified: boolean;
version: FileVersion;
observedVersion: FileVersion;
error: string | null;
}
export type FileConflictCallout =
| { kind: "changed"; canOverwrite: boolean }
| { kind: "deleted" }
| { kind: "checkFailed" };
export interface FileEditorFile {
content: string;
hasBom: boolean;
version: Extract<FileVersion, { status: "ready" }>;
}
export interface FileEditorSession {
write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult>;
}
export type FileEditorObservation =
| { status: "ready"; file: FileEditorFile }
| Extract<FileVersion, { status: "missing" | "error" }>;
export interface FileObservationSource {
subscribe(listener: () => void): () => void;
getObservation(): FileEditorObservation | null;
refresh(): void;
}
type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion };
export interface FileEditorClock {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
}
const systemClock: FileEditorClock = {
setTimeout(callback, delay) {
return globalThis.setTimeout(callback, delay);
},
clearTimeout(handle) {
globalThis.clearTimeout(handle);
},
};
export class FileEditorModel {
private readonly session: FileEditorSession;
private readonly clock: FileEditorClock;
private readonly listeners = new Set<() => void>();
private snapshot: FileEditorSnapshot;
private autosave: ReturnType<typeof setTimeout> | null = null;
private saveSequence = 0;
private disposed = false;
private observedWhileSaving: FileEditorObservation | null = null;
private observed: ObservedDiskState;
private lastReceivedObservation: FileEditorObservation | null = null;
private refreshObservation: (() => void) | null = null;
private reloadRequested = false;
private persistedContent: string;
private hasBom: boolean;
private unsubscribeObservationSource: (() => void) | null = null;
constructor(input: {
file: FileEditorFile;
session: FileEditorSession;
clock?: FileEditorClock;
}) {
this.session = input.session;
this.clock = input.clock ?? systemClock;
this.persistedContent = input.file.content;
this.hasBom = input.file.hasBom;
this.observed = { status: "ready", file: input.file };
this.snapshot = {
status: "clean",
content: input.file.content,
lineSeparator: detectLineSeparator(input.file.content),
modified: false,
version: input.file.version,
observedVersion: input.file.version,
error: null,
};
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileEditorSnapshot => this.snapshot;
connectFileObservations(source: FileObservationSource): void {
this.disconnectFileObservations();
this.refreshObservation = source.refresh;
const receiveObservation = () => {
const observation = source.getObservation();
if (observation) this.receiveFileObservation(observation);
};
this.unsubscribeObservationSource = source.subscribe(receiveObservation);
receiveObservation();
}
disconnectFileObservations(): void {
this.unsubscribeObservationSource?.();
this.unsubscribeObservationSource = null;
this.refreshObservation = null;
}
edit(content: string): void {
if (this.disposed || content === this.snapshot.content) return;
this.reloadRequested = false;
const modified = content !== this.persistedContent;
let status: FileEditorStatus = modified ? "dirty" : "clean";
if (this.snapshot.status === "conflict") {
status = "conflict";
}
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
async save(): Promise<void> {
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
return;
}
if (this.snapshot.observedVersion.status !== "ready") {
this.enterConflict(this.snapshot.observedVersion);
return;
}
await this.performWrite(this.snapshot.observedVersion);
}
receiveFileObservation(observation: FileEditorObservation): void {
if (this.disposed || observation === this.lastReceivedObservation) return;
this.lastReceivedObservation = observation;
const version = observationVersion(observation);
this.observed = observation;
this.setSnapshot({ ...this.snapshot, observedVersion: version });
if (this.snapshot.status === "saving") {
this.observedWhileSaving = observation;
return;
}
if (observation.status !== "ready") {
this.reloadRequested = false;
this.enterConflict(version);
return;
}
if (this.reloadRequested) {
this.reloadRequested = false;
this.applyFile(observation.file);
return;
}
if (observation.file.content === this.persistedContent) {
this.adoptUnchangedFile(observation.file);
return;
}
if (this.snapshot.status === "clean") {
this.applyFile(observation.file);
return;
}
this.enterConflict(version);
}
async overwrite(): Promise<void> {
if (this.disposed || this.snapshot.status !== "conflict") return;
if (this.snapshot.observedVersion.status !== "ready") return;
await this.performWrite(this.snapshot.observedVersion);
}
async reload(): Promise<void> {
if (this.disposed) return;
if (this.observed.status !== "ready") {
this.reloadRequested = true;
this.refreshObservation?.();
return;
}
this.applyFile(this.observed.file);
}
dispose(): void {
this.disposed = true;
this.reloadRequested = false;
this.saveSequence += 1;
this.clearAutosave();
this.disconnectFileObservations();
this.listeners.clear();
}
suspendAutosave(): () => void {
const wasScheduled = this.autosave !== null;
this.clearAutosave();
let resumed = false;
return () => {
if (resumed || this.disposed) return;
resumed = true;
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
};
}
private async performWrite(
expectedVersion: Extract<FileVersion, { status: "ready" }>,
): Promise<void> {
this.clearAutosave();
const sequence = ++this.saveSequence;
const content = this.snapshot.content;
const hasBom = this.hasBom;
this.observedWhileSaving = null;
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
const serializedContent = hasBom ? `\uFEFF${content}` : content;
let result: FileWriteResult;
try {
result = await this.session.write({
content: serializedContent,
expectedModifiedAt: expectedVersion.modifiedAt,
expectedRevision: expectedVersion.revision,
});
} catch (error) {
if (this.disposed || sequence !== this.saveSequence) return;
this.setSnapshot({
...this.snapshot,
status: "error",
error: error instanceof Error ? error.message : String(error),
});
return;
}
if (this.disposed || sequence !== this.saveSequence) return;
if (result.status === "error") {
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
return;
}
if (result.status === "conflict") {
this.observed = { status: "unsettled", version: result.version };
this.enterConflict(result.version);
return;
}
const writtenVersion: Extract<FileVersion, { status: "ready" }> = {
status: "ready",
cwd: this.snapshot.version.cwd,
path: this.snapshot.version.path,
size: result.size,
modifiedAt: result.modifiedAt,
revision: result.revision,
};
const pending = this.takeObservedWhileSaving();
this.persistedContent = content;
if (pending && !observationMatchesWrite(pending, content, hasBom)) {
const pendingVersion = observationVersion(pending);
this.observed = pending;
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
version: writtenVersion,
observedVersion: pendingVersion,
error: null,
});
return;
}
const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion;
this.observed = pending ?? { status: "unsettled", version: writtenVersion };
const modified = this.snapshot.content !== this.persistedContent;
this.setSnapshot({
...this.snapshot,
status: modified ? "dirty" : "clean",
modified,
version: settledVersion,
observedVersion: settledVersion,
error: null,
});
if (modified) this.scheduleAutosave();
}
private applyFile(file: FileEditorFile): void {
this.clearAutosave();
this.saveSequence += 1;
this.persistedContent = file.content;
this.hasBom = file.hasBom;
this.observed = { status: "ready", file };
this.setSnapshot({
status: "clean",
content: file.content,
lineSeparator: detectLineSeparator(file.content),
modified: false,
version: file.version,
observedVersion: file.version,
error: null,
});
}
private takeObservedWhileSaving(): FileEditorObservation | null {
const observation = this.observedWhileSaving;
this.observedWhileSaving = null;
return observation;
}
private enterConflict(version: FileVersion): void {
this.clearAutosave();
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
observedVersion: version,
error: version.status === "error" ? version.error : null,
});
}
private adoptUnchangedFile(file: FileEditorFile): void {
this.hasBom = file.hasBom;
this.observed = { status: "ready", file };
const modified = this.snapshot.content !== this.persistedContent;
const recovering = this.snapshot.status === "conflict";
let status = this.snapshot.status;
if (recovering) status = modified ? "dirty" : "clean";
this.setSnapshot({
...this.snapshot,
status,
modified,
version: file.version,
observedVersion: file.version,
error: recovering ? null : this.snapshot.error,
});
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
private scheduleAutosave(): void {
this.clearAutosave();
this.autosave = this.clock.setTimeout(() => {
this.autosave = null;
void this.save();
}, 800);
}
private clearAutosave(): void {
if (!this.autosave) return;
this.clock.clearTimeout(this.autosave);
this.autosave = null;
}
private setSnapshot(snapshot: FileEditorSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null {
if (snapshot.status !== "conflict") return null;
switch (snapshot.observedVersion.status) {
case "ready":
return { kind: "changed", canOverwrite: snapshot.modified };
case "missing":
return { kind: "deleted" };
case "error":
return { kind: "checkFailed" };
default:
return assertNever(snapshot.observedVersion);
}
}
function assertNever(value: never): never {
throw new Error(`Unexpected file version: ${JSON.stringify(value)}`);
}
function detectLineSeparator(content: string): FileLineSeparator {
for (let index = 0; index < content.length; index += 1) {
const character = content.charCodeAt(index);
if (character === 10) return "\n";
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
}
return "\n";
}
function observationVersion(observation: FileEditorObservation): FileVersion {
return observation.status === "ready" ? observation.file.version : observation;
}
function observationMatchesWrite(
observation: FileEditorObservation,
content: string,
hasBom: boolean,
): boolean {
return (
observation.status === "ready" &&
observation.file.content === content &&
observation.file.hasBom === hasBom
);
}
@@ -1,60 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { describe, expect, test } from "vitest";
import type { LiveFileObservation } from "../live-file/model";
import { createFileObservationSource, type LiveObservationSource } from "./observation-source";
class TestLiveObservationSource implements LiveObservationSource {
observation: LiveFileObservation | null;
constructor(observation: LiveFileObservation | null) {
this.observation = observation;
}
subscribe(): () => void {
return () => undefined;
}
getObservation(): LiveFileObservation | null {
return this.observation;
}
refresh(): void {}
}
function readyObservation(): LiveFileObservation {
const file: FileReadResult = {
bytes: new TextEncoder().encode("one"),
mime: "text/plain",
size: 3,
path: "file.ts",
kind: "text",
modifiedAt: "2026-07-18T00:00:00.000Z",
revision: "revision-1",
};
return {
status: "ready",
file,
version: {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
};
}
describe("createFileObservationSource", () => {
test("returns the same editor observation until the live observation changes", () => {
const liveFile = new TestLiveObservationSource(readyObservation());
const source = createFileObservationSource(liveFile);
const first = source.getObservation();
const repeated = source.getObservation();
expect(repeated).toBe(first);
liveFile.observation = readyObservation();
expect(source.getObservation()).not.toBe(first);
});
});
@@ -1,50 +0,0 @@
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { LiveFileObservation } from "../live-file/model";
import type { FileEditorObservation, FileObservationSource } from "./model";
export interface LiveObservationSource {
subscribe(listener: () => void): () => void;
getObservation(): LiveFileObservation | null;
refresh(): void;
}
export function createFileObservationSource(
liveFile: LiveObservationSource,
): FileObservationSource {
let liveObservation: LiveFileObservation | null = null;
let editorObservation: FileEditorObservation | null = null;
return {
subscribe: liveFile.subscribe,
refresh: liveFile.refresh,
getObservation() {
const nextLiveObservation = liveFile.getObservation();
if (nextLiveObservation === liveObservation) return editorObservation;
liveObservation = nextLiveObservation;
editorObservation = fileEditorObservation(nextLiveObservation);
return editorObservation;
},
};
}
function fileEditorObservation(
observation: LiveFileObservation | null,
): FileEditorObservation | null {
if (!observation || observation.status !== "ready") return observation;
const decodedFile = explorerFileFromReadResult(observation.file);
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
return {
status: "error",
cwd: observation.version.cwd,
path: observation.version.path,
error: "File is no longer text.",
};
}
return {
status: "ready",
file: {
content: decodedFile.content,
hasBom: decodedFile.hasBom,
version: observation.version,
},
};
}
@@ -1,61 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { LiveFileModel, type LiveFileSession } from "./model";
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
import { isWeb } from "@/constants/platform";
export function useLiveFile(input: {
client: DaemonClient | null;
cwd: string | null;
path: string | null;
enabled: boolean;
liveUpdates: boolean;
}) {
const [model] = useState(() => new LiveFileModel());
const session = useMemo<LiveFileSession | null>(() => {
if (!input.client) return null;
const client = input.client;
return {
subscribe(target, onVersion) {
return client.subscribeFile(target, onVersion);
},
read(target) {
return client.readFile(
target.cwd,
target.path,
undefined,
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
);
},
};
}, [input.client]);
useEffect(() => {
if (!input.enabled || !session || !input.cwd || !input.path) {
model.close();
return;
}
model.open({
session,
target: { cwd: input.cwd, path: input.path },
liveUpdates: input.liveUpdates,
});
return () => model.close();
}, [input.cwd, input.enabled, input.liveUpdates, input.path, model, session]);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
return {
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
error: snapshot.read.status === "error" ? snapshot.read.error : null,
// Effects start reads after the first render. An enabled target is therefore
// already in the producer's initial/retargeting display state.
isFetching:
input.enabled &&
(snapshot.read.status === "pending" ||
(snapshot.read.status === "idle" && snapshot.observation === null)),
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
refresh: model.refresh,
model,
snapshot,
};
}
@@ -1,344 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import { describe, expect, it, vi } from "vitest";
import { LiveFileModel, type LiveFileSession, type LiveFileSubscription } from "./model";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function ready(revision = "revision-1"): Extract<FileVersion, { status: "ready" }> {
return {
status: "ready",
cwd: "/workspace",
path: "file.ts",
size: 3,
modifiedAt: "2026-07-30T00:00:00.000Z",
revision,
};
}
function file(revision = "revision-1", content = "one"): FileReadResult {
return {
bytes: new TextEncoder().encode(content),
mime: "text/plain",
size: content.length,
path: "file.ts",
kind: "text",
modifiedAt: "2026-07-30T00:00:00.000Z",
revision,
};
}
class TestLiveFileSession implements LiveFileSession {
readonly reads: Deferred<FileReadResult>[] = [];
subscribeAttempts = 0;
private onVersion: ((version: FileVersion) => void) | null = null;
private readonly initial: FileVersion;
constructor(initial: FileVersion = ready()) {
this.initial = initial;
}
async subscribe(
_target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
this.subscribeAttempts += 1;
this.onVersion = onVersion;
return { initial: this.initial, unsubscribe: () => (this.onVersion = null) };
}
read(): Promise<FileReadResult> {
const request = deferred<FileReadResult>();
this.reads.push(request);
return request.promise;
}
emit(version: FileVersion): void {
this.onVersion?.(version);
}
}
class DeferredSubscribeSession implements LiveFileSession {
readonly reads: Deferred<FileReadResult>[] = [];
readonly subscription = deferred<LiveFileSubscription>();
private onVersion: ((version: FileVersion) => void) | null = null;
subscribe(
_target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
this.onVersion = onVersion;
return this.subscription.promise;
}
read(): Promise<FileReadResult> {
const request = deferred<FileReadResult>();
this.reads.push(request);
return request.promise;
}
emit(version: FileVersion): void {
this.onVersion?.(version);
}
}
class FlakySubscriptionSession extends TestLiveFileSession {
override async subscribe(
target: { cwd: string; path: string },
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription> {
if (this.subscribeAttempts === 0) {
this.subscribeAttempts += 1;
throw new Error("Connection closed.");
}
return super.subscribe(target, onVersion);
}
}
async function waitForRead(
session: { reads: Deferred<FileReadResult>[] },
count: number,
): Promise<void> {
await vi.waitFor(() => expect(session.reads).toHaveLength(count));
}
describe("LiveFileModel", () => {
it("reports initial and read-pending work before any terminal observation", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
expect(model.getSnapshot()).toEqual({
observation: null,
read: { status: "pending", requested: false },
});
});
it("reports read error as terminal and recovers on refresh", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
session.reads[0].reject(new Error("read failed"));
await vi.waitFor(() =>
expect(model.getSnapshot().read).toEqual({ status: "error", error: "read failed" }),
);
model.refresh();
await waitForRead(session, 2);
session.reads[1].resolve(file("revision-2"));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
});
it("does not publish a missing candidate when the following read succeeds", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
const publishedStatuses: string[] = [];
model.subscribe(() => {
const observation = model.getObservation();
if (observation) publishedStatuses.push(observation.status);
});
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
await waitForRead(session, 2);
session.reads[1].resolve(file());
await vi.waitFor(() => expect(model.getSnapshot().read).toEqual({ status: "idle" }));
expect(publishedStatuses).not.toContain("missing");
});
it("publishes missing when the following read also fails", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
await waitForRead(session, 2);
session.reads[1].reject(new Error("File not found."));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
});
it("preserves a missing candidate for its queued confirming read", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
session.reads[0].resolve(file());
await waitForRead(session, 2);
session.reads[1].reject(new Error("File not found."));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
});
it("recovers only from a read begun after a missing observation", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
model.refresh();
await waitForRead(session, 2);
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
session.reads[1].resolve(file("revision-2", "two"));
await waitForRead(session, 3);
expect(model.getObservation()?.status).toBe("ready");
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
session.reads[2].resolve(file("revision-2", "two"));
await vi.waitFor(() => {
expect(model.getSnapshot()).toMatchObject({
observation: {
status: "ready",
version: { revision: "revision-2" },
file: { revision: "revision-2" },
},
read: { status: "idle" },
});
});
});
it("keeps an actionable error visible while refresh fails", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
session.emit({
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
});
await waitForRead(session, 2);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
session.reads[1].reject(new Error("File unavailable."));
await vi.waitFor(() => {
expect(model.getSnapshot()).toMatchObject({
observation: { status: "error", error: "Requested path is not a file" },
read: { status: "error", error: "File unavailable." },
});
});
model.refresh();
await waitForRead(session, 3);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
});
it("keeps an update that arrives before the subscription response", async () => {
const session = new DeferredSubscribeSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
session.emit(ready("revision-2"));
session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} });
await waitForRead(session, 1);
expect(model.getObservation()).toBeNull();
});
it("retries a failed subscription when the user refreshes", async () => {
const session = new FlakySubscriptionSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
expect(session.subscribeAttempts).toBe(1);
session.reads[0].resolve(file());
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
model.refresh();
await waitForRead(session, 2);
expect(session.subscribeAttempts).toBe(2);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
});
it("reads once without subscribing when live updates are disabled", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: false,
});
await waitForRead(session, 1);
expect(session.subscribeAttempts).toBe(0);
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
});
it("ignores a read that completes after the model closes", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({
session,
target: { cwd: "/workspace", path: "file.ts" },
liveUpdates: true,
});
await waitForRead(session, 1);
model.close();
session.reads[0].resolve(file());
await Promise.resolve();
expect(model.getSnapshot()).toEqual({
observation: null,
read: { status: "idle" },
});
});
});
@@ -1,272 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
export interface LiveFileTarget {
cwd: string;
path: string;
}
export interface LiveFileSubscription {
initial: FileVersion;
unsubscribe(): void;
}
export interface LiveFileSession {
subscribe(
target: LiveFileTarget,
onVersion: (version: FileVersion) => void,
): Promise<LiveFileSubscription>;
read(target: LiveFileTarget): Promise<FileReadResult>;
}
export type LiveFileReadState =
| { status: "idle" }
| { status: "pending"; requested: boolean }
| { status: "error"; error: string };
export type LiveFileObservation =
| {
status: "ready";
version: Extract<FileVersion, { status: "ready" }>;
file: FileReadResult;
}
| Extract<FileVersion, { status: "missing" | "error" }>;
export interface LiveFileSnapshot {
observation: LiveFileObservation | null;
read: LiveFileReadState;
}
const idleSnapshot: LiveFileSnapshot = {
observation: null,
read: { status: "idle" },
};
export class LiveFileModel {
private readonly listeners = new Set<() => void>();
private snapshot: LiveFileSnapshot = idleSnapshot;
private session: LiveFileSession | null = null;
private target: LiveFileTarget | null = null;
private liveUpdates = false;
private unsubscribe: (() => void) | null = null;
private subscriptionPending = false;
private generation = 0;
private issuedReadSequence = 0;
private activeReadSequence: number | null = null;
private activeReadRequested = false;
private observedAfterReadSequence: number | null = null;
private candidateVersion: FileVersion | null = null;
private refreshQueued = false;
private requestedRefreshQueued = false;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): LiveFileSnapshot => this.snapshot;
getObservation = (): LiveFileObservation | null => this.snapshot.observation;
open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void {
this.reset();
this.session = input.session;
this.target = input.target;
this.liveUpdates = input.liveUpdates;
const generation = this.generation;
if (!input.liveUpdates) {
this.scheduleRead(false);
return;
}
void this.startSubscription(generation);
}
close(): void {
this.reset();
}
refresh = (): void => {
if (!this.session || !this.target) return;
if (this.liveUpdates && !this.unsubscribe && !this.subscriptionPending) {
void this.startSubscription(this.generation);
}
this.scheduleRead(true);
};
private scheduleRead(requested: boolean): void {
if (this.activeReadSequence !== null) {
this.refreshQueued = true;
this.activeReadRequested ||= requested;
this.requestedRefreshQueued ||= requested;
if (requested && this.snapshot.read.status === "pending") {
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested: true } });
}
return;
}
this.startRead(requested);
}
private reset(): void {
this.generation += 1;
this.unsubscribe?.();
this.unsubscribe = null;
this.session = null;
this.target = null;
this.liveUpdates = false;
this.subscriptionPending = false;
this.issuedReadSequence = 0;
this.activeReadSequence = null;
this.activeReadRequested = false;
this.observedAfterReadSequence = null;
this.candidateVersion = null;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
this.setSnapshot(idleSnapshot);
}
private async startSubscription(generation: number): Promise<void> {
const session = this.session;
const target = this.target;
if (!session || !target) return;
this.subscriptionPending = true;
let receivedUpdate = false;
try {
const subscription = await session.subscribe(target, (version) => {
if (generation !== this.generation) return;
receivedUpdate = true;
this.observeVersion(version);
});
if (generation !== this.generation) {
subscription.unsubscribe();
return;
}
this.subscriptionPending = false;
this.unsubscribe = subscription.unsubscribe;
if (!receivedUpdate) this.observeVersion(subscription.initial);
} catch {
if (generation !== this.generation) return;
this.subscriptionPending = false;
this.scheduleRead(false);
}
}
private observeVersion(version: FileVersion): void {
this.observedAfterReadSequence = this.issuedReadSequence;
this.candidateVersion = version;
this.scheduleRead(false);
}
private startRead(requested: boolean): void {
const session = this.session;
const target = this.target;
if (!session || !target) return;
const generation = this.generation;
const sequence = ++this.issuedReadSequence;
this.activeReadSequence = sequence;
this.activeReadRequested = requested;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested } });
void session.read(target).then(
(file) => this.finishRead({ generation, sequence, target, file }),
(error) => this.failRead({ generation, sequence, target, error }),
);
}
private finishRead(input: {
generation: number;
sequence: number;
target: LiveFileTarget;
file: FileReadResult;
}): void {
if (!this.isActiveRead(input.generation, input.sequence)) return;
const requested = this.activeReadRequested;
this.activeReadSequence = null;
this.activeReadRequested = false;
if (this.readPredatesLatestObservation(input.sequence)) {
this.startRead(requested || this.takeQueuedRequested());
return;
}
this.observedAfterReadSequence = null;
this.candidateVersion = null;
this.setSnapshot({
observation: {
status: "ready",
file: input.file,
version: readyVersion(input.target, input.file),
},
read: { status: "idle" },
});
this.startQueuedRead();
}
private failRead(input: {
generation: number;
sequence: number;
target: LiveFileTarget;
error: unknown;
}): void {
if (!this.isActiveRead(input.generation, input.sequence)) return;
const requested = this.activeReadRequested;
this.activeReadSequence = null;
this.activeReadRequested = false;
if (this.readPredatesLatestObservation(input.sequence)) {
this.startRead(requested || this.takeQueuedRequested());
return;
}
const message = errorMessage(input.error);
const candidate = this.candidateVersion;
this.candidateVersion = null;
const candidateDescribesFailure =
candidate?.status === "missing" || candidate?.status === "error";
const observation: LiveFileObservation = candidateDescribesFailure
? candidate
: { status: "error", cwd: input.target.cwd, path: input.target.path, error: message };
this.setSnapshot({ ...this.snapshot, observation, read: { status: "error", error: message } });
this.startQueuedRead();
}
private isActiveRead(generation: number, sequence: number): boolean {
return generation === this.generation && sequence === this.activeReadSequence;
}
private readPredatesLatestObservation(sequence: number): boolean {
return this.observedAfterReadSequence !== null && sequence <= this.observedAfterReadSequence;
}
private startQueuedRead(): void {
if (!this.refreshQueued) return;
this.startRead(this.takeQueuedRequested());
}
private takeQueuedRequested(): boolean {
const requested = this.requestedRefreshQueued;
this.refreshQueued = false;
this.requestedRefreshQueued = false;
return requested;
}
private setSnapshot(snapshot: LiveFileSnapshot): void {
if (snapshot === this.snapshot) return;
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
function readyVersion(
target: LiveFileTarget,
file: FileReadResult,
): Extract<FileVersion, { status: "ready" }> {
return {
status: "ready",
cwd: target.cwd,
path: target.path,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+107 -623
View File
@@ -1,202 +1,27 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState } from "react";
import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client";
import { Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
FilePanelPicker,
FilePanelView,
isSourceFilePanel,
useFilePanelChoice,
type FilePanel,
type FilePanelChoice,
} from "@/file-panels";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { Button } from "@/components/ui/button";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useFileHandle, useFileHandleSnapshot } from "@/file-panels/file";
import { FilePanelLoading } from "@/file-panels/loading";
import { FilePanelPicker, FilePanelView, useFilePanelChoice, type FilePanel } from "@/file-panels";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar, FileSourceStatus } from "./bar";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel, LiveFileSnapshot } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface FilePanelBodyProps {
bytes: Uint8Array | null;
setText?: (text: string) => void;
panel: FilePanel | null;
preview: ExplorerFile | null;
isLoading: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
documentPreviewUri: string | null;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function ReadonlySource({
preview,
filename,
location,
navigationRevision,
}: {
preview: ExplorerFile;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<FileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
function TooLargeSource({ size }: { size?: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
}
/** The tab body: the file's panel, Source read-only when the tab cannot edit. */
function FilePanelBody({
bytes,
setText,
panel,
preview,
isLoading,
location,
navigationRevision,
documentPreviewUri,
workspaceRoot,
client,
serverId,
}: FilePanelBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
if (isLoading && !preview) {
return (
<View style={styles.centerState} testID="file-preview-loading">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
if (!preview) {
return (
<View style={styles.centerState} testID="file-preview-unsupported">
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
</View>
);
}
if (!panel) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
if (isSourceFilePanel(panel)) {
return (
<ReadonlySource
preview={preview}
filename={filePath}
location={location}
navigationRevision={navigationRevision}
/>
);
}
return (
<FilePanelView
bytes={bytes}
panel={panel}
preview={preview}
location={location}
navigationRevision={navigationRevision}
setText={setText}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
documentPreviewUri={documentPreviewUri}
/>
);
}
import { useSessionStore } from "@/stores/session-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { formatFileSize } from "@/utils/format-file-size";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { FilePanelBar } from "./bar";
/**
* The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows
* its own loading; the tab only knows the file exists, its kind, and whether the panel has
* unsaved changes.
*/
export function FilePane({
serverId,
workspaceRoot,
@@ -209,451 +34,120 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
const connected = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
const root = workspaceRoot.trim();
const path = location.path.trim();
const target = useMemo(
() => (path ? resolveFilePreviewReadTarget({ path, workspaceRoot: root }) : null),
[path, root],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
() =>
normalizedFilePath
? resolveFilePreviewReadTarget({
path: normalizedFilePath,
workspaceRoot: normalizedWorkspaceRoot,
})
: null,
[normalizedFilePath, normalizedWorkspaceRoot],
const absolutePath = useMemo(
() => buildAbsoluteExplorerPath({ workspaceRoot: root, entryPath: path }),
[path, root],
);
// Re-read the file when this pane becomes visible again (#445). `isActive`
// covers tab switches; active app visibility covers backgrounding and returning
// from another window after an external edit. The gate lives in isFileQueryEnabled.
// A hidden tab or a backgrounded app stops observing; observing again reports what changed.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const enabled = isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
const handle = useFileHandle({
serverId,
target,
absolutePath,
observing: isActive && isAppVisible,
});
const liveFile = useLiveFile({
client,
cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null,
enabled,
liveUpdates: supportsEditing,
});
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const { panel: resolvedPanel, choice } = useFilePanelChoice({
const snapshot = useFileHandleSnapshot(handle);
const targetKey = target ? `${target.cwd}:${target.path}` : null;
const { panel, choice } = useFilePanelChoice({
serverId,
targetKey,
path: location.path,
file: readyFile(liveFile.snapshot, targetKey),
path,
fileKind: snapshot.status === "ready" ? snapshot.kind : null,
presentable: isActive,
lineNavigation: location.lineStart ? navigationRevision : null,
});
const previewLifecycle = useFilePreview({
panel: resolvedPanel,
targetKey,
liveFileSnapshot: liveFile.snapshot,
});
const {
file: preview,
panel,
bytes,
documentAttachment,
} = resolveFilePreviewLifecycle(previewLifecycle);
const documentPreviewUri = useAttachmentPreviewUrl(documentAttachment);
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
const isLoading =
previewLifecycle.status === "initial" ||
previewLifecycle.status === "read_pending" ||
previewLifecycle.status === "preparing";
return (
<>
<FilePanelPicker choice={choice} />
<FilePanePresentation
bytes={bytes}
panel={panel}
choice={choice}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
onRetryRead={liveFile.refresh}
retryingRead={liveFile.isRetrying}
retryLabel={t("common.actions.retry")}
filename={getFileNameFromPath(location.path) ?? location.path}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={isLoading}
location={location}
navigationRevision={navigationRevision}
documentPreviewUri={documentPreviewUri}
/>
</>
const [modified, setModified] = useState(false);
usePublishPanelInstanceAttributes({ modified, suspendPendingSave: handle?.holdWrites });
const panelLocation = useMemo(
() => ({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
revision: navigationRevision,
}),
[location.lineEnd, location.lineStart, navigationRevision],
);
}
/** The read for `targetKey`; right after a retarget the snapshot still holds the previous file. */
function readyFile(snapshot: LiveFileSnapshot, targetKey: string | null): FileReadResult | null {
const observation = snapshot.observation;
if (observation?.status !== "ready") return null;
const { cwd, path } = observation.version;
return targetKey === `${cwd}:${path}` ? observation.file : null;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
}): boolean {
return Boolean(
isWeb &&
input.supportsEditing &&
input.preview?.kind === "text" &&
input.preview.size <= 1024 * 1024,
const panelTarget = useMemo(
() => ({ serverId, workspaceRoot: root, path }),
[path, root, serverId],
);
}
function FilePanePresentation({
bytes,
panel,
choice,
serverId,
workspaceRoot,
client,
readTarget,
preview,
liveFile,
onRetryRead,
retryingRead,
retryLabel,
filename,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
location,
navigationRevision,
documentPreviewUri,
}: {
bytes: Uint8Array | null;
panel: FilePanel | null;
choice: FilePanelChoice;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
liveFile: LiveFileModel;
onRetryRead: () => void;
retryingRead: boolean;
retryLabel: string;
filename: string;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
documentPreviewUri: string | null;
}) {
if (!client && readTarget) {
let body;
if (!connected && target) {
body = <Text style={styles.errorText}>{t("workspace.terminal.hostDisconnected")}</Text>;
} else if (!handle || snapshot.status === "loading") {
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{disconnectedMessage}</Text>
</View>
<FilePanelLoading />
</View>
);
}
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
bytes={bytes}
panel={panel}
choice={choice}
documentPreviewUri={documentPreviewUri}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
preview={preview as TextExplorerFile}
liveFile={liveFile}
onRetryRead={onRetryRead}
retryingRead={retryingRead}
filename={filename}
isLoading={isLoading}
location={location}
navigationRevision={navigationRevision}
/>
} else if (snapshot.status === "missing" || snapshot.status === "error") {
body = (
<>
<Text style={styles.errorText}>
{snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")}
</Text>
<Button variant="outline" size="sm" onPress={handle.retry}>
{t("common.actions.retry")}
</Button>
</>
);
}
if (errorMessage) {
if (errorMessage === "File is too large to display") {
return (
<View style={styles.container} testID="workspace-file-pane">
<TooLargeSource />
</View>
);
}
} else if (!panel) {
body = (
<>
<Text style={styles.mutedText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.mutedText}>{formatFileSize(snapshot.size)}</Text>
</>
);
} else {
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Button variant="outline" size="sm" onPress={onRetryRead} loading={retryingRead}>
{retryLabel}
</Button>
</View>
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
{preview ? <FilePanelBar size={preview.size} lineCount={lineCount} choice={choice} /> : null}
<FilePanelBody
bytes={bytes}
panel={panel}
preview={preview}
isLoading={isLoading}
location={location}
navigationRevision={navigationRevision}
documentPreviewUri={documentPreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</View>
);
}
function EditableFilePane({
bytes,
documentPreviewUri,
panel,
choice,
serverId,
workspaceRoot,
client,
cwd,
path,
preview,
liveFile,
onRetryRead,
retryingRead,
filename,
isLoading,
location,
navigationRevision,
}: {
bytes: Uint8Array | null;
documentPreviewUri: string | null;
panel: FilePanel | null;
choice: FilePanelChoice;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
cwd: string;
path: string;
preview: TextExplorerFile;
liveFile: LiveFileModel;
onRetryRead: () => void;
retryingRead: boolean;
filename: string;
isLoading: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const session = useMemo(
() => ({
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, ...input });
},
}),
[client, cwd, path],
);
const [model] = useState(() => {
return new FileEditorModel({
file: {
content: preview.content ?? "",
hasBom: preview.hasBom,
version: {
status: "ready",
cwd,
path,
size: preview.size,
modifiedAt: preview.modifiedAt,
revision: preview.revision,
},
},
session,
});
});
useEffect(() => {
const source = createFileObservationSource(liveFile);
model.connectFileObservations(source);
return () => model.disconnectFileObservations();
}, [liveFile, model]);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
const theme = UnistylesRuntime.getTheme();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[
theme.colors.border,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.surface0,
theme.colors.syntax,
theme.colors.terminal.cursor,
theme.colors.terminal.selectionBackground,
theme.colorScheme,
theme.fontFamily.mono,
theme.fontSize.code,
],
);
useEffect(() => () => model.dispose(), [model]);
const handleReload = useCallback(() => {
if (!snapshot.modified) {
void model.reload();
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: t("panels.file.editor.reloadTitle"),
message: t("panels.file.editor.reloadMessage"),
confirmLabel: t("panels.file.editor.reload"),
destructive: true,
});
if (confirmed) void model.reload();
})();
}, [model, snapshot.modified, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const conflict = fileConflictAlertState({
callout: getFileConflictCallout(snapshot),
onOverwrite: handleOverwrite,
onReload: handleReload,
onRetry: onRetryRead,
retrying: retryingRead,
});
const setText = useCallback((text: string) => model.edit(text), [model]);
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
const renderedPreview = useMemo<ExplorerFile>(
() => ({
...preview,
content: snapshot.content,
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
modifiedAt:
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
}),
[preview, snapshot.content, snapshot.version],
);
const sourceActive = panel !== null && isSourceFilePanel(panel);
const sourceStatus = useMemo(
() => (sourceActive ? <FileSourceStatus cursor={cursor} vimMode={vimMode} /> : null),
[cursor, sourceActive, vimMode],
);
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelBar
size={
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
conflict={conflict}
choice={choice}
trailing={sourceStatus}
/>
{sourceActive ? (
<FileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
theme={visualTheme}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePanelBody
bytes={bytes}
<FilePanelPicker choice={choice} />
<FilePanelBar choice={choice} modified={modified} />
<MountedFilePanel
key={`${panel.id}:${targetKey}`}
panel={panel}
setText={setText}
preview={renderedPreview}
isLoading={isLoading}
location={location}
navigationRevision={navigationRevision}
documentPreviewUri={documentPreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
file={handle.file}
location={panelLocation}
target={panelTarget}
onModifiedChange={setModified}
/>
)}
</View>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>{body}</View>
</View>
);
}
function fileConflictAlertState(input: {
callout: FileConflictCallout | null;
onOverwrite(): void;
onReload(): void;
onRetry(): void;
retrying: boolean;
}): FileConflictAlertState | undefined {
if (!input.callout) return undefined;
if (input.callout.kind === "deleted") return { kind: "deleted" };
if (input.callout.kind === "checkFailed") {
return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry };
}
return {
kind: "changed",
canOverwrite: input.callout.canOverwrite,
onReload: input.onReload,
onOverwrite: input.onOverwrite,
};
/** One panel mount. Its tab reports unsaved changes until the panel unmounts. */
function MountedFilePanel({
panel,
file,
location,
target,
onModifiedChange,
}: {
panel: FilePanel;
file: PluginFile;
location: { lineStart?: number; lineEnd?: number; revision: number };
target: { serverId: string; workspaceRoot: string; path: string };
onModifiedChange: (modified: boolean) => void;
}) {
const [tab] = useState<PluginFileTab>(() => ({
setModified: (modified) => onModifiedChange(modified),
}));
useEffect(() => () => onModifiedChange(false), [onModifiedChange]);
return <FilePanelView panel={panel} file={file} tab={tab} location={location} target={target} />;
}
const styles = StyleSheet.create((theme) => ({
@@ -669,24 +163,14 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
emptyText: {
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
binaryMetaText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -1,25 +0,0 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import type { FilePanel } from "@/file-panels";
import type { LiveFileSnapshot } from "../live-file/model";
import {
createFilePanePreview,
FilePreviewLifecycleModel,
type FilePreviewLifecycleSnapshot,
} from "./model";
/** Bridges the raw-file owner to the preview preparation owner. */
export function useFilePreview(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
panel: FilePanel | null;
}): FilePreviewLifecycleSnapshot {
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const { targetKey, liveFileSnapshot, panel } = input;
useEffect(() => {
model.setSource({ targetKey, liveFileSnapshot, panel });
}, [liveFileSnapshot, model, targetKey, panel]);
return snapshot;
}
@@ -1,199 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { LiveFileSnapshot } from "../live-file/model";
import { describe, expect, it } from "vitest";
import {
createFilePanePreview,
FilePreviewLifecycleModel,
type FilePanePreview,
type FilePreviewLifecycleSnapshot,
} from "./model";
function previewFile(content: string) {
return {
kind: "text" as const,
path: "file.ts",
size: 7,
encoding: "utf-8" as const,
hasBom: false,
modifiedAt: "2026-08-20T00:00:00.000Z",
content,
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function file(revision = "revision-1", path = "file.ts"): FileReadResult {
return {
bytes: new TextEncoder().encode("preview"),
mime: "text/plain",
size: 7,
path,
kind: "text",
modifiedAt: "2026-08-20T00:00:00.000Z",
revision,
};
}
function pending(): LiveFileSnapshot {
return { observation: null, read: { status: "pending", requested: false } };
}
function completed(rawFile: FileReadResult): LiveFileSnapshot {
return {
observation: {
status: "ready",
file: rawFile,
version: {
status: "ready",
cwd: "/workspace",
path: rawFile.path,
size: rawFile.size,
modifiedAt: rawFile.modifiedAt,
revision: rawFile.revision,
},
},
read: { status: "idle" },
};
}
function source(
targetKey: string,
snapshot: LiveFileSnapshot,
): {
targetKey: string;
liveFileSnapshot: LiveFileSnapshot;
panel: null;
} {
return { targetKey, liveFileSnapshot: snapshot, panel: null };
}
describe("FilePreviewLifecycleModel", () => {
it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const third = deferred<FilePanePreview | null>();
const preparations = [first, second, third];
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
const preview: FilePanePreview = {
file: previewFile("preview"),
panel: null,
bytesPreview: null,
};
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending" });
model.setSource(source("/workspace:file.ts", completed(file("one"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
first.resolve(preview);
await expectSnapshot(model, { status: "ready", preview });
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(
source("/workspace:file.ts", {
observation: {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
},
read: { status: "pending", requested: true },
}),
);
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(source("/workspace:file.ts", completed(file("two"))));
expect(model.getSnapshot()).toEqual({ status: "preparing", preview });
second.resolve(null);
await expectSnapshot(model, { status: "unsupported" });
model.setSource(source("/workspace:file.ts", completed(file("three"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
third.reject(new Error("attachment failed"));
await expectSnapshot(model, { status: "error", message: "attachment failed" });
});
it("does not publish a stale conversion after retargeting", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const model = new FilePreviewLifecycleModel((rawFile) =>
rawFile.revision === "one" ? first.promise : second.promise,
);
const nextPreview: FilePanePreview = {
file: previewFile("two"),
panel: null,
bytesPreview: null,
};
model.setSource(source("/workspace:file.ts", completed(file("one"))));
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
first.resolve({ file: previewFile("one"), panel: null, bytesPreview: null });
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "preparing" });
second.resolve(nextPreview);
await expectSnapshot(model, { status: "ready", preview: nextPreview });
});
});
async function expectSnapshot(
model: FilePreviewLifecycleModel,
expected: FilePreviewLifecycleSnapshot,
): Promise<void> {
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(model.getSnapshot()).toEqual(expected);
}
it("does not persist attachments when a text file has no selected panel", async () => {
let writes = 0;
await createFilePanePreview(file(), null, async () => {
writes++;
return {
id: "attachment",
mimeType: "text/plain",
storageType: "web-indexeddb",
storageKey: "attachment",
createdAt: 0,
};
});
expect(writes).toBe(0);
});
it("reprepares the same file when its selected panel changes", async () => {
const preparations: Array<string | null> = [];
const model = new FilePreviewLifecycleModel(async (raw, panel) => {
preparations.push(panel?.id ?? null);
return { file: previewFile("preview"), panel, bytesPreview: null };
});
const initial = source("/workspace:file.ts", completed(file()));
model.setSource(initial);
await Promise.resolve();
const textPanel = {
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
} as const;
model.setSource({ ...initial, panel: textPanel });
await Promise.resolve();
model.setSource({ ...initial, panel: textPanel });
expect(preparations).toEqual([null, "builtin:markdown"]);
expect(model.getSnapshot()).toMatchObject({ status: "ready", preview: { panel: textPanel } });
});
@@ -1,223 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { FilePanel } from "@/file-panels";
import type { ExplorerFile } from "@/stores/session-store";
import type { LiveFileSnapshot } from "../live-file/model";
export interface FilePanePreview {
file: ExplorerFile;
panel: FilePanel | null;
bytesPreview: { bytes: Uint8Array; attachment: AttachmentMetadata } | null;
}
export type FilePreviewLifecycleSnapshot =
| { status: "initial" }
| { status: "read_pending"; preview?: FilePanePreview }
| { status: "preparing"; preview?: FilePanePreview }
| { status: "ready"; preview: FilePanePreview }
| { status: "error"; message: string; preview?: FilePanePreview }
| { status: "unsupported" };
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
/** Converts a completed raw read into the preview resources consumed by FilePane. */
export async function createFilePanePreview(
file: FileReadResult,
panel: FilePanel | null,
persist: typeof persistAttachmentFromBytes = persistAttachmentFromBytes,
): Promise<FilePanePreview | null> {
const explorerFile = explorerFileFromReadResult(file);
if (panel?.document !== "bytes") {
return { file: explorerFile, panel, bytesPreview: null };
}
const documentAttachment = await persist({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path),
});
return {
file: explorerFile,
panel,
bytesPreview: { bytes: file.bytes, attachment: documentAttachment },
};
}
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
export class FilePreviewLifecycleModel {
private readonly listeners = new Set<() => void>();
private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot;
private generation = 0;
private preparedSourceKey: string | null = null;
private targetKey: string | null = null;
private preparedPanel: FilePanel | null = null;
constructor(
private readonly prepare: (
file: FileReadResult,
panel: FilePanel | null,
) => Promise<FilePanePreview | null>,
) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
setSource(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
panel: FilePanel | null;
}): void {
const targetChanged = input.targetKey !== this.targetKey;
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
if (targetChanged) {
this.targetKey = input.targetKey;
this.preparedSourceKey = null;
this.generation += 1;
}
const source = getReadySource(input);
if (source) {
if (source.key === this.preparedSourceKey && input.panel === this.preparedPanel) {
return;
}
this.preparedSourceKey = source.key;
this.preparedPanel = input.panel;
const generation = ++this.generation;
this.setSnapshot({
status: "preparing",
...(retainedPreview ? { preview: retainedPreview } : {}),
});
void this.prepareSource({
file: source.file,
panel: input.panel,
generation,
retainedPreview,
});
return;
}
const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot);
if (nextSnapshot.status === "initial" && retainedPreview) {
return;
}
const retainedSnapshot = retainedPreview
? { ...nextSnapshot, preview: retainedPreview }
: nextSnapshot;
if (sameNonReadyState(this.snapshot, retainedSnapshot)) {
return;
}
this.preparedSourceKey = null;
this.generation += 1;
this.setSnapshot(retainedSnapshot);
}
private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
private async prepareSource(input: {
file: FileReadResult;
panel: FilePanel | null;
generation: number;
retainedPreview?: FilePanePreview;
}): Promise<void> {
try {
const preview = await this.prepare(input.file, input.panel);
if (input.generation !== this.generation) return;
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
} catch (error) {
if (input.generation !== this.generation) return;
this.setSnapshot({
status: "error",
message: errorMessage(error),
...(input.retainedPreview ? { preview: input.retainedPreview } : {}),
});
}
}
}
export function filePreviewFromLifecycle(
snapshot: FilePreviewLifecycleSnapshot,
): FilePanePreview | undefined {
return "preview" in snapshot ? snapshot.preview : undefined;
}
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
file: ExplorerFile | null;
panel: FilePanel | null;
bytes: Uint8Array | null;
documentAttachment: AttachmentMetadata | null;
} {
const preview = filePreviewFromLifecycle(snapshot);
if (!preview) return { file: null, panel: null, bytes: null, documentAttachment: null };
return {
file: preview.file,
panel: preview.panel,
bytes: preview.bytesPreview ? preview.bytesPreview.bytes : null,
documentAttachment: preview.bytesPreview ? preview.bytesPreview.attachment : null,
};
}
function getReadySource(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
}): { key: string; file: FileReadResult } | null {
const observation = input.liveFileSnapshot.observation;
if (!input.targetKey || observation?.status !== "ready") {
return null;
}
const { version } = observation;
if (input.targetKey !== `${version.cwd}:${version.path}`) {
return null;
}
return {
key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`,
file: observation.file,
};
}
function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot {
if (snapshot.read.status === "error") {
return { status: "error", message: snapshot.read.error };
}
if (snapshot.read.status === "pending") {
return { status: "read_pending" };
}
if (snapshot.observation?.status === "error") {
return { status: "error", message: snapshot.observation.error };
}
if (snapshot.observation?.status === "missing") {
return { status: "error", message: "File not found" };
}
return initialSnapshot;
}
function sameNonReadyState(
current: FilePreviewLifecycleSnapshot,
next: FilePreviewLifecycleSnapshot,
): boolean {
if (current.status !== next.status) {
return false;
}
return current.status !== "error" || next.status !== "error" || current.message === next.message;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Failed to prepare preview";
}
@@ -1,141 +0,0 @@
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { selectSourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "native" });
if (presentation === "unsupported") {
return (
<View style={styles.unsupported} testID="file-source-too-large">
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
</View>
);
}
return (
<VirtualizedSource
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
/>
);
}
function VirtualizedSource({
content,
filename,
location,
navigationRevision,
presentation,
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
if (presentation === "highlighted")
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
useEffect(() => {
if (!location.lineStart) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lines.length - 1),
animated: false,
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
}
function sourceLineKey(line: SourceLine): string {
return String(line.number);
}
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
unsupported: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
+52 -50
View File
@@ -1,92 +1,94 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { useCallback, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StyleSheet } from "react-native-unistyles";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { Theme } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { FileHtmlPreview } from "./html-preview";
import { FilePanelLoading } from "./loading";
import { FileMarkdownPreview } from "./markdown-preview";
import type { RenderingFilePanel } from "./catalog";
import type { BuiltinFilePanelProps } from "./panels";
import { SourcePanel } from "./source/panel";
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
interface BuiltinFilePanelProps {
content: string;
filePath: string;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
documentPreviewUri: string | null;
}
function MarkdownPanel(props: BuiltinFilePanelProps) {
/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
const text = useFileText(file);
const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
const [rendered, setRendered] = useState(false);
const handleLayout = useCallback(() => setRendered(true), []);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
return (
<View style={styles.previewScrollContainer}>
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
<FileMarkdownPreview
source={props.content}
filePath={props.filePath}
workspaceRoot={props.workspaceRoot}
client={props.client}
serverId={props.serverId}
/>
<View style={styles.container}>
<ScrollView style={styles.container} showsVerticalScrollIndicator>
<View onLayout={handleLayout}>
<FileMarkdownPreview
source={text.text}
filePath={target.path}
workspaceRoot={target.workspaceRoot}
client={client}
serverId={target.serverId}
/>
</View>
</ScrollView>
{rendered ? null : <FilePanelLoading />}
</View>
);
}
function HtmlPanel({ content }: BuiltinFilePanelProps) {
function HtmlPanel({ file }: BuiltinFilePanelProps) {
const text = useFileText(file);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={content} testID="file-html-preview" />
<View style={styles.container}>
<FileHtmlPreview html={text.text} testID="file-html-preview" />
</View>
);
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ImagePanel({ file }: BuiltinFilePanelProps) {
const url = useFileUrl(file);
if (url.status === "loading") return <FilePanelLoading />;
if (url.status === "error") return <FilePanelError message={url.error.message} />;
return <ZoomableImage uri={url.url} testID="image-file-preview" />;
}
function ImagePanel({ documentPreviewUri }: BuiltinFilePanelProps) {
const { t } = useTranslation();
if (!documentPreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
}
return <ZoomableImage uri={documentPreviewUri} testID="image-file-preview" />;
function FilePanelError({ message }: { message: string }) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{message}</Text>
</View>
);
}
const components = {
"builtin:markdown": MarkdownPanel,
"builtin:html": HtmlPanel,
"builtin:image": ImagePanel,
"builtin:source": SourcePanel,
};
export function BuiltinFilePanelView({
panel,
...props
}: BuiltinFilePanelProps & {
panel: RenderingFilePanel;
}) {
}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
const Component = components[panel.id];
return <Component {...props} />;
}
const styles = StyleSheet.create((theme) => ({
previewScrollContainer: { flex: 1, minHeight: 0 },
previewContent: { flex: 1, minHeight: 0 },
container: { flex: 1, minHeight: 0 },
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
loadingText: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
+4 -20
View File
@@ -8,12 +8,10 @@ const richMarkdown: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md"] },
document: "text",
maxBytes: Infinity,
};
function textFile(path: string): FilePanelFile {
return { path, kind: "text", size: 100, hasBytes: true };
return { path, kind: "text" };
}
function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
@@ -52,39 +50,25 @@ describe("built-in file panels", () => {
});
it("has no Source for files that are not text", () => {
expect(
candidateIds({ path: "archive.zip", kind: "binary", size: 512, hasBytes: true }),
).toEqual([]);
expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]);
});
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
"registers the image extension %s",
(extension) => {
const resolution = resolveFilePanel({
file: {
path: `/capture.${extension.toUpperCase()}`,
kind: "image",
size: 512,
hasBytes: true,
},
file: { path: `/capture.${extension.toUpperCase()}`, kind: "image" },
candidates: builtins,
associations: { [`ext:${extension}`]: "builtin:markdown" },
});
expect(resolution.panel?.id).toBe("builtin:image");
expect(
resolveFilePanel({
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
file: { path: "capture.png", kind: "binary" },
candidates: builtins,
associations: {},
}).panel,
).toBeNull();
},
);
it("does not impose a size limit on built-in text panels", () => {
expect(candidateIds({ ...textFile("notes.md"), size: 11 * 1024 * 1024 })).toEqual([
"builtin:markdown",
"builtin:source",
]);
});
});
+4 -8
View File
@@ -5,22 +5,19 @@ const renderingPanels = [
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
fileKind: "text",
},
{
id: "builtin:html",
builtin: true,
files: { extensions: ["html", "htm"] },
document: "text",
maxBytes: Infinity,
fileKind: "text",
},
{
id: "builtin:image",
builtin: true,
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
document: "bytes",
maxBytes: Infinity,
fileKind: "image",
},
] as const satisfies readonly FilePanelCandidate[];
@@ -29,8 +26,7 @@ export const sourceFilePanel = {
id: "builtin:source",
builtin: true,
files: "all",
document: "text",
maxBytes: Infinity,
fileKind: "text",
} as const satisfies FilePanelCandidate;
export type RenderingFilePanel = (typeof renderingPanels)[number];
+14 -6
View File
@@ -1,11 +1,10 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { useFilePanelAssociations } from "../associations";
import { sourceFilePanel } from "../catalog";
import { useFilePanels, type FilePanel } from "../panels";
import { resolveFileReadPanels } from "../resolve";
import { resolveFilePanel, type FilePanelFileKind } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry";
@@ -19,13 +18,14 @@ export function useFilePanelChoice(input: {
serverId: string;
targetKey: string | null;
path: string;
file: FileReadResult | null;
/** The kind the file's stat reports, or null before the stat. */
fileKind: FilePanelFileKind | null;
/** The tab is on screen. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}): { panel: FilePanel | null; choice: FilePanelChoice } {
const { targetKey, path, file, presentable, lineNavigation } = input;
const { targetKey, path, fileKind, presentable, lineNavigation } = input;
const { serverId, workspaceId, tabId } = usePaneContext();
const candidates = useFilePanels(input.serverId);
const { associations, remember } = useFilePanelAssociations();
@@ -35,8 +35,16 @@ export function useFilePanelChoice(input: {
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
() => resolveFileReadPanels({ file, path, candidates, associations, choice: chosenPanelId }),
[associations, candidates, chosenPanelId, file, path],
() =>
fileKind
? resolveFilePanel({
file: { path, kind: fileKind },
candidates,
associations,
choice: chosenPanelId,
})
: null,
[associations, candidates, chosenPanelId, fileKind, path],
);
useEffect(() => {
@@ -7,8 +7,6 @@ const rich: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
};
const markdown = "builtin:markdown";
const source = sourceFilePanel.id;
@@ -31,7 +29,7 @@ function openTab(
let lineNavigation = options.lineNavigation ?? null;
const resolve = () =>
resolveFilePanel({
file: { path: targetKey.split(":")[1]!, kind: "text", size: 10, hasBytes: true },
file: { path: targetKey.split(":")[1]!, kind: "text" },
candidates,
associations,
choice: chosenPanelFor(model.getSnapshot(), targetKey),
@@ -1,73 +0,0 @@
import { expect, it } from "vitest";
import { FileEditorModel, type FileEditorClock } from "@/file-pane/editor/model";
import { textDocument } from "./document";
class DocumentClock implements FileEditorClock {
callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout() {
this.callback = null;
}
fire() {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
it("plugin text edits mark the host document dirty and autosave with its revision", async () => {
const clock = new DocumentClock();
const writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> =
[];
const model = new FileEditorModel({
file: {
content: "original",
hasBom: false,
version: {
status: "ready",
cwd: "/workspace",
path: "notes.md",
size: 8,
modifiedAt: "2026-10-19T00:00:00Z",
revision: "first",
},
},
clock,
session: {
async write(input) {
writes.push(input);
return {
status: "written",
modifiedAt: "2026-10-19T00:00:01Z",
size: input.content.length,
revision: "second",
};
},
},
});
const document = textDocument(model.getSnapshot().content, (next) => model.edit(next));
expect(document.editable).toBe(true);
document.setText("edited");
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true, content: "edited" });
expect(writes).toEqual([]);
clock.fire();
await Promise.resolve();
expect(writes).toEqual([
{ content: "edited", expectedModifiedAt: "2026-10-19T00:00:00Z", expectedRevision: "first" },
]);
expect(model.getSnapshot()).toMatchObject({
status: "clean",
modified: false,
content: "edited",
});
model.dispose();
});
it("readonly text documents leave the host text unchanged", () => {
const document = textDocument("readonly");
document.setText("ignored");
expect(document).toMatchObject({ kind: "text", text: "readonly", editable: false });
});
-16
View File
@@ -1,16 +0,0 @@
import type { PluginBytesDocument, PluginTextDocument } from "@getpaseo/plugin/client";
export function textDocument(text: string, setText?: (text: string) => void): PluginTextDocument {
return {
kind: "text",
text,
editable: Boolean(setText),
setText(next) {
setText?.(next);
},
};
}
export function bytesDocument(bytes: Uint8Array, url: string): PluginBytesDocument {
return { kind: "bytes", bytes, url };
}
@@ -0,0 +1,115 @@
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import {
persistAttachmentFromBytes,
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import type { FileKind, FileObservation, FileSession } from "./handle";
export interface DaemonFileFeatures {
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: boolean;
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
fileByteWrites: boolean;
}
/** A file session over a daemon connection. */
export function createDaemonFileSession(
client: DaemonClient,
features: DaemonFileFeatures,
): FileSession {
return {
async observe(target, onObservation) {
if (!features.workspaceFileEditing) {
// COMPAT(workspaceFileEditing): the host cannot observe files; its read is the stat.
return {
initial: statFromRead(await client.readFile(target.cwd, target.path)),
unsubscribe() {},
};
}
let kind: { kind: FileKind; mimeType: string } | null = null;
const classify = (version: FileVersion): FileObservation => {
if (version.status !== "ready") return version;
if (version.kind && version.mimeType) {
kind = { kind: version.kind, mimeType: version.mimeType };
}
// COMPAT(fileVersionKind): added in v0.13.0, remove after 2027-04-20. Older daemons
// classify a file only when reading it; the first read's kind stands for later versions.
if (!kind) return { ...version, status: "error", error: "The file has no kind." };
return { ...version, ...kind };
};
const subscription = await client.subscribeFile(target, (version) =>
onObservation(classify(version)),
);
const initial = subscription.initial;
if (initial.status === "ready" && !initial.kind) {
// COMPAT(fileVersionKind): see above.
const read = statFromRead(await client.readFile(target.cwd, target.path));
kind = { kind: read.kind, mimeType: read.mimeType };
}
return {
initial: classify(initial),
unsubscribe() {
void subscription.unsubscribe();
},
};
},
async read(target) {
return client.readFile(target.cwd, target.path);
},
async write({ target, content, ...expected }) {
if (!features.workspaceFileEditing) throw new Error("Update the host to edit files.");
if (typeof content === "string") {
return client.writeFile({ ...target, content, ...expected });
}
if (!features.fileByteWrites) throw new Error("Update the host to write binary files.");
return client.writeFile({
...target,
content: bytesToBase64(content),
contentEncoding: "base64",
...expected,
});
},
async createUrl({ target, bytes, mimeType }) {
const attachment = await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType,
path: `${target.cwd}/${target.path}`,
contentLength: bytes.byteLength,
contentKey: String(Date.now()),
}),
bytes,
mimeType,
fileName: getFileNameFromPath(target.path),
});
const url = await resolveAttachmentPreviewUrl(attachment);
return {
url,
release() {
void releaseAttachmentPreviewUrl({ attachment, url });
},
};
},
};
}
function statFromRead(file: FileReadResult): Extract<FileObservation, { status: "ready" }> {
return {
status: "ready",
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
kind: file.kind,
mimeType: file.mime,
};
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
}
@@ -0,0 +1,179 @@
import { PluginFileConflictError, type PluginFileEvent } from "@getpaseo/plugin/client";
import { describe, expect, it } from "vitest";
import { FileHandle } from "./handle";
import { InMemoryFileSession } from "./testing";
async function openFile(content: string | Uint8Array = "first\n") {
const session = new InMemoryFileSession();
session.put("notes.md", content);
const handle = new FileHandle(session, { cwd: "/repo", path: "notes.md" }, "/repo/notes.md");
handle.setObserving(true);
await settle();
const events: PluginFileEvent[] = [];
handle.file.watch((event) => events.push(event));
return { session, handle, file: handle.file, events };
}
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe("FileHandle", () => {
it("describes the file from its stat before anything reads it", async () => {
const { handle, file } = await openFile("# Notes\n");
expect(handle.getSnapshot()).toMatchObject({ status: "ready", kind: "text", size: 8 });
expect([file.path, file.name, file.extension, file.mimeType, file.size]).toEqual([
"/repo/notes.md",
"notes.md",
"md",
"text/plain",
8,
]);
});
it("writes over the version it read and refreshes the stat", async () => {
const { session, file, events } = await openFile();
expect(await file.readText()).toBe("first\n");
await file.writeText("second, longer\n");
expect(session.text("notes.md")).toBe("second, longer\n");
expect(file.size).toBe(15);
expect(events).toEqual([]);
});
it("rejects a write after an external change unless forced", async () => {
const { session, file, events } = await openFile();
await file.readText();
session.put("notes.md", "external\n");
await expect(file.writeText("mine\n")).rejects.toBeInstanceOf(PluginFileConflictError);
expect(session.text("notes.md")).toBe("external\n");
await file.writeText("mine\n", { force: true });
expect(session.text("notes.md")).toBe("mine\n");
expect(events).toEqual(["changed"]);
});
it("forces over a change it has not observed yet", async () => {
const { session, handle, file } = await openFile();
await file.readText();
handle.setObserving(false);
session.put("notes.md", "unobserved\n");
await file.writeText("mine\n", { force: true });
expect(session.text("notes.md")).toBe("mine\n");
});
it("keeps a byte order mark it read", async () => {
const { session, file } = await openFile(
new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode("bom\r\n")]),
);
expect(await file.readText()).toBe("bom\r\n");
await file.writeText("saved\r\n");
expect(session.text("notes.md")).toBe("saved\r\n");
});
it("tells watchers about each external version once, and about deletion", async () => {
const { session, file, events } = await openFile();
session.put("notes.md", "external\n");
await file.readText();
session.remove("notes.md");
session.remove("notes.md");
await settle();
session.put("notes.md", "restored\n");
expect(events).toEqual(["changed", "deleted", "changed"]);
});
it("keeps the stat of a deleted file, so its panel stays mounted", async () => {
const { session, handle } = await openFile();
session.remove("notes.md");
await settle();
expect(handle.getSnapshot()).toMatchObject({ status: "ready", kind: "text" });
});
it("does not report a deletion when the file is back before a read confirms it", async () => {
const { session, events } = await openFile();
session.remove("notes.md");
session.put("notes.md", "first\n");
await settle();
expect(events).toEqual(["changed"]);
});
it("reports what changed while the tab was off screen", async () => {
const { session, handle, events } = await openFile();
handle.setObserving(false);
session.put("notes.md", "while hidden\n");
expect(events).toEqual([]);
handle.setObserving(true);
await settle();
expect(events).toEqual(["changed"]);
expect(session.observerCount()).toBe(1);
});
it("reads after its own pending writes", async () => {
const { file } = await openFile();
await file.readText();
const write = file.writeText("flushed\n");
const read = file.readText();
await write;
expect(await read).toBe("flushed\n");
});
it("holds writes while the tab asks to discard them, and drops them when it closes", async () => {
const { session, handle, file } = await openFile();
await file.readText();
const resume = handle.holdWrites();
const kept = file.writeText("kept\n");
await settle();
expect(session.text("notes.md")).toBe("first\n");
resume();
await kept;
expect(session.text("notes.md")).toBe("kept\n");
handle.holdWrites();
const dropped = file.writeText("dropped\n");
handle.dispose();
await expect(dropped).rejects.toThrow("The file tab was closed.");
expect(session.text("notes.md")).toBe("kept\n");
});
it("owns one URL per version and releases it when replaced or closed", async () => {
const { session, handle, file } = await openFile();
const first = await file.url();
expect(await file.url()).toBe(first);
session.put("notes.md", "changed\n");
const second = await file.url();
expect(second).not.toBe(first);
expect(session.createdUrls).toEqual([first, second]);
expect(session.releasedUrls).toEqual([first]);
handle.dispose();
expect(session.releasedUrls).toEqual([first, second]);
});
it("writes bytes", async () => {
const { session, file } = await openFile();
await file.readBytes();
await file.writeBytes(new Uint8Array([1, 2, 3]));
expect(session.writes).toEqual(["bytes:3"]);
});
});
+413
View File
@@ -0,0 +1,413 @@
import {
PluginFileConflictError,
type PluginFile,
type PluginFileEvent,
type PluginFileWriteOptions,
} from "@getpaseo/plugin/client";
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
import { decodeUtf8 } from "@/utils/decode-utf8";
export interface FileTarget {
cwd: string;
path: string;
}
export type FileKind = "text" | "image" | "binary";
/** A version the session has classified, so a panel can be chosen before the file is read. */
export interface FileStat {
status: "ready";
size: number;
modifiedAt: string;
revision?: string;
kind: FileKind;
mimeType: string;
}
export type FileObservation =
| FileStat
| Extract<FileVersion, { status: "missing" }>
| Extract<FileVersion, { status: "error" }>;
export interface FileContents {
bytes: Uint8Array;
size: number;
modifiedAt: string;
revision?: string;
}
/** What a handle needs from the host. The daemon implementation lives in `./daemon-session`. */
export interface FileSession {
observe(
target: FileTarget,
onObservation: (observation: FileObservation) => void,
): Promise<{ initial: FileObservation; unsubscribe(): void }>;
read(target: FileTarget): Promise<FileContents>;
write(input: {
target: FileTarget;
content: string | Uint8Array;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult>;
createUrl(input: { target: FileTarget; bytes: Uint8Array; mimeType: string }): Promise<{
url: string;
release(): void;
}>;
}
export type FileHandleSnapshot =
| { status: "loading" }
| FileStat
| { status: "missing" }
| { status: "error"; error: string };
interface Version {
size: number;
modifiedAt: string;
revision?: string;
}
/**
* One file tab target's file: its stat for choosing a panel, and the `PluginFile` every panel
* reads and writes through.
*
* The handle remembers the version it last read or wrote, and writes only over that version.
* Changes on disk reach `watch` listeners once per version; versions this handle read or wrote
* itself do not. Reads wait for this handle's writes, so a panel that mounts while another flushes
* reads the flushed file. Writes run one at a time, and wait while the tab holds them.
*
* Once the file has a stat, the snapshot keeps it: the mounted panel keeps what it shows, and
* learns about a deletion from `watch`. A deletion is reported only after a read confirms it, so a
* program that removes the file and writes it back does not look like one.
*/
export class FileHandle {
readonly file: PluginFile;
private readonly listeners = new Set<() => void>();
private readonly watchers = new Set<(event: PluginFileEvent) => void>();
private snapshot: FileHandleSnapshot = { status: "loading" };
private stat: FileStat | null = null;
private latest: FileObservation | null = null;
private base: Version | null = null;
/** Versions this handle has read, written, or told watchers about. */
private readonly seen = new Set<string>();
private deleted = false;
private confirmingDeletion = false;
private hasBom = false;
private writes: Promise<unknown> = Promise.resolve();
private writing = 0;
private holds = 0;
private releaseHold: (() => void) | null = null;
private held: Promise<void> | null = null;
private observing: { generation: number; unsubscribe: (() => void) | null } | null = null;
private generation = 0;
private currentUrl: { key: string; url: Promise<string>; release: () => void } | null = null;
private disposed = false;
constructor(
private readonly session: FileSession,
private readonly target: FileTarget,
absolutePath: string,
) {
const stat = () => this.stat;
const name = absolutePath.split(/[\\/]/).pop() ?? "";
const dot = name.lastIndexOf(".");
this.file = {
path: absolutePath,
name,
extension: dot < 0 ? "" : name.slice(dot + 1).toLowerCase(),
get size() {
return stat()?.size ?? 0;
},
get modifiedAt() {
return stat()?.modifiedAt ?? "";
},
get mimeType() {
return stat()?.mimeType ?? "application/octet-stream";
},
readText: () => this.readText(),
readBytes: () => this.readBytes(),
url: () => this.url(),
writeText: (text, options) => this.writeText(text, options),
writeBytes: (bytes, options) => this.write(bytes, options),
watch: (listener) => {
this.watchers.add(listener);
return () => this.watchers.delete(listener);
},
};
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileHandleSnapshot => this.snapshot;
/** Observes the file while the tab is on screen. Coming back reports what changed meanwhile. */
setObserving(observing: boolean): void {
if (this.disposed || observing === (this.observing !== null)) return;
if (!observing) {
this.stopObserving();
return;
}
const generation = ++this.generation;
this.observing = { generation, unsubscribe: null };
void this.observe(generation);
}
private async observe(generation: number): Promise<void> {
let subscription: Awaited<ReturnType<FileSession["observe"]>>;
try {
subscription = await this.session.observe(this.target, (observation) => {
if (this.observing?.generation === generation) this.receive(observation);
});
} catch (error) {
if (this.observing?.generation !== generation) return;
this.observing = null;
this.setSnapshot({ status: "error", error: errorMessage(error) });
return;
}
if (this.observing?.generation !== generation) {
subscription.unsubscribe();
return;
}
this.observing.unsubscribe = subscription.unsubscribe;
this.receive(subscription.initial);
}
/** Observes again after a failure. */
retry = (): void => {
this.stopObserving();
this.setObserving(true);
};
/**
* Holds this file's writes while the tab asks whether to discard unsaved changes. The returned
* function lets them run; closing the tab disposes the handle, which drops them.
*/
holdWrites = (): (() => void) => {
this.holds += 1;
if (this.holds === 1) {
this.held = new Promise((resolve) => {
this.releaseHold = resolve;
});
}
let resumed = false;
return () => {
if (resumed) return;
resumed = true;
this.holds -= 1;
if (this.holds > 0) return;
this.releaseHold?.();
this.releaseHold = null;
this.held = null;
};
};
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.stopObserving();
this.currentUrl?.release();
this.currentUrl = null;
this.releaseHold?.();
this.listeners.clear();
this.watchers.clear();
}
private stopObserving(): void {
this.observing?.unsubscribe?.();
this.observing = null;
}
private receive(observation: FileObservation): void {
if (this.disposed) return;
this.latest = observation;
if (observation.status === "ready") {
this.stat = observation;
if (!this.base) this.adopt(observation);
this.setSnapshot(observation);
} else if (!this.stat) {
this.setSnapshot(
observation.status === "missing"
? { status: "missing" }
: { status: "error", error: observation.error },
);
}
this.notifyChange(observation);
}
/** Tells watchers about a disk version this handle did not read or write itself. */
private notifyChange(observation: FileObservation): void {
if (this.writing > 0 || observation.status === "error") return;
if (observation.status === "missing") {
if (!this.deleted) void this.confirmDeletion();
return;
}
this.notifyVersion(observation);
}
private notifyVersion(version: Version): void {
const key = versionKey(version);
const restored = this.deleted;
this.deleted = false;
if (this.seen.has(key) && !restored) return;
this.seen.add(key);
this.emit("changed");
}
private async confirmDeletion(): Promise<void> {
if (this.confirmingDeletion) return;
this.confirmingDeletion = true;
let contents: FileContents | null = null;
try {
contents = await this.session.read(this.target);
} catch {
// The read failing is the confirmation.
} finally {
this.confirmingDeletion = false;
}
if (this.disposed || this.writing > 0 || this.deleted) return;
if (contents) {
this.notifyVersion(contents);
return;
}
if (this.latest?.status !== "missing") return;
this.deleted = true;
this.emit("deleted");
}
private emit(event: PluginFileEvent): void {
for (const watcher of this.watchers) watcher(event);
}
private async readContents(): Promise<FileContents> {
this.assertOpen();
await this.writes;
const contents = await this.session.read(this.target);
this.assertOpen();
this.adopt(contents);
return contents;
}
private async readText(): Promise<string> {
const { bytes } = await this.readContents();
this.hasBom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
return decodeUtf8(bytes);
}
private async readBytes(): Promise<Uint8Array> {
return (await this.readContents()).bytes;
}
private url(): Promise<string> {
const current = this.currentUrl;
if (current && this.stat && current.key === versionKey(this.stat)) return current.url;
return this.createUrl();
}
private async createUrl(): Promise<string> {
const contents = await this.readContents();
const key = versionKey(contents);
if (this.currentUrl?.key === key) return this.currentUrl.url;
const created = await this.session.createUrl({
target: this.target,
bytes: contents.bytes,
mimeType: this.file.mimeType,
});
if (this.disposed) {
created.release();
throw new Error("The file tab was closed.");
}
this.currentUrl?.release();
this.currentUrl = { key, url: Promise.resolve(created.url), release: created.release };
return created.url;
}
private writeText(text: string, options?: PluginFileWriteOptions): Promise<void> {
return this.write(this.hasBom ? `${text}` : text, options);
}
private write(content: string | Uint8Array, options?: PluginFileWriteOptions): Promise<void> {
const write = this.writes.then(() => this.performWrite(content, options?.force === true));
this.writes = write.catch(() => undefined);
return write;
}
private async performWrite(content: string | Uint8Array, force: boolean): Promise<void> {
this.assertOpen();
if (this.held) await this.held;
this.assertOpen();
this.writing += 1;
try {
let expected = force ? (this.stat ?? this.base) : this.base;
for (let attempt = 0; ; attempt += 1) {
if (!expected) throw new PluginFileConflictError();
const result = await this.session.write({
target: this.target,
content,
expectedModifiedAt: expected.modifiedAt,
expectedRevision: expected.revision,
});
if (result.status === "written") {
this.adopt(result);
return;
}
if (result.status === "error") throw new Error(result.error);
const disk = result.version;
if (force && attempt === 0 && disk.status === "ready") {
expected = disk;
continue;
}
throw new PluginFileConflictError(
disk.status === "missing" ? "The file was deleted." : undefined,
);
}
} finally {
this.writing -= 1;
// Observations that arrived during the write may be its own version, so they wait for it.
if (this.latest && !this.disposed) this.notifyChange(this.latest);
}
}
/** Records a version this handle read or wrote, so its observation does not notify. */
private adopt(version: Version): void {
this.base = { size: version.size, modifiedAt: version.modifiedAt, revision: version.revision };
this.seen.add(versionKey(version));
if (this.stat && versionKey(this.stat) !== versionKey(version)) {
this.stat = { ...this.stat, ...this.base };
this.setSnapshot(this.stat);
}
}
private assertOpen(): void {
if (this.disposed) throw new Error("The file tab was closed.");
}
private setSnapshot(snapshot: FileHandleSnapshot): void {
if (sameSnapshot(this.snapshot, snapshot)) return;
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
function versionKey(version: Version): string {
return version.revision ?? `${version.size}:${version.modifiedAt}`;
}
function sameSnapshot(current: FileHandleSnapshot, next: FileHandleSnapshot): boolean {
if (current.status !== next.status) return false;
if (current.status === "ready" && next.status === "ready") {
return (
versionKey(current) === versionKey(next) &&
current.kind === next.kind &&
current.mimeType === next.mimeType
);
}
if (current.status === "error" && next.status === "error") return current.error === next.error;
return true;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { useSessionStore } from "@/stores/session-store";
import { createDaemonFileSession } from "./daemon-session";
import { FileHandle, type FileHandleSnapshot, type FileTarget } from "./handle";
/** The tab target's file handle, observing while `observing`. Disposed when the target changes. */
export function useFileHandle(input: {
serverId: string;
target: FileTarget | null;
absolutePath: string;
observing: boolean;
}): FileHandle | null {
const { serverId, target, absolutePath, observing } = input;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
const workspaceFileEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
const fileByteWrites = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.fileByteWrites === true,
);
const cwd = target?.cwd ?? null;
const path = target?.path ?? null;
const [handle, setHandle] = useState<{ key: string; handle: FileHandle } | null>(null);
const key = client && cwd && path ? `${cwd}:${path}` : null;
useEffect(() => {
if (!client || !cwd || !path) {
setHandle(null);
return;
}
const session = createDaemonFileSession(client, { workspaceFileEditing, fileByteWrites });
const next = new FileHandle(session, { cwd, path }, absolutePath);
setHandle({ key: `${cwd}:${path}`, handle: next });
return () => next.dispose();
}, [absolutePath, client, cwd, fileByteWrites, path, workspaceFileEditing]);
const current = handle?.key === key ? handle.handle : null;
useEffect(() => {
current?.setObserving(observing);
}, [current, observing]);
return current;
}
const LOADING: FileHandleSnapshot = { status: "loading" };
const subscribeNothing = () => () => {};
const getLoading = () => LOADING;
export function useFileHandleSnapshot(handle: FileHandle | null): FileHandleSnapshot {
return useSyncExternalStore(
handle?.subscribe ?? subscribeNothing,
handle?.getSnapshot ?? getLoading,
handle?.getSnapshot ?? getLoading,
);
}
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { describe, expect, it } from "vitest";
import { FileHandle } from "./handle";
import { InMemoryFileSession } from "./testing";
function openFile(content = "first\n") {
const session = new InMemoryFileSession();
session.put("notes.md", content);
const handle = new FileHandle(session, { cwd: "/repo", path: "notes.md" }, "/repo/notes.md");
handle.setObserving(true);
return { session, handle, file: handle.file };
}
describe("file hooks", () => {
it("reads the text, then follows changes without going back to loading", async () => {
const { session, file } = openFile();
const states: string[] = [];
const { result } = renderHook(() => {
const text = useFileText(file);
states.push(text.status);
return text;
});
await waitFor(() => expect(result.current).toEqual({ status: "ready", text: "first\n" }));
act(() => session.put("notes.md", "second\n"));
await waitFor(() => expect(result.current).toEqual({ status: "ready", text: "second\n" }));
expect(states.lastIndexOf("loading")).toBeLessThan(states.indexOf("ready"));
});
it("reports a deleted file as an error", async () => {
const { session, file } = openFile();
const { result } = renderHook(() => useFileText(file));
await waitFor(() => expect(result.current.status).toBe("ready"));
act(() => session.remove("notes.md"));
await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.status === "error" && result.current.error.message).toBe(
"The file was deleted.",
);
});
it("resolves a URL per version and releases the old one", async () => {
const { session, file } = openFile();
const { result } = renderHook(() => useFileUrl(file));
await waitFor(() => expect(result.current).toEqual({ status: "ready", url: "blob:test/1" }));
act(() => session.put("notes.md", "second\n"));
await waitFor(() => expect(result.current).toEqual({ status: "ready", url: "blob:test/2" }));
expect(session.releasedUrls).toEqual(["blob:test/1"]);
});
});
@@ -0,0 +1,2 @@
export { FileHandle, type FileHandleSnapshot, type FileStat, type FileTarget } from "./handle";
export { useFileHandle, useFileHandleSnapshot } from "./hook";
@@ -0,0 +1,127 @@
import type { FileWriteResult } from "@getpaseo/protocol/messages";
import type { FileContents, FileObservation, FileSession, FileTarget } from "./handle";
interface StoredFile {
bytes: Uint8Array;
revision: number;
}
/**
* A file session over an in-memory disk, for tests. A write delivers its own observation before
* it resolves, the order the daemon's watcher can produce.
*/
export class InMemoryFileSession implements FileSession {
readonly createdUrls: string[] = [];
readonly releasedUrls: string[] = [];
readonly writes: string[] = [];
private readonly files = new Map<string, StoredFile>();
private readonly observers = new Set<{
path: string;
onObservation: (observation: FileObservation) => void;
}>();
private revision = 0;
private urlCount = 0;
private nextWriteError: string | null = null;
/** The next write reports this error instead of writing. */
failNextWrite(error: string): void {
this.nextWriteError = error;
}
/** Writes the file as another program would. */
put(path: string, content: string | Uint8Array): void {
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
this.files.set(path, { bytes, revision: ++this.revision });
this.notify(path);
}
remove(path: string): void {
this.files.delete(path);
this.notify(path);
}
text(path: string): string | null {
const file = this.files.get(path);
return file ? new TextDecoder("utf-8", { ignoreBOM: true }).decode(file.bytes) : null;
}
observerCount(): number {
return this.observers.size;
}
async observe(target: FileTarget, onObservation: (observation: FileObservation) => void) {
const observer = { path: target.path, onObservation };
this.observers.add(observer);
return {
initial: this.observation(target.path),
unsubscribe: () => {
this.observers.delete(observer);
},
};
}
async read(target: FileTarget): Promise<FileContents> {
const file = this.files.get(target.path);
if (!file) throw new Error("File not found");
return { bytes: file.bytes, ...version(file) };
}
async write(input: {
target: FileTarget;
content: string | Uint8Array;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
if (this.nextWriteError) {
const error = this.nextWriteError;
this.nextWriteError = null;
return { status: "error", error };
}
const file = this.files.get(input.target.path);
if (!file) {
return {
status: "conflict",
version: { status: "missing", cwd: "/", path: input.target.path },
};
}
if (input.expectedRevision !== String(file.revision)) {
return {
status: "conflict",
version: { status: "ready", cwd: "/", path: input.target.path, ...version(file) },
};
}
this.writes.push(
typeof input.content === "string" ? input.content : `bytes:${input.content.length}`,
);
this.put(input.target.path, input.content);
const written = this.files.get(input.target.path)!;
return { status: "written", ...version(written) };
}
async createUrl() {
const url = `blob:test/${++this.urlCount}`;
this.createdUrls.push(url);
return { url, release: () => this.releasedUrls.push(url) };
}
private observation(path: string): FileObservation {
const file = this.files.get(path);
if (!file) return { status: "missing", cwd: "/", path };
return { status: "ready", kind: "text", mimeType: "text/plain", ...version(file) };
}
private notify(path: string): void {
const observation = this.observation(path);
for (const observer of this.observers) {
if (observer.path === path) observer.onObservation(observation);
}
}
}
function version(file: StoredFile) {
return {
size: file.bytes.length,
modifiedAt: new Date(file.revision * 1000).toISOString(),
revision: String(file.revision),
};
}
+5 -1
View File
@@ -1,7 +1,11 @@
export { isSourceFilePanel } from "./catalog";
export { FilePanelView } from "./view";
export type { FilePanel } from "./panels";
export { useFilePanelChoice, type FilePanelChoice } from "./choice/hook";
export {
useFilePanelChoice,
useFilePanelChoiceSnapshot,
type FilePanelChoice,
} from "./choice/hook";
export { FilePanelPicker } from "./choice/picker";
export { FilePanelSwitcher } from "./choice/switcher";
export {
+35
View File
@@ -0,0 +1,35 @@
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The file tab's loading state. It covers the whole panel, so a panel can render beneath it. */
export function FilePanelLoading() {
const { t } = useTranslation();
return (
<View style={styles.overlay} testID="file-preview-loading">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.text}>{t("panels.file.loading")}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
backgroundColor: theme.colors.surface0,
},
text: {
marginTop: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
+17 -7
View File
@@ -1,19 +1,31 @@
import { useMemo } from "react";
import type {
PluginFile,
PluginFileLocation,
PluginFilePanelContribution,
PluginFileTab,
} from "@getpaseo/plugin/client";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin, EvaluatedPluginFilePanelContribution } from "@/plugins/types";
import type { InstalledPlugin } from "@/plugins/types";
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
export interface PluginFilePanel {
id: string;
builtin: false;
files: EvaluatedPluginFilePanelContribution["files"];
document: "text" | "bytes";
maxBytes: number;
files: PluginFilePanelContribution["files"];
plugin: InstalledPlugin;
contribution: EvaluatedPluginFilePanelContribution;
contribution: PluginFilePanelContribution;
}
export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel;
/** A built-in panel gets the plugin panel's file, tab and location, plus where the file lives. */
export interface BuiltinFilePanelProps {
file: PluginFile;
tab: PluginFileTab;
location: PluginFileLocation;
target: { serverId: string; workspaceRoot: string; path: string };
}
export function useFilePanels(serverId: string): readonly FilePanel[] {
const plugins = useInstalledPlugins();
return useMemo(
@@ -35,8 +47,6 @@ function hostFilePanels(plugins: readonly InstalledPlugin[]): readonly FilePanel
id: `plugin:${plugin.id}/${contribution.id}`,
builtin: false,
files: contribution.files,
document: contribution.document,
maxBytes: contribution.maxBytes,
plugin,
contribution,
})),
+6 -46
View File
@@ -1,5 +1,4 @@
import { useMemo } from "react";
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type { PluginTheme } from "@getpaseo/plugin";
import { withUnistyles } from "react-native-unistyles";
@@ -12,30 +11,12 @@ import { PluginInstallationProvider } from "@/plugins/installation-provider";
import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary";
import { resolvePluginPlatform } from "@/plugins/platform";
import { toPluginTheme } from "@/plugins/theme";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import type { Theme } from "@/styles/theme";
import type { ExplorerFile } from "@/stores/session-store";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { filePanelExtension } from "./resolve";
import type { PluginFilePanel } from "./panels";
import type { BuiltinFilePanelProps, PluginFilePanel } from "./panels";
interface Props {
workspaceRoot: string;
panel: PluginFilePanel;
preview: ExplorerFile;
location: WorkspaceFileLocation;
navigationRevision: number;
document: PluginFilePanelProps["document"];
}
function PluginHost({
workspaceRoot,
panel,
preview,
location,
navigationRevision,
document,
theme,
}: Props & { theme: PluginTheme }) {
type Props = Pick<BuiltinFilePanelProps, "file" | "tab" | "location"> & { panel: PluginFilePanel };
function PluginHost({ panel, file, tab, location, theme }: Props & { theme: PluginTheme }) {
const { workspaceId } = usePaneContext();
const { plugin, contribution } = panel;
const serverId = plugin.serverId;
@@ -46,27 +27,6 @@ function PluginHost({
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const file = useMemo(
() => ({
path: buildAbsoluteExplorerPath({ workspaceRoot, entryPath: location.path }),
name: location.path.split(/[\\/]/).pop() ?? "",
extension: filePanelExtension(location.path),
mimeType: preview.mimeType ?? "application/octet-stream",
size: preview.size,
modifiedAt: preview.modifiedAt,
lineStart: location.lineStart,
lineEnd: location.lineEnd,
}),
[
workspaceRoot,
location.path,
location.lineStart,
location.lineEnd,
preview.mimeType,
preview.size,
preview.modifiedAt,
],
);
const Component = contribution.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Component} key={panel.id}>
@@ -78,9 +38,9 @@ function PluginHost({
layout={layout}
navigation={navigation}
workspaceId={workspaceId}
navigationRevision={navigationRevision}
document={document}
file={file}
tab={tab}
location={location}
/>
</PluginClientStateProvider>
</PluginInstallationProvider>
+19 -113
View File
@@ -1,8 +1,3 @@
import { createFilePanePreview } from "@/file-pane/preview-lifecycle/model";
import { filePanelCatalog } from "./catalog";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { resolveFileReadPanels } from "./resolve";
import { describe, expect, it } from "vitest";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
@@ -10,15 +5,14 @@ const markdown: FilePanelCandidate = {
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
fileKind: "text",
};
const alternative: FilePanelCandidate = {
...markdown,
id: "plugin:notes/markdown",
builtin: false,
files: { extensions: ["md", "markdown"] },
};
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text" };
function resolve(
overrides: Partial<Parameters<typeof resolveFilePanel<FilePanelCandidate>>[0]> = {},
@@ -66,7 +60,7 @@ describe("resolveFilePanel", () => {
associatedPanelId: null,
});
});
it("offers a panel claiming every file wherever its document is available", () => {
it("offers a panel claiming every file of its kind for files of that kind", () => {
const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
expect(resolve({ file: { ...file, path: "index.ts" }, candidates: [every] })).toEqual({
panel: every,
@@ -76,26 +70,17 @@ describe("resolveFilePanel", () => {
});
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
});
it("filters text panels for binary files even with an association", () => {
it("passes over a built-in panel for another kind of file, even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
).toBeNull();
).toBe(alternative);
});
it("requires bytes for bytes panels and accepts binary files with bytes", () => {
const bytes: FilePanelCandidate = { ...alternative, document: "bytes" };
expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull();
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes);
});
it("includes the maxBytes boundary and skips oversized associated panels", () => {
const limited = { ...alternative, maxBytes: 100 };
expect(resolve({ candidates: [limited] }).panel).toBe(limited);
expect(
resolve({
file: { ...file, size: 101 },
candidates: [limited, markdown],
associations: { "ext:md": limited.id },
}).panel,
).toBe(markdown);
it("offers plugin panels for every kind of file their selector matches", () => {
for (const kind of ["text", "image", "binary"] as const) {
expect(resolve({ file: { ...file, kind }, candidates: [alternative] }).panel).toBe(
alternative,
);
}
});
it("uses exact basename keys when an eligible candidate matches by filename", () => {
const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } };
@@ -111,11 +96,13 @@ describe("resolveFilePanel", () => {
});
it("uses extension keys when filename candidates were filtered out", () => {
const named = {
...alternative,
maxBytes: 1,
...markdown,
id: "builtin:named",
files: { extensions: [], filenames: ["README.MD"] },
};
expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md");
expect(
resolve({ file: { ...file, kind: "binary" }, candidates: [named, alternative] }).selectorKey,
).toBe("ext:md");
});
it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])(
"matches an extensionless exact filename: %s",
@@ -136,95 +123,14 @@ describe("resolveFilePanel", () => {
});
});
it("opens a binary PDF with its plugin and filters oversized and unavailable documents", () => {
it("opens a binary PDF with its plugin by its selector alone", () => {
const pdf: FilePanelCandidate = {
id: "plugin:pdf/viewer",
builtin: false,
files: { extensions: ["pdf"] },
document: "bytes",
maxBytes: 1024,
};
const pdfFile: FilePanelFile = {
path: "/workspace/document.pdf",
kind: "binary",
size: 1024,
hasBytes: true,
};
const pdfFile: FilePanelFile = { path: "/workspace/document.pdf", kind: "binary" };
expect(
resolveFilePanel({ file: pdfFile, candidates: [markdown, pdf], associations: {} }).panel,
).toBe(pdf);
expect(
resolveFilePanel({
file: { ...pdfFile, size: 1025 },
candidates: [pdf],
associations: { "ext:pdf": pdf.id },
}).panel,
).toBeNull();
});
describe("resolved panel resources", () => {
const attachment: AttachmentMetadata = {
id: "preview",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "preview",
createdAt: 0,
};
function read(path: string, kind: FileReadResult["kind"]): FileReadResult {
return {
path,
kind,
bytes: new TextEncoder().encode("contents"),
mime: "text/plain",
size: 8,
modifiedAt: "2026-10-19T00:00:00Z",
};
}
it.each([
["notes.md", "text"],
["source.ts", "text"],
["unknown.bin", "binary"],
] as const)(
"does not create an attachment for %s, including live refreshes",
async (path, kind) => {
const writes: Uint8Array[] = [];
const persist = async (input: { bytes: Uint8Array }) => {
writes.push(input.bytes);
return attachment;
};
for (const modifiedAt of ["2026-10-19T00:00:00Z", "2026-10-19T00:00:01Z"]) {
const raw = { ...read(path, kind), modifiedAt };
const panel =
resolveFileReadPanels({
file: raw,
path,
candidates: filePanelCatalog([]),
associations: {},
})?.panel ?? null;
const preview = await createFilePanePreview(raw, panel, persist);
expect(preview?.bytesPreview).toBeNull();
expect(preview?.file).not.toHaveProperty("bytes");
}
expect(writes).toEqual([]);
},
);
it("persists and retains bytes only for the selected image panel", async () => {
const raw = read("picture.png", "image");
const panel =
resolveFileReadPanels({
file: raw,
path: raw.path,
candidates: filePanelCatalog([]),
associations: {},
})?.panel ?? null;
const writes: Uint8Array[] = [];
const preview = await createFilePanePreview(raw, panel, async (input) => {
writes.push(input.bytes);
return attachment;
});
expect(panel?.id).toBe("builtin:image");
expect(writes).toEqual([raw.bytes]);
expect(preview?.bytesPreview).toEqual({ bytes: raw.bytes, attachment });
expect(preview?.file).not.toHaveProperty("bytes");
});
});
+13 -33
View File
@@ -1,10 +1,9 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
export type FilePanelFileKind = "text" | "image" | "binary";
/** What resolution knows about a file: its path and the kind its stat reports, never contents. */
export interface FilePanelFile {
path: string;
kind: "text" | "image" | "binary";
size: number;
hasBytes: boolean;
kind: FilePanelFileKind;
}
export interface FilePanelSelector {
@@ -12,7 +11,7 @@ export interface FilePanelSelector {
filenames?: readonly string[];
}
/** A selector, or `"all"`: every file whose document the panel can read. */
/** A selector, or `"all"`: every file of the panel's `fileKind`. */
export type FilePanelFiles = FilePanelSelector | "all";
export interface FilePanelCandidate {
@@ -20,8 +19,8 @@ export interface FilePanelCandidate {
/** Paseo's own panel. Paseo asks which panel opens a file only when a plugin panel claims it. */
builtin: boolean;
files: FilePanelFiles;
document: "text" | "bytes";
maxBytes: number;
/** The only kind of file a built-in panel reads. Plugin panels are offered for any kind. */
fileKind?: FilePanelFileKind;
}
export interface FilePanelResolution<Candidate> {
@@ -87,8 +86,8 @@ export function filePanelClaimedSelectorKeys(files: FilePanelFiles): string[] {
}
/**
* Whether a panel is offered for files under `selectorKey`, by the resolver's matching rule. Size
* and document kind depend on the file, so they are not part of the answer.
* Whether a panel is offered for files under `selectorKey`, by the resolver's matching rule. The
* file kind depends on the file, so it is not part of the answer.
*/
export function claimsFilePanelSelectorKey(
selector: FilePanelSelector,
@@ -113,12 +112,11 @@ export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
choice?: string | null;
}): FilePanelResolution<Candidate> {
const { file, associations } = input;
const candidates = input.candidates.filter((candidate) => {
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
return (
documentAvailable && file.size <= candidate.maxBytes && matchesFiles(file, candidate.files)
);
});
const candidates = input.candidates.filter(
(candidate) =>
(candidate.fileKind === undefined || candidate.fileKind === file.kind) &&
matchesFiles(file, candidate.files),
);
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
const selectorKey = matchedFilename
@@ -129,21 +127,3 @@ export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
const panel = chosen ?? associated ?? candidates[0] ?? null;
return { panel, candidates, selectorKey, associatedPanelId: associated?.id ?? null };
}
/** Raw reads always carry bytes, independently of whether a panel will retain them. */
export function resolveFileReadPanels<Candidate extends FilePanelCandidate>(input: {
file: FileReadResult | null;
path: string;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
choice?: string | null;
}): FilePanelResolution<Candidate> | null {
const { file, path } = input;
if (!file) return null;
return resolveFilePanel({
file: { path, kind: file.kind, size: file.size, hasBytes: true },
candidates: input.candidates,
associations: input.associations,
choice: input.choice,
});
}
@@ -18,8 +18,6 @@ function richMarkdown(serverId: string): HostedCandidate {
id: "plugin:markdown-panel/rich-markdown",
builtin: false,
files: { extensions: ["MD"] },
document: "text",
maxBytes: 10 * 1024 * 1024,
serverId,
};
}
@@ -28,8 +26,6 @@ const dockerfile: HostedCandidate = {
id: "plugin:docker/dockerfile",
builtin: false,
files: { extensions: [], filenames: ["Dockerfile"] },
document: "text",
maxBytes: Infinity,
serverId: "host-a",
};
@@ -53,13 +49,11 @@ describe("buildFilePanelSettingsRows", () => {
]);
});
it("offers Source only on keys a text panel claims, and asks only between several", () => {
it("offers Source only on keys a built-in text panel claims, and asks only between several", () => {
const pdf: HostedCandidate = {
id: "plugin:pdf-panel/pdf",
builtin: false,
files: { extensions: ["pdf"] },
document: "bytes",
maxBytes: Infinity,
};
expect(rows(catalog(pdf))).toEqual([]);
expect(rows(catalog(pdf), { "ext:pdf": pdf.id })).toEqual([
@@ -67,6 +61,11 @@ describe("buildFilePanelSettingsRows", () => {
]);
});
it("does not offer Source on a key only plugin panels claim: their files' kind is unknown", () => {
const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" };
expect(rows(catalog(dockerfile, other))[0]?.candidates).toEqual([dockerfile, other]);
});
it("offers a panel installed on several hosts once, as the first host has it", () => {
const first = richMarkdown("host-a");
const [row] = rows(catalog(first, richMarkdown("host-b")));
@@ -40,9 +40,9 @@ export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>
}
/**
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file
* (Source) is offered where a panel claiming the key reads the same document: that is the only
* knowledge of whether the key's files are text.
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file of
* a kind (Source) is offered where a built-in panel claiming the key reads that kind: without a
* file, that is the only knowledge of whether the key's files are text.
*/
function offeredCandidates<Candidate extends FilePanelCandidate>(
candidates: readonly Candidate[],
@@ -54,7 +54,7 @@ function offeredCandidates<Candidate extends FilePanelCandidate>(
);
return candidates.filter((candidate) =>
candidate.files === "all"
? claiming.some((claimant) => claimant.document === candidate.document)
? claiming.some((claimant) => claimant.fileKind === candidate.fileKind)
: claiming.includes(candidate),
);
}
@@ -10,14 +10,12 @@ const warningIconMapping = (theme: Theme) => ({ color: theme.colors.palette.ambe
export type FileConflictAlertState =
| { kind: "changed"; canOverwrite: boolean; onReload(): void; onOverwrite(): void }
| { kind: "deleted" }
| { kind: "checkFailed"; retrying: boolean; onRetry(): void };
| { kind: "deleted" };
export function FileConflictAlert({ state }: { state: FileConflictAlertState }) {
const { t } = useTranslation();
let title = t("panels.file.editor.changedOnDisk");
if (state.kind === "deleted") title = t("panels.file.editor.deletedTitle");
else if (state.kind === "checkFailed") title = t("panels.file.editor.checkFailedTitle");
let description: string | undefined;
if (state.kind !== "changed") description = t("panels.file.editor.preservedDescription");
else if (state.canOverwrite) description = t("panels.file.editor.conflictDescription");
@@ -29,23 +27,16 @@ export function FileConflictAlert({ state }: { state: FileConflictAlertState })
<Text style={styles.title}>{title}</Text>
{description ? <Text style={styles.description}>{description}</Text> : null}
</View>
{state.kind !== "deleted" ? (
{state.kind === "changed" ? (
<View style={styles.actions}>
{state.kind === "changed" && state.canOverwrite ? (
{state.canOverwrite ? (
<Button variant="outline" size="sm" onPress={state.onOverwrite}>
{t("panels.file.editor.overwrite")}
</Button>
) : null}
{state.kind === "changed" ? (
<Button variant="outline" size="sm" onPress={state.onReload}>
{t("panels.file.editor.reload")}
</Button>
) : null}
{state.kind === "checkFailed" ? (
<Button variant="outline" size="sm" onPress={state.onRetry} loading={state.retrying}>
{t("common.actions.retry")}
</Button>
) : null}
<Button variant="outline" size="sm" onPress={state.onReload}>
{t("panels.file.editor.reload")}
</Button>
</View>
) : null}
</View>
@@ -0,0 +1,240 @@
import { describe, expect, test } from "vitest";
import { FileHandle } from "../../file/handle";
import { InMemoryFileSession } from "../../file/testing";
import { FileEditorModel, getFileConflictCallout, type FileEditorClock } from "./model";
class TestClock implements FileEditorClock {
private callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout(): void {
this.callback = null;
}
get scheduled(): boolean {
return this.callback !== null;
}
fire(): void {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function openEditor(content: string | Uint8Array = "first\n") {
const session = new InMemoryFileSession();
session.put("source.ts", content);
const handle = new FileHandle(session, { cwd: "/repo", path: "source.ts" }, "/repo/source.ts");
handle.setObserving(true);
await settle();
const clock = new TestClock();
const model = new FileEditorModel({ file: handle.file, clock });
model.open();
await settle();
return { session, handle, clock, model, disk: () => session.text("source.ts") };
}
describe("FileEditorModel", () => {
test("loads the file clean", async () => {
const { model } = await openEditor();
expect(model.getSnapshot()).toMatchObject({
status: "clean",
content: "first\n",
modified: false,
});
});
test("reports a file it cannot read", async () => {
const session = new InMemoryFileSession();
const handle = new FileHandle(session, { cwd: "/repo", path: "gone.ts" }, "/repo/gone.ts");
const model = new FileEditorModel({ file: handle.file });
model.open();
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "failed", error: "File not found" });
});
test("tracks whether the buffer differs from the file", async () => {
const { model } = await openEditor();
model.edit("second\n");
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true });
model.edit("first\n");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("autosaves the latest edit after inactivity", async () => {
const { model, clock, disk } = await openEditor();
model.edit("draft\n");
model.edit("latest\n");
clock.fire();
await settle();
expect(disk()).toBe("latest\n");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("keeps a newer edit modified when an older save finishes", async () => {
const { model, disk } = await openEditor();
model.edit("saved\n");
const save = model.save();
model.edit("newer\n");
await save;
expect(disk()).toBe("saved\n");
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true });
});
test("keeps CRLF content and the byte order mark in file form", async () => {
const { model, disk } = await openEditor(
new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode("a\r\nb\n")]),
);
expect(model.getSnapshot()).toMatchObject({ content: "a\r\nb\n", lineSeparator: "\r\n" });
model.edit("c\r\nd\r\n");
await model.save();
expect(disk()).toBe("c\r\nd\r\n");
});
test("follows the disk while clean", async () => {
const { session, model } = await openEditor();
session.put("source.ts", "external\r\n");
await settle();
expect(model.getSnapshot()).toMatchObject({
status: "clean",
content: "external\r\n",
lineSeparator: "\r\n",
});
});
test("conflicts when the disk changes under a modified buffer, and overwrites", async () => {
const { session, model, disk } = await openEditor();
model.edit("local\n");
session.put("source.ts", "external\n");
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local\n" });
expect(getFileConflictCallout(model.getSnapshot())).toEqual({
kind: "changed",
canOverwrite: true,
});
await model.overwrite();
expect(disk()).toBe("local\n");
expect(model.getSnapshot()).toMatchObject({ status: "clean", conflict: null });
});
test("reload discards a conflicted buffer for the disk", async () => {
const { session, model } = await openEditor();
model.edit("local\n");
session.put("source.ts", "external\n");
await settle();
await model.reload();
expect(model.getSnapshot()).toMatchObject({
status: "clean",
content: "external\n",
modified: false,
});
});
test("a save over an unobserved change conflicts instead of writing", async () => {
const { session, handle, model, disk } = await openEditor();
handle.setObserving(false);
session.put("source.ts", "external\n");
model.edit("local\n");
await model.save();
expect(disk()).toBe("external\n");
expect(model.getSnapshot()).toMatchObject({ status: "conflict", conflict: "changed" });
});
test("stays clean when the disk changes back to its own content", async () => {
const { session, model, clock } = await openEditor();
model.edit("local\n");
session.put("source.ts", "first\n");
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local\n" });
expect(clock.scheduled).toBe(true);
});
test("a deletion conflicts with local changes and stops autosave", async () => {
const { session, model, clock } = await openEditor();
model.edit("local\n");
session.remove("source.ts");
await settle();
expect(getFileConflictCallout(model.getSnapshot())).toEqual({ kind: "deleted" });
expect(clock.scheduled).toBe(false);
});
test("an external change during a save is reconciled after it", async () => {
const { session, handle, model } = await openEditor();
model.edit("local\n");
const resume = handle.holdWrites();
const save = model.save();
session.put("source.ts", "external\n");
resume();
await save;
await settle();
expect(model.getSnapshot()).toMatchObject({ status: "conflict", conflict: "changed" });
});
test("reports failed saves without losing the buffer", async () => {
const { session, model } = await openEditor();
session.failNextWrite("disk full");
model.edit("local\n");
await model.save();
expect(model.getSnapshot()).toMatchObject({
status: "error",
content: "local\n",
error: "disk full",
});
});
test("flushes a modified buffer when disposed for another panel", async () => {
const { model, disk } = await openEditor();
model.edit("switching\n");
model.dispose({ flush: true });
await settle();
expect(disk()).toBe("switching\n");
});
test("discards the buffer when the tab closes while holding writes", async () => {
const { handle, model, disk } = await openEditor();
model.edit("discarded\n");
handle.holdWrites();
model.dispose({ flush: true });
handle.dispose();
await settle();
expect(disk()).toBe("first\n");
});
});
@@ -0,0 +1,287 @@
import {
PluginFileConflictError,
type PluginFile,
type PluginFileEvent,
} from "@getpaseo/plugin/client";
export type FileEditorStatus =
| "loading"
| "failed"
| "clean"
| "dirty"
| "saving"
| "conflict"
| "error";
export type FileLineSeparator = "\n" | "\r\n" | "\r";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
lineSeparator: FileLineSeparator;
/** The buffer differs from what this editor last read or saved. */
modified: boolean;
/** Why the editor is in conflict: the file changed or was deleted on disk. */
conflict: "changed" | "deleted" | null;
/** The failed load's or save's message. */
error: string | null;
}
export type FileConflictCallout = { kind: "changed"; canOverwrite: boolean } | { kind: "deleted" };
export interface FileEditorClock {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
}
const systemClock: FileEditorClock = {
setTimeout(callback, delay) {
return globalThis.setTimeout(callback, delay);
},
clearTimeout(handle) {
globalThis.clearTimeout(handle);
},
};
const AUTOSAVE_DELAY_MS = 800;
/**
* Source's buffer over a `PluginFile`: reads the file, autosaves edits, and reconciles changes on
* disk. A clean buffer follows the disk; a modified one conflicts and the user overwrites or
* reloads. The file handle rejects writes over versions this editor has not read.
*/
export class FileEditorModel {
private readonly file: PluginFile;
private readonly clock: FileEditorClock;
private readonly listeners = new Set<() => void>();
private snapshot: FileEditorSnapshot = {
status: "loading",
content: "",
lineSeparator: "\n",
modified: false,
conflict: null,
error: null,
};
private persisted = "";
private autosave: ReturnType<typeof setTimeout> | null = null;
private saving = false;
private saveSequence = 0;
private readSequence = 0;
private eventDuringSave: PluginFileEvent | null = null;
private unwatch: (() => void) | null = null;
private disposed = false;
constructor(input: { file: PluginFile; clock?: FileEditorClock }) {
this.file = input.file;
this.clock = input.clock ?? systemClock;
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileEditorSnapshot => this.snapshot;
/** Reads the file and follows its changes on disk. */
open(): void {
if (this.disposed || this.unwatch) return;
this.unwatch = this.file.watch((event) => this.receiveFileEvent(event));
void this.reload();
}
edit(content: string): void {
if (this.disposed || !this.isLoaded() || content === this.snapshot.content) return;
const modified = content !== this.persisted;
let status: FileEditorStatus = modified ? "dirty" : "clean";
if (this.snapshot.status === "conflict") status = "conflict";
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
async save(): Promise<void> {
if (this.snapshot.status !== "dirty" && this.snapshot.status !== "error") return;
await this.write({ force: false });
}
/** Writes the buffer over a file that changed on disk. */
async overwrite(): Promise<void> {
if (this.snapshot.conflict !== "changed") return;
await this.write({ force: true });
}
/** Replaces the buffer with the file on disk. */
async reload(): Promise<void> {
if (this.disposed) return;
const sequence = ++this.readSequence;
try {
const content = await this.file.readText();
if (this.disposed || sequence !== this.readSequence) return;
this.applyFile(content);
} catch (error) {
if (this.disposed || sequence !== this.readSequence) return;
if (this.isLoaded()) return;
this.setSnapshot({ ...this.snapshot, status: "failed", error: errorMessage(error) });
}
}
/**
* Stops following the file. With `flush`, a modified buffer is written first, so switching to
* another panel keeps the edit. The tab holds that write while it asks to discard it.
*/
dispose({ flush }: { flush: boolean }): void {
if (this.disposed) return;
const status = this.snapshot.status;
if (flush && this.snapshot.modified && status !== "conflict") {
void this.file.writeText(this.snapshot.content).catch(() => undefined);
}
this.disposed = true;
this.clearAutosave();
this.unwatch?.();
this.unwatch = null;
this.listeners.clear();
}
private receiveFileEvent(event: PluginFileEvent): void {
if (this.disposed) return;
if (!this.isLoaded()) {
if (event === "changed") void this.reload();
return;
}
if (this.saving) {
this.eventDuringSave = event;
return;
}
if (event === "deleted") {
this.enterConflict("deleted");
return;
}
void this.reconcile();
}
/** Follows a clean buffer to the disk; a modified one conflicts unless the disk matches it. */
private async reconcile(): Promise<void> {
const sequence = ++this.readSequence;
let content: string;
try {
content = await this.file.readText();
} catch {
return;
}
if (this.disposed || sequence !== this.readSequence) return;
if (this.saving) {
this.eventDuringSave = "changed";
return;
}
if (content === this.persisted) {
this.adoptUnchangedFile();
return;
}
if (!this.snapshot.modified) {
this.applyFile(content);
return;
}
this.enterConflict("changed");
}
private async write({ force }: { force: boolean }): Promise<void> {
if (this.disposed) return;
this.clearAutosave();
const sequence = ++this.saveSequence;
const content = this.snapshot.content;
this.saving = true;
this.eventDuringSave = null;
this.setSnapshot({ ...this.snapshot, status: "saving", conflict: null, error: null });
try {
await this.file.writeText(content, { force });
} catch (error) {
this.saving = false;
if (this.disposed || sequence !== this.saveSequence) return;
if (error instanceof PluginFileConflictError) this.enterConflict("changed");
else this.setSnapshot({ ...this.snapshot, status: "error", error: errorMessage(error) });
this.receivePendingEvent();
return;
}
this.saving = false;
if (this.disposed || sequence !== this.saveSequence) return;
this.persisted = content;
const modified = this.snapshot.content !== this.persisted;
this.setSnapshot({ ...this.snapshot, status: modified ? "dirty" : "clean", modified });
if (modified) this.scheduleAutosave();
this.receivePendingEvent();
}
private receivePendingEvent(): void {
const event = this.eventDuringSave;
this.eventDuringSave = null;
if (event) this.receiveFileEvent(event);
}
private applyFile(content: string): void {
this.clearAutosave();
this.saveSequence += 1;
this.persisted = content;
this.setSnapshot({
status: "clean",
content,
lineSeparator: detectLineSeparator(content),
modified: false,
conflict: null,
error: null,
});
}
private adoptUnchangedFile(): void {
if (this.snapshot.status !== "conflict") return;
const status = this.snapshot.modified ? "dirty" : "clean";
this.setSnapshot({ ...this.snapshot, status, conflict: null, error: null });
if (status === "dirty") this.scheduleAutosave();
}
private enterConflict(conflict: "changed" | "deleted"): void {
this.clearAutosave();
this.setSnapshot({ ...this.snapshot, status: "conflict", conflict, error: null });
}
private isLoaded(): boolean {
return this.snapshot.status !== "loading" && this.snapshot.status !== "failed";
}
private scheduleAutosave(): void {
this.clearAutosave();
this.autosave = this.clock.setTimeout(() => {
this.autosave = null;
void this.save();
}, AUTOSAVE_DELAY_MS);
}
private clearAutosave(): void {
if (!this.autosave) return;
this.clock.clearTimeout(this.autosave);
this.autosave = null;
}
private setSnapshot(snapshot: FileEditorSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null {
if (snapshot.status !== "conflict" || !snapshot.conflict) return null;
if (snapshot.conflict === "deleted") return { kind: "deleted" };
return { kind: "changed", canOverwrite: snapshot.modified };
}
function detectLineSeparator(content: string): FileLineSeparator {
for (let index = 0; index < content.length; index += 1) {
const character = content.charCodeAt(index);
if (character === 10) return "\n";
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
}
return "\n";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -22,6 +22,7 @@ export function FileEditorView(_props: {
};
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
onReady(): void;
}) {
return (
<View style={styles.container}>
@@ -17,6 +17,8 @@ interface FileEditorViewProps {
theme: EditorVisualTheme;
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
/** The editor shows the file's content. */
onReady(): void;
}
const languageCompartment = new Compartment();
@@ -41,12 +43,20 @@ export function FileEditorView({
theme,
onCursorChange,
onVimModeChange,
onReady,
}: FileEditorViewProps) {
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
const initial = useRef({
filename,
model,
theme,
vimEnabled,
content: snapshot.content,
onReady,
});
const onCursorChangeRef = useRef(onCursorChange);
onCursorChangeRef.current = onCursorChange;
@@ -83,6 +93,7 @@ export function FileEditorView({
});
viewRef.current = view;
onCursorChangeRef.current({ line: 1, column: 1 });
values.onReady();
return () => {
view.destroy();
viewRef.current = null;
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useFileText } from "@getpaseo/plugin/client";
import { Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { formatFileSize } from "@/utils/format-file-size";
import { FilePanelLoading } from "../loading";
import type { BuiltinFilePanelProps } from "../panels";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { EditorVisualTheme } from "./editor/extensions.web";
import { FileEditorModel, getFileConflictCallout } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { selectSourcePresentation } from "./readonly/presentation";
import { FileSourceView } from "./readonly/view";
import { SourceStatusRow } from "./status";
/** The daemon refuses to write larger files. */
const MAX_EDITABLE_BYTES = 1024 * 1024;
/**
* Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file
* on native read-only, and refuses files above the display budget before reading them.
*/
export function SourcePanel(props: BuiltinFilePanelProps) {
const size = props.file.size;
const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
if (presentation === "unsupported") return <TooLargeSource size={size} />;
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableSource {...props} />;
return <ReadonlySource {...props} presentation={presentation} />;
}
function TooLargeSource({ size }: { size: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.mutedText}>{t("panels.file.tooLargeToDisplay")}</Text>
<Text style={styles.mutedText}>{formatFileSize(size)}</Text>
</View>
);
}
function ReadonlySource({
file,
location,
target,
presentation,
}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) {
const text = useFileText(file);
const theme = useEditorVisualTheme();
const { t } = useTranslation();
const sourceLocation = useMemo(
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
[location.lineEnd, location.lineStart, target.path],
);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{text.error.message || t("panels.file.failedToLoad")}</Text>
</View>
);
}
return (
<View style={styles.container}>
<FileSourceView
content={text.text}
filename={target.path}
location={sourceLocation}
navigationRevision={location.revision}
presentation={presentation}
theme={theme}
/>
<SourceStatusRow size={file.size} lineCount={text.text.split("\n").length} />
</View>
);
}
function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [model] = useState(() => new FileEditorModel({ file }));
const [editorReady, setEditorReady] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const theme = useEditorVisualTheme();
useEffect(() => {
model.open();
return () => model.dispose({ flush: true });
}, [model]);
useEffect(() => {
tab.setModified(snapshot.modified);
}, [snapshot.modified, tab]);
const handleReload = useCallback(() => {
if (!snapshot.modified) {
void model.reload();
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: t("panels.file.editor.reloadTitle"),
message: t("panels.file.editor.reloadMessage"),
confirmLabel: t("panels.file.editor.reload"),
destructive: true,
});
if (confirmed) void model.reload();
})();
}, [model, snapshot.modified, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const handleRetry = useCallback(() => void model.reload(), [model]);
const handleReady = useCallback(() => setEditorReady(true), []);
const editorLocation = useMemo(
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
[location.lineEnd, location.lineStart, target.path],
);
if (snapshot.status === "loading") return <FilePanelLoading />;
if (snapshot.status === "failed") {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>{snapshot.error ?? t("panels.file.failedToLoad")}</Text>
<Button variant="outline" size="sm" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
</View>
);
}
const callout = getFileConflictCallout(snapshot);
let conflict: FileConflictAlertState | null = null;
if (callout?.kind === "deleted") conflict = { kind: "deleted" };
else if (callout) {
conflict = {
kind: "changed",
canOverwrite: callout.canOverwrite,
onReload: handleReload,
onOverwrite: handleOverwrite,
};
}
return (
<View style={styles.container}>
{conflict ? <FileConflictAlert state={conflict} /> : null}
<View style={styles.editor}>
<FileEditorView
model={model}
filename={target.path}
location={editorLocation}
navigationRevision={location.revision}
vimEnabled={settings.vimKeybindings}
theme={theme}
onCursorChange={setCursor}
onVimModeChange={setVimMode}
onReady={handleReady}
/>
{editorReady ? null : <FilePanelLoading />}
</View>
<SourceStatusRow
size={file.size}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
vimMode={vimMode}
cursor={cursor}
/>
</View>
);
}
function useEditorVisualTheme(): EditorVisualTheme {
const theme = UnistylesRuntime.getTheme();
return useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[
theme.colors.border,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.surface0,
theme.colors.syntax,
theme.colors.terminal.cursor,
theme.colors.terminal.selectionBackground,
theme.colorScheme,
theme.fontFamily.mono,
theme.fontSize.code,
],
);
}
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, minHeight: 0 },
editor: { flex: 1, minHeight: 0 },
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
padding: theme.spacing[4],
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
@@ -0,0 +1,67 @@
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { describe, expect, it } from "vitest";
import { buildSourceLines } from "./source-lines";
const large = Array.from({ length: 14000 }, (_, i) => `export const item${i} = ${i};`).join("\n");
/** Collects every report until `count` have arrived. */
function build(
input: { presentation: "highlighted" | "plain"; content?: string; firstLines?: number },
count = 1,
) {
return new Promise<HighlightToken[][][]>((resolve) => {
const reports: HighlightToken[][][] = [];
buildSourceLines(
{
content: input.content ?? large,
filename: "large.ts",
presentation: input.presentation,
firstLines: input.firstLines ?? 0,
},
(lines) => {
reports.push(lines);
if (reports.length === count) resolve(reports);
},
);
});
}
describe("buildSourceLines", () => {
it("reports the first lines, then the whole file, leaving the thread free between slices", async () => {
let ticks = 0;
const ticker = setInterval(() => ticks++, 0);
const [first, whole] = await build({ presentation: "highlighted", firstLines: 300 }, 2);
clearInterval(ticker);
const highlighted = highlightCode(large, "large.ts");
expect(first.slice(0, 300)).toEqual(highlighted.slice(0, 300));
expect(first[300]).toEqual([{ text: "export const item300 = 300;", style: null }]);
expect(whole).toEqual(highlighted);
expect(ticks).toBeGreaterThan(1);
});
it("splits plain lines without highlighting them", async () => {
expect(await build({ presentation: "plain", content: "const a = 1;\n\nb" })).toEqual([
[
[{ text: "const a = 1;", style: null }],
[{ text: "", style: null }],
[{ text: "b", style: null }],
],
]);
});
it("never reports lines once cancelled", async () => {
let reported = false;
const cancel = buildSourceLines(
{ content: large, filename: "large.ts", presentation: "highlighted", firstLines: 0 },
() => {
reported = true;
},
);
await new Promise((resolve) => setTimeout(resolve, 20));
cancel();
await new Promise((resolve) => setTimeout(resolve, 300));
expect(reported).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { highlightCodeInSteps, type HighlightToken } from "@getpaseo/highlight";
/** JavaScript time per slice; the rest of the frame stays free for input and rendering. */
const SLICE_BUDGET_MS = 12;
const PLAIN_LINES_PER_UNIT = 2000;
/**
* Splits `content` into source lines, highlighted or plain, a time slice per task so a large file
* never blocks the JavaScript thread. Highlighting reports `firstLines` lines highlighted, the
* rest plain, before the whole file. Returns a cancel function.
*/
export function buildSourceLines(
input: {
content: string;
filename: string;
presentation: "highlighted" | "plain";
firstLines: number;
},
onLines: (lines: HighlightToken[][]) => void,
): () => void {
const steps =
input.presentation === "highlighted"
? highlightCodeInSteps(input.content, input.filename, { firstLines: input.firstLines })
: plainLinesInSteps(input.content);
let cancelSlice = scheduleSlice(runSlice);
function runSlice() {
const deadline = performance.now() + SLICE_BUDGET_MS;
do {
const step = steps.next();
if (step.done) {
onLines(step.value);
return;
}
if (step.value) onLines(step.value);
} while (performance.now() < deadline);
cancelSlice = scheduleSlice(runSlice);
}
return () => cancelSlice();
}
/**
* Runs `slice` once the JavaScript thread is idle. React Native runs idle callbacks after pending
* events and renders without waiting for a frame; a zero timeout waits for the next frame.
*/
function scheduleSlice(slice: () => void): () => void {
if (typeof requestIdleCallback === "function") {
const id = requestIdleCallback(slice);
return () => cancelIdleCallback(id);
}
const timer = setTimeout(slice, 0);
return () => clearTimeout(timer);
}
function* plainLinesInSteps(content: string): Generator<undefined, HighlightToken[][], void> {
const lines: HighlightToken[][] = [];
let from = 0;
for (;;) {
const end = content.indexOf("\n", from);
lines.push([{ text: content.slice(from, end === -1 ? content.length : end), style: null }]);
if (end === -1) return lines;
from = end + 1;
if (lines.length % PLAIN_LINES_PER_UNIT === 0) yield;
}
}
@@ -0,0 +1,136 @@
import { memo, useEffect, useRef, useState } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { FilePanelLoading } from "../../loading";
import type { SourcePresentation } from "./presentation";
import { buildSourceLines } from "./source-lines";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
presentation: Exclude<SourcePresentation, "unsupported">;
theme: EditorVisualTheme;
}
/** Lines past the opening line highlighted before the rest of a large file. */
const FIRST_LINES = 200;
/**
* A virtualized, read-only source view. Shows Loading until the lines around the opening line are
* highlighted; the rest highlight in the background. A changed file keeps showing the previous
* lines until its own are ready.
*/
export function FileSourceView({
content,
filename,
location,
navigationRevision,
presentation,
}: FileSourceViewProps) {
const listRef = useRef<FlatList<HighlightToken[]>>(null);
const [lines, setLines] = useState<HighlightToken[][] | null>(null);
// Navigating within the file does not rebuild its lines; a new build starts at the latest line.
const openingLine = useRef(location.lineStart);
useEffect(() => {
openingLine.current = location.lineStart;
});
useEffect(() => {
const firstLines = (openingLine.current ?? 1) + FIRST_LINES;
return buildSourceLines({ content, filename, presentation, firstLines }, setLines);
}, [content, filename, presentation]);
const lineCount = lines?.length ?? 0;
useEffect(() => {
if (!location.lineStart || lineCount === 0) return;
listRef.current?.scrollToIndex({
index: Math.min(location.lineStart - 1, lineCount - 1),
animated: false,
viewPosition: 0.5,
});
}, [lineCount, location.lineStart, navigationRevision]);
if (!lines) return <FilePanelLoading />;
return (
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
interface SourceLineProps {
tokens: HighlightToken[];
number: number;
}
// Finishing the highlight replaces every line; the lines already highlighted stay as they were.
const SourceLineView = memo(function SourceLineView({ tokens, number }: SourceLineProps) {
// A token's offset in its line identifies it; its text and style can repeat.
let offset = 0;
return (
<View style={styles.line}>
<Text style={styles.gutter}>{number}</Text>
<Text selectable style={styles.text}>
{tokens.map((token) => {
const key = offset;
offset += token.text.length;
return (
<Text key={key} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
);
})}
</Text>
</View>
);
}, sameSourceLine);
function sameSourceLine(previous: SourceLineProps, next: SourceLineProps): boolean {
return (
previous.number === next.number &&
previous.tokens.length === next.tokens.length &&
previous.tokens.every(
(token, index) =>
token.text === next.tokens[index].text && token.style === next.tokens[index].style,
)
);
}
function sourceLineKey(_tokens: HighlightToken[], index: number): string {
return String(index);
}
function sourceLineLayout(_data: ArrayLike<HighlightToken[]> | null | undefined, index: number) {
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<HighlightToken[]> = ({ item, index }) => (
<SourceLineView tokens={item} number={index + 1} />
);
const styles = StyleSheet.create((theme) => ({
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
gutter: {
width: 56,
paddingRight: theme.spacing[3],
textAlign: "right",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
text: {
flex: 1,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
},
}));
@@ -6,63 +6,34 @@ import { getLanguageForFile } from "@getpaseo/highlight";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
import { FilePanelLoading } from "../../loading";
import type { SourcePresentation } from "./presentation";
interface FileSourceViewProps {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
presentation: Exclude<SourcePresentation, "unsupported">;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
const languageCompartment = new Compartment();
const themeCompartment = new Compartment();
/** A read-only source view for files Source does not edit. Shows Loading until the editor exists. */
export function FileSourceView({
content,
filename,
location,
navigationRevision,
size,
theme,
tooLargeMessage,
}: FileSourceViewProps) {
const presentation = selectSourcePresentation({ size, platform: "web" });
if (presentation === "unsupported") {
return (
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
{tooLargeMessage}
</div>
);
}
return (
<ReadonlyCodeMirror
content={content}
filename={filename}
location={location}
navigationRevision={navigationRevision}
presentation={presentation}
theme={theme}
/>
);
}
function ReadonlyCodeMirror({
content,
filename,
location,
navigationRevision,
presentation,
theme,
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
}: FileSourceViewProps) {
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const [ready, setReady] = useState(false);
const initial = useRef({ content, filename, presentation, theme });
useEffect(() => {
@@ -88,6 +59,7 @@ function ReadonlyCodeMirror({
}),
});
viewRef.current = view;
setReady(true);
return () => {
view.destroy();
viewRef.current = null;
@@ -121,6 +93,7 @@ function ReadonlyCodeMirror({
<div style={FRAME_STYLE}>
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
<FileFind model={find} editor={viewRef} />
{ready ? null : <FilePanelLoading />}
</div>
);
}
@@ -142,9 +115,3 @@ const FRAME_STYLE = {
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
display: "flex",
flex: 1,
justifyContent: "center",
} as const;
@@ -0,0 +1,122 @@
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import { formatFileSize } from "@/utils/format-file-size";
import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */
export function SourceStatusRow({
size,
lineCount,
editorStatus,
vimMode,
cursor,
}: {
size: number;
lineCount: number;
editorStatus?: FileEditorStatus;
vimMode?: string | null;
cursor?: { line: number; column: number };
}) {
const { t } = useTranslation();
return (
<View style={styles.row} testID="file-source-status">
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus ? t("panels.file.editor.editorStatus", { status: editorStatus }) : undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexShrink: 0,
height: 28,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
status: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
dirtyDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
vim: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));
+4 -33
View File
@@ -1,38 +1,9 @@
import type { ComponentProps } from "react";
import type { ExplorerFile } from "@/stores/session-store";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FilePanel } from "./panels";
import type { SourceFilePanel } from "./catalog";
import type { FilePanel, BuiltinFilePanelProps } from "./panels";
import { BuiltinFilePanelView } from "./builtin";
import { PluginFilePanelHost } from "./plugin-host";
import { bytesDocument, textDocument } from "./document";
/** Renders a file panel as the file tab's body. Source is the tab's own editor, not hosted here. */
export function FilePanelView(
props: Omit<ComponentProps<typeof BuiltinFilePanelView>, "panel"> & {
panel: Exclude<FilePanel, SourceFilePanel>;
preview: ExplorerFile;
bytes: Uint8Array | null;
location: WorkspaceFileLocation;
navigationRevision: number;
setText?: (text: string) => void;
},
) {
const { panel, preview } = props;
/** Renders a file panel as the file tab's body. The panel owns its loading, errors and saving. */
export function FilePanelView({ panel, ...props }: BuiltinFilePanelProps & { panel: FilePanel }) {
if (panel.builtin) return <BuiltinFilePanelView {...props} panel={panel} />;
let document;
if (panel.document === "text") document = textDocument(props.content, props.setText);
else if (props.bytes && props.documentPreviewUri)
document = bytesDocument(props.bytes, props.documentPreviewUri);
if (!document) return null;
return (
<PluginFilePanelHost
workspaceRoot={props.workspaceRoot}
panel={panel}
preview={preview}
location={props.location}
navigationRevision={props.navigationRevision}
document={document}
/>
);
return <PluginFilePanelHost {...props} panel={panel} />;
}
@@ -5,7 +5,6 @@ import {
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { usePanelStore } from "@/stores/panel-store";
import { revealExplorerDirectory } from "@/file-explorer/tree";
import { parentExplorerPath } from "@/utils/explorer-paths";
@@ -13,7 +12,6 @@ import { parentExplorerPath } from "@/utils/explorer-paths";
function createExplorerState(): AgentFileExplorerState {
return {
directories: new Map(),
files: new Map(),
isLoading: false,
lastError: null,
pendingRequest: null,
@@ -148,7 +146,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
const directories = new Map(state.directories);
@@ -195,70 +192,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[requestDirectoryListing, workspaceStateKey],
);
const requestFilePreview = useCallback(
async (path: string) => {
if (!workspaceStateKey) {
return;
}
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "file" },
}));
if (!normalizedWorkspaceRoot) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return;
}
if (!client) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return;
}
try {
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
const files = new Map(state.files);
const explorerFile = explorerFileFromReadResult(file);
files.set(explorerFile.path, explorerFile);
nextState.files = files;
return nextState;
});
} catch (error) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : t("panels.file.failedToLoadPreview"),
pendingRequest: null,
}));
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const requestFileDownloadToken = useCallback(
async (path: string) => {
if (!normalizedWorkspaceRoot) {
@@ -379,7 +312,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
workspaceStateKey,
revealDirectory,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
requestFileBytes,
createEntry,
+1 -1
View File
@@ -1942,6 +1942,7 @@ export const ar: TranslationResources = {
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
failedToLoad: "فشل تحميل الملف",
notFound: "الملف غير موجود",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
fileSize: "حجم الملف {{size}}",
@@ -1954,7 +1955,6 @@ export const ar: TranslationResources = {
vimMode: "وضع Vim ‏{{mode}}",
cursor: "السطر {{line}}، العمود {{column}}",
deletedTitle: "تم حذف الملف من القرص",
checkFailedTitle: "تعذّر التحقق من الملف على القرص",
preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.",
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
overwrite: "استبدال",
+1 -1
View File
@@ -1967,6 +1967,7 @@ export const en = {
binaryPreviewUnavailable: "Binary preview unavailable",
tooLargeToDisplay: "This file is too large to display",
failedToLoad: "Failed to load file",
notFound: "File not found",
failedToLoadPreview: "Failed to load file preview",
editor: {
fileSize: "File size {{size}}",
@@ -1979,7 +1980,6 @@ export const en = {
vimMode: "Vim mode {{mode}}",
cursor: "Line {{line}}, column {{column}}",
deletedTitle: "File deleted on disk",
checkFailedTitle: "Couldn't check file on disk",
preservedDescription: "The open copy is preserved.",
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
overwrite: "Overwrite",
+1 -1
View File
@@ -1990,6 +1990,7 @@ export const es: TranslationResources = {
binaryPreviewUnavailable: "Vista previa binaria no disponible",
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
failedToLoad: "No se pudo cargar el archivo",
notFound: "No se encontró el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
fileSize: "Tamaño {{size}}",
@@ -2002,7 +2003,6 @@ export const es: TranslationResources = {
vimMode: "Modo Vim {{mode}}",
cursor: "Línea {{line}}, columna {{column}}",
deletedTitle: "Archivo eliminado del disco",
checkFailedTitle: "No se pudo comprobar el archivo en disco",
preservedDescription: "La copia abierta se ha conservado.",
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
overwrite: "Sobrescribir",
+1 -1
View File
@@ -1988,6 +1988,7 @@ export const fr: TranslationResources = {
binaryPreviewUnavailable: "Aperçu indisponible pour un fichier binaire",
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
failedToLoad: "Impossible de charger le fichier",
notFound: "Fichier introuvable",
failedToLoadPreview: "Impossible de charger l’aperçu du fichier",
editor: {
fileSize: "Taille du fichier : {{size}}",
@@ -2000,7 +2001,6 @@ export const fr: TranslationResources = {
vimMode: "Mode Vim {{mode}}",
cursor: "Ligne {{line}}, colonne {{column}}",
deletedTitle: "Fichier supprimé du disque",
checkFailedTitle: "Impossible de vérifier le fichier sur le disque",
preservedDescription: "La copie ouverte est conservée.",
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
overwrite: "Écraser",
+1 -1
View File
@@ -1961,6 +1961,7 @@ export const ja: TranslationResources = {
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
failedToLoad: "ファイルの読み込みに失敗しました",
notFound: "ファイルが見つかりません",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
fileSize: "ファイルサイズ {{size}}",
@@ -1973,7 +1974,6 @@ export const ja: TranslationResources = {
vimMode: "Vim モード {{mode}}",
cursor: "{{line}} 行、{{column}} 列",
deletedTitle: "ファイルはディスク上から削除されました",
checkFailedTitle: "ディスク上のファイルを確認できませんでした",
preservedDescription: "開いているコピーは保持されています。",
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
overwrite: "上書き",
+1 -1
View File
@@ -1952,6 +1952,7 @@ export const ko: TranslationResources = {
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
failedToLoad: "파일을 불러오지 못했습니다",
notFound: "파일을 찾을 수 없습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
editor: {
fileSize: "파일 크기 {{size}}",
@@ -1964,7 +1965,6 @@ export const ko: TranslationResources = {
vimMode: "Vim 모드 {{mode}}",
cursor: "{{line}} 라인, {{column}} 열",
deletedTitle: "디스크에서 파일이 삭제되었습니다.",
checkFailedTitle: "디스크의 파일을 확인할 수 없습니다.",
preservedDescription: "열린 복사본은 보존됩니다.",
conflictDescription: "로컬 버퍼가 보존되었습니다. 유지할 버전을 선택하세요.",
overwrite: "덮어쓰기",
+1 -1
View File
@@ -1975,6 +1975,7 @@ export const ptBR: TranslationResources = {
binaryPreviewUnavailable: "Prévia binária indisponível",
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
failedToLoad: "Falha ao carregar arquivo",
notFound: "Arquivo não encontrado",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
fileSize: "Tamanho {{size}}",
@@ -1987,7 +1988,6 @@ export const ptBR: TranslationResources = {
vimMode: "Modo Vim {{mode}}",
cursor: "Linha {{line}}, coluna {{column}}",
deletedTitle: "Arquivo excluído do disco",
checkFailedTitle: "Não foi possível verificar o arquivo no disco",
preservedDescription: "A cópia aberta foi preservada.",
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
overwrite: "Sobrescrever",
+1 -1
View File
@@ -1974,6 +1974,7 @@ export const ru: TranslationResources = {
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
tooLargeToDisplay: "Этот файл слишком велик для отображения",
failedToLoad: "Не удалось загрузить файл",
notFound: "Файл не найден",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
fileSize: "Размер файла {{size}}",
@@ -1986,7 +1987,6 @@ export const ru: TranslationResources = {
vimMode: "Режим Vim {{mode}}",
cursor: "Строка {{line}}, столбец {{column}}",
deletedTitle: "Файл удалён с диска",
checkFailedTitle: "Не удалось проверить файл на диске",
preservedDescription: "Открытая копия сохранена в редакторе.",
conflictDescription:
"Локальный буфер сохранён в редакторе. Выберите, какую версию оставить.",
+1 -1
View File
@@ -1920,6 +1920,7 @@ export const zhCN: TranslationResources = {
binaryPreviewUnavailable: "二进制预览不可用",
tooLargeToDisplay: "此文件过大,无法显示",
failedToLoad: "加载文件失败",
notFound: "找不到文件",
failedToLoadPreview: "加载文件预览失败",
editor: {
fileSize: "文件大小 {{size}}",
@@ -1932,7 +1933,6 @@ export const zhCN: TranslationResources = {
vimMode: "Vim 模式 {{mode}}",
cursor: "第 {{line}} 行,第 {{column}} 列",
deletedTitle: "文件已从磁盘删除",
checkFailedTitle: "无法检查磁盘上的文件",
preservedDescription: "打开的副本已保留。",
conflictDescription: "本地内容已保留。请选择要保留的版本。",
overwrite: "覆盖",
+7 -16
View File
@@ -695,9 +695,10 @@ describe("evaluatePluginClientBundle", () => {
`(function(require) {
const shared = require("@getpaseo/plugin");
const client = require("@getpaseo/plugin/client");
const { ExternalLink } = require("@getpaseo/plugin/client/ui");
const { ExternalLink, Loading } = require("@getpaseo/plugin/client/ui");
if (typeof ExternalLink !== "function") throw new Error("ExternalLink");
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl"]) {
if (typeof Loading !== "function") throw new Error("Loading");
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl", "useFileText", "useFileBytes", "useFileUrl", "PluginFileConflictError"]) {
if (name in shared || typeof client[name] !== "function") throw new Error(name);
}
if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports");
@@ -815,18 +816,11 @@ it("binds imported getters to each originating installation across delayed callb
describe("file panels", () => {
const panel =
'{ id: "pdf", title: "PDF", icon: "FileText", files: { extensions: ["pdf"] }, document: "bytes", Component() { return null; } }';
it("registers a panel with the default limit and removes it on cleanup", async () => {
'{ id: "pdf", title: "PDF", icon: "FileText", files: { extensions: ["pdf"] }, Component() { return null; } }';
it("registers a panel and removes it on cleanup", async () => {
const plugin = evaluatePluginClientBundle("files", bundle(`plugin.addFilePanel(${panel})`));
expect(
plugin.filePanels.map(({ id, files, document, maxBytes }) => ({
id,
files,
document,
maxBytes,
})),
).toEqual([
{ id: "pdf", files: { extensions: ["pdf"] }, document: "bytes", maxBytes: 10 * 1024 * 1024 },
expect(plugin.filePanels.map(({ id, title, files }) => ({ id, title, files }))).toEqual([
{ id: "pdf", title: "PDF", files: { extensions: ["pdf"] } },
]);
await plugin.cleanup();
expect(plugin.filePanels).toEqual([]);
@@ -838,11 +832,8 @@ describe("file panels", () => {
['files: { extensions: ["pdf"] }', 'files: { extensions: ["foo/bar"] }', "selector"],
['files: { extensions: ["pdf"] }', "files: { extensions: [] }", "selector"],
['files: { extensions: ["pdf"] }', 'files: { extensions: [], filenames: ["a/b"] }', "selector"],
['document: "bytes"', 'document: "binary"', "document"],
['id: "pdf"', 'id: "Bad"', "file panel id"],
['title: "PDF"', 'title: " "', "title"],
['document: "bytes"', 'document: "bytes", maxBytes: 0', "maxBytes"],
['document: "bytes"', 'document: "bytes", maxBytes: Infinity', "maxBytes"],
["Component() { return null; }", "Component: {}", "component"],
['icon: "FileText"', 'icon: ""', "icon"],
])("rejects invalid registration %s → %s", (from, to, error) => {
-8
View File
@@ -282,13 +282,6 @@ export function runPluginClientBundle(
) {
throw new Error(`File panel ${panelId} has invalid selector`);
}
if (contribution.document !== "text" && contribution.document !== "bytes")
throw new Error(`File panel ${panelId} has invalid document`);
if (
contribution.maxBytes !== undefined &&
(!Number.isFinite(contribution.maxBytes) || contribution.maxBytes <= 0)
)
throw new Error(`File panel ${panelId} has invalid maxBytes`);
if (typeof contribution.Component !== "function")
throw new Error(`File panel ${panelId} is not a component`);
filePanelIds.add(panelId);
@@ -303,7 +296,6 @@ export function runPluginClientBundle(
extensions: [...files.extensions],
...(files.filenames ? { filenames: [...files.filenames] } : {}),
},
maxBytes: contribution.maxBytes ?? 10 * 1024 * 1024,
},
() => filePanelIds.delete(panelId),
);
@@ -14,3 +14,5 @@ export { ExternalLink } from "@/components/ui/external-link";
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
export { DocumentFrame } from "@/file-panels/document-frame";
export { FilePanelLoading as Loading } from "@/file-panels/loading";
+1 -5
View File
@@ -27,10 +27,6 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont
locations: readonly PluginPanelLocation[];
};
export type EvaluatedPluginFilePanelContribution = PluginFilePanelContribution & {
maxBytes: number;
};
export interface EvaluatedPlugin {
id: string;
cleanup: PluginCleanup;
@@ -40,7 +36,7 @@ export interface EvaluatedPlugin {
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
legacySidebarItems: PluginSidebarContribution[];
filePanels: EvaluatedPluginFilePanelContribution[];
filePanels: PluginFilePanelContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSlashCommands: PluginClientSlashCommandContribution[];
+1 -17
View File
@@ -235,8 +235,6 @@ function preserveMapIdentity<Key, Value>(
}
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
export type ExplorerEncoding = "utf-8" | "base64" | "none";
export interface ExplorerEntry {
name: string;
@@ -246,19 +244,6 @@ export interface ExplorerEntry {
modifiedAt: string;
}
export interface ExplorerFile {
path: string;
kind: ExplorerFileKind;
encoding: ExplorerEncoding;
content?: string;
// TextDecoder removes a leading UTF-8 BOM; retain this bit so file writes can restore it.
hasBom: boolean;
mimeType?: string;
size: number;
modifiedAt: string;
revision?: string;
}
export interface ExplorerDirectory {
path: string;
entries: ExplorerEntry[];
@@ -266,12 +251,11 @@ export interface ExplorerDirectory {
interface ExplorerRequestState {
path: string;
mode: "list" | "file";
mode: "list";
}
export interface AgentFileExplorerState {
directories: Map<string, ExplorerDirectory>;
files: Map<string, ExplorerFile>;
isLoading: boolean;
lastError: string | null;
pendingRequest: ExplorerRequestState | null;
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { decodeUtf8 } from "./decode-utf8.native";
function decodesLikeTextDecoder(bytes: Uint8Array) {
expect(decodeUtf8(bytes)).toBe(new TextDecoder().decode(bytes));
}
describe("decodeUtf8 on native", () => {
it("decodes ASCII, multibyte characters and emoji across chunk boundaries", () => {
const text = "const café = '中文 🙂 %20 100%';\n".repeat(4000);
decodesLikeTextDecoder(new TextEncoder().encode(text));
expect(decodeUtf8(new TextEncoder().encode(text))).toBe(text);
});
it("drops a leading byte order mark", () => {
const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69]);
expect(decodeUtf8(bytes)).toBe("hi");
});
it("replaces malformed sequences", () => {
decodesLikeTextDecoder(new Uint8Array([0x61, 0xff, 0x62, 0xe4, 0xb8]));
});
it("decodes an empty file", () => {
expect(decodeUtf8(new Uint8Array())).toBe("");
});
});
@@ -0,0 +1,24 @@
const CHUNK_BYTES = 0x8000;
/**
* Decodes UTF-8 text, dropping a leading byte order mark and replacing malformed sequences.
*
* Hermes has no native TextDecoder; the installed one decodes in JavaScript and takes ~260 ms for
* 400 KB on an Android emulator. The engine's string builtins decode the same bytes in ~11 ms.
*/
export function decodeUtf8(bytes: Uint8Array): string {
let binary = "";
for (let index = 0; index < bytes.length; index += CHUNK_BYTES) {
const chunk = bytes.subarray(index, index + CHUNK_BYTES) as unknown as number[];
binary += String.fromCharCode.apply(null, chunk);
}
let text: string;
try {
// escape() turns each byte above 0x7f into %XX, which decodeURIComponent reads as UTF-8.
text = decodeURIComponent(escape(binary));
} catch {
// Malformed UTF-8: the full decoder substitutes U+FFFD for the bad sequences.
return new TextDecoder().decode(bytes);
}
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
+4
View File
@@ -0,0 +1,4 @@
/** Decodes UTF-8 text, dropping a leading byte order mark and replacing malformed sequences. */
export function decodeUtf8(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}
@@ -0,0 +1,5 @@
export function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
+2
View File
@@ -4895,6 +4895,8 @@ export class DaemonClient {
cwd: string;
path: string;
content: string;
/** `base64` requires `server_info.features.fileByteWrites`. */
contentEncoding?: "utf-8" | "base64";
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { highlightCode, highlightLine } from "../highlighter.js";
import type { HighlightToken } from "../types.js";
import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js";
describe("highlightCode", () => {
it("highlights JavaScript code with correct token styles", () => {
@@ -341,6 +342,69 @@ const title = "Hello";
});
});
describe("highlightCodeInSteps", () => {
it("splits a large file into many bounded steps with the same result", () => {
const code = Array.from(
{ length: 2000 },
(_, index) => `/* block ${index}\n comment */ export const item${index} = "${index}";`,
).join("\n");
const steps = highlightCodeInSteps(code, "large.ts");
let count = 0;
let step = steps.next();
while (!step.done) {
count++;
step = steps.next();
}
expect(count).toBeGreaterThan(20);
expect(step.value).toEqual(highlightCode(code, "large.ts"));
expect(step.value[1]).toEqual([
{ text: " comment */", style: "comment" },
{ text: " ", style: null },
{ text: "export", style: "keyword" },
{ text: " ", style: null },
{ text: "const", style: "keyword" },
{ text: " ", style: null },
{ text: "item0", style: "definition" },
{ text: " ", style: null },
{ text: "=", style: "operator" },
{ text: " ", style: null },
{ text: '"0"', style: "string" },
{ text: ";", style: "punctuation" },
]);
});
it("highlights the first lines first, then the whole file", () => {
const code = Array.from({ length: 1000 }, (_, index) => `const item${index} = ${index};`).join(
"\n",
);
const full = highlightCode(code, "large.ts");
const steps = highlightCodeInSteps(code, "large.ts", { firstLines: 100 });
const progress: HighlightToken[][][] = [];
let step = steps.next();
while (!step.done) {
if (step.value) progress.push(step.value);
step = steps.next();
}
expect(progress).toHaveLength(1);
expect(progress[0].slice(0, 100)).toEqual(full.slice(0, 100));
expect(progress[0][100]).toEqual([{ text: "const item100 = 100;", style: null }]);
expect(progress[0]).toHaveLength(1000);
expect(step.value).toEqual(full);
});
it("highlights the whole file at once when the first lines are most of it", () => {
const code = "const a = 1;\nconst b = 2;\nconst c = 3;";
const steps = [...iterate(highlightCodeInSteps(code, "small.ts", { firstLines: 2 }))];
expect(steps.filter(Boolean)).toEqual([]);
});
});
function* iterate<T>(steps: Generator<T, unknown, void>): Generator<T> {
for (let step = steps.next(); !step.done; step = steps.next()) yield step.value;
}
describe("highlightLine", () => {
it("highlights a single line", () => {
const tokens = highlightLine("const x = 1;", "test.js");
+138 -48
View File
@@ -1,68 +1,158 @@
import type { Parser, Tree } from "@lezer/common";
import { highlightTree } from "@lezer/highlight";
import type { HighlightStyle, HighlightToken } from "./types.js";
import { getParserForFile } from "./parsers.js";
import { staticSyntaxHighlighter } from "./syntax-roles.js";
/** Parser steps per unit of work: one step shifts or reduces a token. */
const PARSE_STEPS_PER_UNIT = 200;
/** Lines highlighted per unit of work. */
const LINES_PER_UNIT = 200;
export interface HighlightStepsOptions {
/**
* Highlight this many lines first and yield every line, those highlighted and the rest plain,
* before highlighting the whole file.
*/
firstLines?: number;
}
/** One unit of work done (`undefined`), or every line with the first ones highlighted. */
export type HighlightStep = HighlightToken[][] | undefined;
export function highlightCode(code: string, filename: string): HighlightToken[][] {
const parser = getParserForFile(filename);
if (!parser) {
return code.split("\n").map((line) => [{ text: line, style: null }]);
const steps = highlightCodeInSteps(code, filename);
for (;;) {
const step = steps.next();
if (step.done) return step.value;
}
}
const tree = parser.parse(code);
/**
* Highlights `code` one bounded unit of work per `next()`, returning the highlighted lines when
* done. Drive it under a time budget so a large file does not block the thread.
*
* The parser reads the file from its start, so a large file takes a while before any line is
* highlighted. With `firstLines`, it first parses only as far as those lines and yields them.
*/
export function* highlightCodeInSteps(
code: string,
filename: string,
options: HighlightStepsOptions = {},
): Generator<HighlightStep, HighlightToken[][], void> {
const lines = code.split("\n");
const result: HighlightToken[][] = [];
const parser = getParserForFile(filename);
if (!parser) return lines.map(plainLine);
for (let i = 0; i < lines.length; i++) {
result.push([]);
const firstLines = options.firstLines ?? 0;
// Highlighting the first lines first costs their parse twice; it pays only for a small share.
if (firstLines > 0 && firstLines < lines.length / 2) {
const firstTree = yield* parseInSteps(parser, code, lineOffset(lines, firstLines));
const first = yield* highlightLinesInSteps(code, lines, firstTree, firstLines);
yield [...first, ...lines.slice(firstLines).map(plainLine)];
}
const tree = yield* parseInSteps(parser, code);
return yield* highlightLinesInSteps(code, lines, tree, lines.length);
}
function* parseInSteps(
parser: Parser,
code: string,
stopAt?: number,
): Generator<undefined, Tree, void> {
const parse = parser.startParse(code);
if (stopAt !== undefined) parse.stopAt(stopAt);
for (let step = 1; ; step++) {
const tree = parse.advance();
if (tree) return tree;
if (step % PARSE_STEPS_PER_UNIT === 0) yield;
}
}
/** Highlights the first `lineCount` lines of `code` from its parse `tree`. */
function* highlightLinesInSteps(
code: string,
lines: string[],
tree: Tree,
lineCount: number,
): Generator<undefined, HighlightToken[][], void> {
const result: HighlightToken[][] = lines.slice(0, lineCount).map(() => []);
const tokens = new LineTokenWriter(code, lines, result);
let chunkFrom = 0;
for (let first = 0; first < lineCount; first += LINES_PER_UNIT) {
const last = Math.min(first + LINES_PER_UNIT, lineCount);
let chunkTo = chunkFrom;
for (let line = first; line < last; line++) chunkTo += lines[line].length + 1;
chunkTo = Math.min(chunkTo, code.length);
let cursor = chunkFrom;
highlightTree(
tree,
staticSyntaxHighlighter,
(from, to, classes) => {
tokens.write(cursor, from, null);
tokens.write(from, to, classes as HighlightStyle);
cursor = to;
},
chunkFrom,
chunkTo,
);
tokens.write(cursor, chunkTo, null);
chunkFrom = chunkTo;
yield;
}
// Build a map of character positions to styles
const styleMap: Array<HighlightStyle | null> = Array.from({ length: code.length }, () => null);
highlightTree(tree, staticSyntaxHighlighter, (from, to, classes) => {
for (let i = from; i < to && i < styleMap.length; i++) {
styleMap[i] = classes as HighlightStyle;
}
});
// Convert style map to tokens per line
let pos = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
if (line.length === 0) {
result[lineIndex].push({ text: "", style: null });
pos++; // skip newline
continue;
}
let currentToken: HighlightToken = { text: "", style: styleMap[pos] };
for (let i = 0; i < line.length; i++) {
const charStyle = styleMap[pos + i];
if (charStyle === currentToken.style) {
currentToken.text += line[i];
} else {
if (currentToken.text) {
result[lineIndex].push(currentToken);
}
currentToken = { text: line[i], style: charStyle };
}
}
if (currentToken.text) {
result[lineIndex].push(currentToken);
}
pos += line.length + 1; // +1 for newline
for (const line of result) {
if (line.length === 0) line.push({ text: "", style: null });
}
return result;
}
/** The offset where line `lineIndex` starts: past each earlier line and its newline. */
function lineOffset(lines: string[], lineIndex: number): number {
let offset = 0;
for (let line = 0; line < lineIndex; line++) offset += lines[line].length + 1;
return offset;
}
function plainLine(line: string): HighlightToken[] {
return [{ text: line, style: null }];
}
/** Splits styled ranges of the code into per-line tokens, written in order. */
class LineTokenWriter {
private line = 0;
private lineFrom = 0;
constructor(
private readonly code: string,
private readonly lines: string[],
private readonly result: HighlightToken[][],
) {}
write(from: number, to: number, style: HighlightStyle | null): void {
while (from < to) {
let lineEnd = this.lineFrom + this.lines[this.line].length;
while (from > lineEnd) {
this.line++;
this.lineFrom = lineEnd + 1;
lineEnd = this.lineFrom + this.lines[this.line].length;
}
const end = Math.min(to, lineEnd);
if (end > from) this.append(this.code.slice(from, end), style);
from = end === lineEnd ? lineEnd + 1 : end;
}
}
private append(text: string, style: HighlightStyle | null): void {
const tokens = this.result[this.line];
const last = tokens[tokens.length - 1];
if (last && last.style === style) last.text += text;
else tokens.push({ text, style });
}
}
export function highlightLine(line: string, filename: string): HighlightToken[] {
const result = highlightCode(line, filename);
return result[0] ?? [{ text: line, style: null }];
+1 -1
View File
@@ -6,7 +6,7 @@ export {
getSupportedExtensions,
} from "./parsers.js";
export { createCodeMirrorHighlightStyle } from "./syntax-roles.js";
export { highlightCode, highlightLine } from "./highlighter.js";
export { highlightCode, highlightCodeInSteps, highlightLine } from "./highlighter.js";
export { darkHighlightColors, lightHighlightColors } from "./colors.js";
export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js";
export {
+6 -30
View File
@@ -2,6 +2,7 @@ import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile, PluginFileLocation, PluginFileTab } from "./file.js";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginButtonRegistration,
@@ -120,45 +121,20 @@ export interface PluginAgentPanelProps extends PluginNavigableHostProps {
agentId: string;
}
export interface PluginTextDocument {
kind: "text";
text: string;
/** Paseo owns autosave, dirty state, and conflicts. False on native, old daemons, and files above 1 MiB. */
editable: boolean;
setText(next: string): void;
}
export interface PluginBytesDocument {
kind: "bytes";
bytes: Uint8Array;
/** Local URL owned by the host. */
url: string;
}
export interface PluginFilePanelProps extends PluginNavigableHostProps {
workspaceId: string;
file: {
path: string;
name: string;
extension: string;
mimeType: string;
size: number;
modifiedAt: string;
lineStart?: number;
lineEnd?: number;
};
navigationRevision: number;
document: PluginTextDocument | PluginBytesDocument;
/** The file itself. The panel decides how to read it, show it, and save it. */
file: PluginFile;
tab: PluginFileTab;
location: PluginFileLocation;
}
export interface PluginFilePanelContribution {
id: string;
title: string;
icon: PluginIcon;
/** Extensions without the dot, case-insensitive, and exact filenames. */
files: { extensions: readonly string[]; filenames?: readonly string[] };
document: "text" | "bytes";
/** Defaults to 10 MiB. */
maxBytes?: number;
Component: ComponentType<PluginFilePanelProps>;
}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useState } from "react";
/**
* The file a file panel shows. Stable for the life of the tab's target. The stat fields are
* refreshed when the file changes on disk.
*/
export interface PluginFile {
/** Absolute path on the host. */
readonly path: string;
readonly name: string;
/** Lowercase, without the dot; empty when the name has none. */
readonly extension: string;
readonly size: number;
readonly modifiedAt: string;
readonly mimeType: string;
/** Reads the file as UTF-8. A byte order mark is dropped here and written back by `writeText`. */
readText(): Promise<string>;
readBytes(): Promise<Uint8Array>;
/**
* A local URL of the file's current bytes, for `DocumentFrame` and images. Paseo owns it: it stays
* valid until a URL of a newer version resolves or the tab closes.
*/
url(): Promise<string>;
/**
* Writes the file if it has not changed since this handle last read or wrote it. Otherwise
* rejects with `PluginFileConflictError`; `force: true` writes anyway.
*/
writeText(text: string, options?: PluginFileWriteOptions): Promise<void>;
writeBytes(bytes: Uint8Array, options?: PluginFileWriteOptions): Promise<void>;
/**
* Calls `listener` when the file changes on disk or is deleted. This handle's own writes do not
* notify. A deletion is reported once a read confirms it; the panel stays mounted. Returns the
* unsubscribe function.
*/
watch(listener: (event: PluginFileEvent) => void): () => void;
}
export interface PluginFileWriteOptions {
force?: boolean;
}
export type PluginFileEvent = "changed" | "deleted";
/** What the panel reports back to its tab. */
export interface PluginFileTab {
/** Shows the tab's unsaved dot and asks before the tab closes. */
setModified(modified: boolean): void;
}
/** The line a link or search result opened the file at. `revision` bumps on every new target. */
export interface PluginFileLocation {
lineStart?: number;
lineEnd?: number;
revision: number;
}
/** The file changed on disk since the handle last read or wrote it. */
export class PluginFileConflictError extends Error {
constructor(message = "The file changed on disk.") {
super(message);
this.name = "PluginFileConflictError";
}
}
export type PluginFileTextState =
| { status: "loading" }
| { status: "ready"; text: string }
| { status: "error"; error: Error };
export type PluginFileBytesState =
| { status: "loading" }
| { status: "ready"; bytes: Uint8Array }
| { status: "error"; error: Error };
export type PluginFileUrlState =
| { status: "loading" }
| { status: "ready"; url: string }
| { status: "error"; error: Error };
/** The file's text, re-read when it changes on disk. Keeps the current text during a re-read. */
export function useFileText(file: PluginFile): PluginFileTextState {
const state = useFileRead(file, "text");
return state.status === "ready" ? { status: "ready", text: state.value as string } : state;
}
/** The file's bytes, re-read when it changes on disk. Keeps the current bytes during a re-read. */
export function useFileBytes(file: PluginFile): PluginFileBytesState {
const state = useFileRead(file, "bytes");
return state.status === "ready" ? { status: "ready", bytes: state.value as Uint8Array } : state;
}
/** A local URL of the file, replaced when it changes on disk. */
export function useFileUrl(file: PluginFile): PluginFileUrlState {
const state = useFileRead(file, "url");
return state.status === "ready" ? { status: "ready", url: state.value as string } : state;
}
type FileRead = "text" | "bytes" | "url";
type FileReadState =
| { status: "loading" }
| { status: "ready"; value: string | Uint8Array }
| { status: "error"; error: Error };
function useFileRead(file: PluginFile, read: FileRead): FileReadState {
const [state, setState] = useState<{ file: PluginFile; read: FileRead; state: FileReadState }>(
() => ({ file, read, state: { status: "loading" } }),
);
useEffect(() => {
let generation = 0;
let active = true;
const load = async () => {
const current = ++generation;
let next: FileReadState;
try {
next = { status: "ready", value: await readFile(file, read) };
} catch (error) {
next = { status: "error", error: asError(error) };
}
if (active && current === generation) setState({ file, read, state: next });
};
void load();
const unwatch = file.watch((event) => {
if (event === "changed") {
void load();
return;
}
generation += 1;
setState({
file,
read,
state: { status: "error", error: new Error("The file was deleted.") },
});
});
return () => {
active = false;
unwatch();
};
}, [file, read]);
return state.file === file && state.read === read ? state.state : { status: "loading" };
}
function readFile(file: PluginFile, read: FileRead): Promise<string | Uint8Array> {
if (read === "text") return file.readText();
if (read === "bytes") return file.readBytes();
return file.url();
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
+11 -2
View File
@@ -2,8 +2,6 @@ export type {
PluginHostProps,
PluginFilePanelContribution,
PluginFilePanelProps,
PluginTextDocument,
PluginBytesDocument,
PluginSurfaceProps,
PluginScreenProps,
PluginPopoverProps,
@@ -50,6 +48,17 @@ export type {
PluginHeaderButtonContribution,
} from "./buttons.js";
export type { PluginIconProps, PluginSerializableIcon, PluginSvgIcon } from "../icons.js";
export type {
PluginFile,
PluginFileBytesState,
PluginFileEvent,
PluginFileLocation,
PluginFileTab,
PluginFileTextState,
PluginFileUrlState,
PluginFileWriteOptions,
} from "./file.js";
export { PluginFileConflictError, useFileBytes, useFileText, useFileUrl } from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
+3
View File
@@ -86,5 +86,8 @@ export declare const SidebarRow: ComponentType<SidebarRowProps>;
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
export declare const SidebarSeparator: ComponentType;
/** Paseo's loading state. Fills the panel; the panel decides when to show it. */
export declare const Loading: ComponentType;
/** Render a local document URL in an isolated frame. */
export declare const DocumentFrame: ComponentType<{ url: string; testID?: string }>;
+8
View File
@@ -2775,6 +2775,10 @@ export const FileVersionSchema = z.discriminatedUnion("status", [
size: z.number().int().nonnegative(),
modifiedAt: z.string(),
revision: z.string().optional(),
// Classified from the extension and the first bytes, so a client can choose how to open the
// file before reading it.
kind: z.enum(["text", "image", "binary"]).optional(),
mimeType: z.string().optional(),
}),
z.object({
status: z.literal("missing"),
@@ -2808,6 +2812,8 @@ export const FileWriteRequestSchema = z.object({
cwd: z.string(),
path: z.string(),
content: z.string(),
// COMPAT(fileByteWrites): added in v0.13.0. Daemons without the feature write `content` as text.
contentEncoding: z.enum(["utf-8", "base64"]).optional(),
expectedModifiedAt: z.string(),
expectedRevision: z.string().optional(),
requestId: z.string(),
@@ -3677,6 +3683,8 @@ export const ServerInfoStatusPayloadSchema = z
workspaceRecovery: z.boolean().optional(),
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: z.boolean().optional(),
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
fileByteWrites: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -134,6 +134,62 @@ describe("file explorer service", () => {
}
});
it("classifies a file version from its extension and first bytes", async () => {
const root = await createTempDir("paseo-file-version-kind-");
try {
await writeFile(path.join(root, "notes.md"), "# Notes\n", "utf8");
await writeFile(path.join(root, "pixel.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
await writeFile(path.join(root, "report.pdf"), "%PDF-1.4\n", "utf8");
await writeFile(path.join(root, "blob.dat"), Buffer.from([0x00, 0x01, 0x02]));
// A multi-byte character split by the sample boundary is still text.
await writeFile(path.join(root, "wide.txt"), `${"a".repeat(8191)}é tail`, "utf8");
const kinds = await Promise.all(
["notes.md", "pixel.png", "report.pdf", "blob.dat", "wide.txt"].map(
async (relativePath) => {
const version = await getExplorerFileVersion({ root, relativePath });
return version.status === "ready" ? [version.kind, version.mimeType] : version.status;
},
),
);
expect(kinds).toEqual([
["text", "text/plain"],
["image", "image/png"],
["binary", "application/pdf"],
["binary", "application/octet-stream"],
["text", "text/plain"],
]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("writes base64 content as bytes, including over a binary file", async () => {
const root = await createTempDir("paseo-file-write-bytes-");
try {
const filePath = path.join(root, "blob.dat");
await writeFile(filePath, Buffer.from([0x00, 0x01, 0x02]));
const current = await getExplorerFileVersion({ root, relativePath: "blob.dat" });
expect(current.status).toBe("ready");
if (current.status !== "ready") return;
const result = await writeExplorerFile({
root,
relativePath: "blob.dat",
content: Buffer.from([0xff, 0x00, 0xfe]).toString("base64"),
contentEncoding: "base64",
expectedModifiedAt: current.modifiedAt,
expectedRevision: current.revision,
});
expect(result).toMatchObject({ status: "written", size: 3 });
expect([...(await readFile(filePath))]).toEqual([0xff, 0x00, 0xfe]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("never creates a missing file through the write API", async () => {
const root = await createTempDir("paseo-file-missing-");
try {
@@ -21,6 +21,8 @@ export interface ReadFileParams {
export interface WriteFileParams extends ReadFileParams {
content: string;
/** `base64` writes the decoded bytes, so binary files can be written too. */
contentEncoding?: "utf-8" | "base64";
expectedModifiedAt: string;
expectedRevision?: string;
}
@@ -33,6 +35,8 @@ export type ExplorerFileVersion =
size: number;
modifiedAt: string;
revision: string;
kind?: ExplorerFileKind;
mimeType?: string;
}
| { status: "missing"; cwd: string; path: string }
| { status: "error"; cwd: string; path: string; error: string };
@@ -408,18 +412,26 @@ export async function getExplorerFileVersion({
const cwd = expandUserPath(root);
try {
const filePath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(filePath.resolvedPath, { bigint: true });
if (!stats.isFile()) {
return { status: "error", cwd, path: relativePath, error: "Requested path is not a file" };
const handle = await openFileForRead(filePath.resolvedPath);
try {
const stats = await handle.stat({ bigint: true });
if (!stats.isFile()) {
return { status: "error", cwd, path: relativePath, error: "Requested path is not a file" };
}
const type = await sniffFileType(handle, path.extname(filePath.resolvedPath).toLowerCase());
return {
status: "ready",
cwd,
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: Number(stats.size),
modifiedAt: stats.mtime.toISOString(),
revision: fileRevision(stats),
kind: type.kind,
mimeType: type.mimeType,
};
} finally {
await handle.close();
}
return {
status: "ready",
cwd,
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: Number(stats.size),
modifiedAt: stats.mtime.toISOString(),
revision: fileRevision(stats),
};
} catch (error) {
if (isMissingEntryError(error)) {
return { status: "missing", cwd, path: relativePath };
@@ -444,10 +456,11 @@ export async function writeExplorerFile({
root,
relativePath,
content,
contentEncoding = "utf-8",
expectedModifiedAt,
expectedRevision,
}: WriteFileParams): Promise<ExplorerFileWriteResult> {
const encoded = Buffer.from(content, "utf8");
const encoded = Buffer.from(content, contentEncoding === "base64" ? "base64" : "utf8");
if (encoded.byteLength > MAX_EDITABLE_FILE_BYTES) {
return { status: "error", error: "File is too large to edit" };
}
@@ -465,9 +478,11 @@ export async function writeExplorerFile({
if (stats.size > BigInt(MAX_EDITABLE_FILE_BYTES)) {
return { status: "error", error: "File is too large to edit" };
}
const current = await handle.readFile();
if (isLikelyBinary(current) || !isValidUtf8(current)) {
return { status: "error", error: "Binary files cannot be edited" };
if (contentEncoding === "utf-8") {
const current = await handle.readFile();
if (isLikelyBinary(current) || !isValidUtf8(current)) {
return { status: "error", error: "Binary files cannot be edited" };
}
}
currentMode = Number(stats.mode);
const modifiedAt = stats.mtime.toISOString();
@@ -558,18 +573,10 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
throw new Error("Requested path is not a file");
}
const ext = path.extname(filePath.resolvedPath).toLowerCase();
let mimeType = BINARY_MIME_TYPES[ext] ?? "application/octet-stream";
if (ext in IMAGE_MIME_TYPES) {
mimeType = IMAGE_MIME_TYPES[ext];
} else if (!(ext in BINARY_MIME_TYPES)) {
const sample = Buffer.alloc(FILE_TYPE_SAMPLE_BYTES);
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
if (!isLikelyBinary(chunk)) {
mimeType = textMimeTypeForExtension(ext);
}
}
const { mimeType } = await sniffFileType(
handle,
path.extname(filePath.resolvedPath).toLowerCase(),
);
return {
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
@@ -910,6 +917,34 @@ function isLikelyBinary(buffer: Buffer): boolean {
return suspicious / buffer.length > 0.3;
}
/** Classifies a file from its extension and first bytes, without reading the rest of it. */
async function sniffFileType(
handle: FileHandle,
ext: string,
): Promise<{ kind: ExplorerFileKind; mimeType: string }> {
if (ext in IMAGE_MIME_TYPES) return { kind: "image", mimeType: IMAGE_MIME_TYPES[ext] };
const binary = {
kind: "binary" as const,
mimeType: BINARY_MIME_TYPES[ext] ?? "application/octet-stream",
};
if (ext in BINARY_MIME_TYPES) return binary;
const sample = Buffer.alloc(FILE_TYPE_SAMPLE_BYTES);
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
const chunk = sample.subarray(0, bytesRead);
if (isLikelyBinary(chunk) || !isValidUtf8Prefix(chunk)) return binary;
return { kind: "text", mimeType: textMimeTypeForExtension(ext) };
}
/** A sample can end inside a multi-byte character, so only a complete invalid sequence fails. */
function isValidUtf8Prefix(buffer: Buffer): boolean {
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer, { stream: true });
return true;
} catch {
return false;
}
}
function isValidUtf8(buffer: Buffer): boolean {
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
@@ -160,6 +160,7 @@ export class WorkspaceFilesSession {
root: request.cwd,
relativePath: request.path,
content: request.content,
contentEncoding: request.contentEncoding,
expectedModifiedAt: request.expectedModifiedAt,
expectedRevision: request.expectedRevision,
});
@@ -1885,6 +1885,8 @@ export class VoiceAssistantWebSocketServer {
workspaceRecovery: true,
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: true,
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
fileByteWrites: true,
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
+5 -3
View File
@@ -12,9 +12,11 @@ Open a `.md` or `.markdown` file in Files. Choose **Rich Markdown — markdown-p
picker. Check **Don't ask me again** to remember the choice on this device.
The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo, redo, and line
wrapping. Edits call the host document's `setText`; Paseo owns autosave, the dirty dot, and
external-change conflicts. The bar's switcher still offers Paseo's Markdown and Source panels. Files above 1 MiB or hosts
without editing support render read-only. Native shows selectable, read-only text.
wrapping. It reads the file once with `readText`, saves 500 ms after the last edit with
`writeText`, and reports unsaved edits with `tab.setModified`. When the file changes on disk with
nothing unsaved, it reads again; with unsaved edits, or when a write rejects with
`PluginFileConflictError`, it offers **Reload** and **Overwrite**. The bar's switcher still offers
Paseo's Markdown and Source panels. Native shows selectable, read-only text through `useFileText`.
Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native
path uses React Native primitives. DOM integration lives in `client/web.ts`.
@@ -1,53 +1,169 @@
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
import { useLayoutEffect, useMemo, useRef } from "react";
import { ScrollView, Text, View } from "react-native";
import { mountEditor } from "./web";
import {
PluginFileConflictError,
type PluginFilePanelProps,
useFileText,
} from "@getpaseo/plugin/client";
import { Loading } from "@getpaseo/plugin/client/ui";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { type MarkdownEditorHandle, mountEditor } from "./web";
interface TextPanelProps {
document: PluginTextDocument;
theme: PluginFilePanelProps["theme"];
}
const SAVE_DELAY_MS = 500;
const editorStyle = { flex: 1, minHeight: 0 };
type EditorState = "loading" | "ready" | "conflict" | "deleted";
function MarkdownEditor({ document, theme }: TextPanelProps) {
/**
* Reads the file once, writes 500 ms after the last edit, and follows the disk while nothing is
* unsaved. With unsaved edits, a change on disk is a conflict: Reload or Overwrite.
*/
function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) {
const parent = useRef<View>(null);
const editor = useRef<ReturnType<typeof mountEditor>>(null);
const currentDocument = useRef(document);
currentDocument.current = document;
const editor = useRef<MarkdownEditorHandle | null>(null);
const text = useRef("");
const unsaved = useRef<string | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [state, setState] = useState<EditorState>("loading");
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
text.current = await file.readText();
} catch (cause) {
setError(messageOf(cause));
return;
}
unsaved.current = null;
tab.setModified(false);
editor.current?.setText(text.current);
setError(null);
setState("ready");
}, [file, tab]);
const save = useCallback(
async (force: boolean) => {
const next = unsaved.current;
if (next === null) return;
try {
await file.writeText(next, { force });
} catch (cause) {
if (cause instanceof PluginFileConflictError) setState("conflict");
else setError(messageOf(cause));
return;
}
if (unsaved.current === next) {
unsaved.current = null;
tab.setModified(false);
}
setError(null);
setState("ready");
},
[file, tab],
);
useLayoutEffect(() => {
editor.current = mountEditor(parent.current, currentDocument.current, theme, (text) => {
currentDocument.current.setText(text);
editor.current = mountEditor(parent.current, text.current, theme, (next) => {
text.current = next;
unsaved.current = next;
tab.setModified(true);
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => void save(false), SAVE_DELAY_MS);
});
return () => {
editor.current?.destroy();
editor.current = null;
};
}, [theme, document.editable]);
}, [save, tab, theme]);
useLayoutEffect(() => {
editor.current?.setText(document.text);
}, [document.text]);
useEffect(() => {
void load();
const unwatch = file.watch((event) => {
if (event === "deleted") setState("deleted");
else if (unsaved.current === null) void load();
else setState("conflict");
});
return () => {
unwatch();
clearTimeout(saveTimer.current);
// Switching panels keeps the edit. Closing the tab asks first and drops it if discarded.
if (unsaved.current !== null) void file.writeText(unsaved.current).catch(() => {});
};
}, [file, load]);
return <View ref={parent} style={editorStyle} />;
const styles = useMemo(
() => ({
panel: { flex: 1, minHeight: 0, backgroundColor: theme.colors.surface0 },
editor: { flex: 1, minHeight: 0 },
banner: {
flexDirection: "row" as const,
alignItems: "center" as const,
gap: 12,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
bannerText: { flex: 1, color: theme.colors.foreground },
error: { color: theme.colors.statusDanger, paddingHorizontal: 16, paddingVertical: 8 },
action: { color: theme.colors.accent },
}),
[theme],
);
const overwrite = useCallback(() => void save(true), [save]);
const reload = useCallback(() => void load(), [load]);
return (
<View style={styles.panel}>
{state === "conflict" ? (
<View style={styles.banner} testID="rich-markdown-conflict">
<Text style={styles.bannerText}>The file changed on disk.</Text>
<Pressable accessibilityRole="button" onPress={reload}>
<Text style={styles.action}>Reload</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={overwrite}>
<Text style={styles.action}>Overwrite</Text>
</Pressable>
</View>
) : null}
{state === "deleted" ? (
<View style={styles.banner}>
<Text style={styles.bannerText}>The file was deleted. Edits are kept here.</Text>
</View>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
<View ref={parent} style={styles.editor} />
{state === "loading" && !error ? <Loading /> : null}
</View>
);
}
export function RichMarkdownPanel({ document, theme, layout }: PluginFilePanelProps) {
/** Native has no editor: selectable, read-only text that follows the disk. */
function MarkdownText({ file, theme }: PluginFilePanelProps) {
const text = useFileText(file);
const styles = useMemo(
() => ({
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
text: { color: theme.colors.foreground, padding: 16 },
error: { color: theme.colors.statusDanger, padding: 16 },
}),
[theme],
);
if (document.kind !== "text") return null;
if (layout.platform === "web") return <MarkdownEditor document={document} theme={theme} />;
if (text.status === "loading") return <Loading />;
if (text.status === "error") return <Text style={styles.error}>{text.error.message}</Text>;
return (
<ScrollView style={styles.screen}>
<Text selectable style={styles.text}>
{document.text}
{text.text}
</Text>
</ScrollView>
);
}
export function RichMarkdownPanel(props: PluginFilePanelProps) {
if (props.layout.platform === "web") return <MarkdownEditor {...props} />;
return <MarkdownText {...props} />;
}
function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}
+13 -9
View File
@@ -1,14 +1,20 @@
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
import type { EditorView, EditorViewConfig } from "@codemirror/view";
import { Platform } from "react-native";
export interface MarkdownEditorHandle {
/** Replaces the editor's text without reporting it as an edit. */
setText(text: string): void;
destroy(): void;
}
/** DOM code stays here; native never evaluates the editor dependencies. */
export function mountEditor(
parent: unknown,
document: PluginTextDocument,
text: string,
theme: PluginFilePanelProps["theme"],
onChange: (text: string) => void,
) {
): MarkdownEditorHandle | null {
if (Platform.OS !== "web" || !parent) return null;
const { EditorState } = require("@codemirror/state") as typeof import("@codemirror/state");
const { EditorView, keymap, lineNumbers } =
@@ -23,7 +29,7 @@ export function mountEditor(
const view: EditorView = new EditorView({
parent: parent as EditorViewConfig["parent"],
state: EditorState.create({
doc: document.text,
doc: text,
extensions: [
markdown(),
syntaxHighlighting(defaultHighlightStyle),
@@ -31,8 +37,6 @@ export function mountEditor(
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
EditorView.lineWrapping,
EditorState.readOnly.of(!document.editable),
EditorView.editable.of(document.editable),
EditorView.contentAttributes.of({ "aria-label": "Rich Markdown editor" }),
EditorView.theme({
"&": {
@@ -55,11 +59,11 @@ export function mountEditor(
}),
});
return {
setText(text: string) {
if (view.state.doc.toString() === text) return;
setText(next) {
if (view.state.doc.toString() === next) return;
applyingHostText = true;
try {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: next } });
} finally {
applyingHostText = false;
}
@@ -7,7 +7,6 @@ export default function contribute(client: PluginClientContext) {
title: "Rich Markdown",
icon: "FileType",
files: { extensions: ["md", "markdown"] },
document: "text",
Component: RichMarkdownPanel,
});
return () => {};
+3 -2
View File
@@ -3,8 +3,9 @@
Requires Paseo 0.13.0 or later. Install this directory with
`paseo plugin install /absolute/path/to/plugin-examples/pdf-panel`, then open a `.pdf` in Files.
The panel passes Paseo's local document URL to `DocumentFrame` on web and iOS.
Files up to 50 MiB match. Android shows the filename, size, and a message that inline PDF rendering is unavailable. The plugin does not read files or construct daemon URLs.
The panel resolves the file's local URL with `useFileUrl`, shows `Loading` until it does, and passes
it to `DocumentFrame` on web and iOS. Android shows the filename, size, and a message that inline
PDF rendering is unavailable. The plugin does not construct daemon URLs.
See the [file panel reference](https://paseo.sh/docs/plugins/reference#file-panels).
+27 -12
View File
@@ -1,26 +1,41 @@
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
import { type PluginFilePanelProps, useFileUrl } from "@getpaseo/plugin/client";
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
import { useMemo } from "react";
import { Text, View } from "react-native";
const fallbackStyle = {
const messageStyle = {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 24,
} as const;
export function PdfPanel({ document, file, theme, layout }: PluginFilePanelProps) {
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
if (document.kind !== "bytes") return null;
if (layout.platform === "android") {
export function PdfPanel(props: PluginFilePanelProps) {
if (props.layout.platform === "android") return <AndroidPdf {...props} />;
return <InlinePdf {...props} />;
}
function InlinePdf({ file, theme }: PluginFilePanelProps) {
const url = useFileUrl(file);
const textStyle = useMemo(() => ({ color: theme.colors.statusDanger }), [theme]);
if (url.status === "loading") return <Loading />;
if (url.status === "error") {
return (
<View style={fallbackStyle}>
<Text style={textStyle}>
{file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline.
</Text>
<View style={messageStyle}>
<Text style={textStyle}>{url.error.message}</Text>
</View>
);
}
return <DocumentFrame url={document.url} testID="pdf-panel" />;
return <DocumentFrame url={url.url} testID="pdf-panel" />;
}
function AndroidPdf({ file, theme }: PluginFilePanelProps) {
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
return (
<View style={messageStyle}>
<Text style={textStyle}>
{file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline.
</Text>
</View>
);
}

Some files were not shown because too many files have changed in this diff Show More