mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
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:
+14
-7
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
+6
-15
@@ -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);
|
||||
}
|
||||
+1
@@ -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}>
|
||||
+12
-1
@@ -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,
|
||||
},
|
||||
}));
|
||||
+8
-41
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "استبدال",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "上書き",
|
||||
|
||||
@@ -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: "덮어쓰기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
"Локальный буфер сохранён в редакторе. Выберите, какую версию оставить.",
|
||||
|
||||
@@ -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: "覆盖",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user