From dfb6215de08cf085375d6274dc6d501c209cbe7b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 08:02:49 +0200 Subject: [PATCH] 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. --- docs/plugins.md | 21 +- .../e2e/browser/file-change-conflicts.spec.ts | 172 +---- packages/app/e2e/browser/file-editing.spec.ts | 8 +- .../plugin-file-panel-examples.spec.ts | 12 +- .../e2e/browser/plugin-file-panels.spec.ts | 79 +- .../helpers/markdown-file-panel-plugin.ts | 2 +- packages/app/src/contexts/session-context.tsx | 3 - .../app/src/file-explorer/read-result.test.ts | 34 - packages/app/src/file-explorer/read-result.ts | 21 - packages/app/src/file-pane/bar.tsx | 149 +--- .../app/src/file-pane/editor/model.test.ts | 652 ---------------- packages/app/src/file-pane/editor/model.ts | 402 ---------- .../editor/observation-source.test.ts | 60 -- .../file-pane/editor/observation-source.ts | 50 -- packages/app/src/file-pane/live-file/hook.ts | 61 -- .../app/src/file-pane/live-file/model.test.ts | 344 --------- packages/app/src/file-pane/live-file/model.ts | 272 ------- packages/app/src/file-pane/pane.tsx | 730 +++--------------- .../src/file-pane/preview-lifecycle/hook.ts | 25 - .../file-pane/preview-lifecycle/model.test.ts | 199 ----- .../src/file-pane/preview-lifecycle/model.ts | 223 ------ .../app/src/file-pane/source/view.native.tsx | 141 ---- packages/app/src/file-panels/builtin.tsx | 102 +-- packages/app/src/file-panels/catalog.test.ts | 24 +- packages/app/src/file-panels/catalog.ts | 12 +- packages/app/src/file-panels/choice/hook.ts | 20 +- .../app/src/file-panels/choice/model.test.ts | 4 +- packages/app/src/file-panels/document.test.ts | 73 -- packages/app/src/file-panels/document.ts | 16 - .../src/file-panels/file/daemon-session.ts | 115 +++ .../app/src/file-panels/file/handle.test.ts | 179 +++++ packages/app/src/file-panels/file/handle.ts | 413 ++++++++++ packages/app/src/file-panels/file/hook.ts | 56 ++ .../app/src/file-panels/file/hooks.test.tsx | 57 ++ packages/app/src/file-panels/file/index.ts | 2 + packages/app/src/file-panels/file/testing.ts | 127 +++ packages/app/src/file-panels/index.ts | 6 +- packages/app/src/file-panels/loading.tsx | 35 + packages/app/src/file-panels/panels.ts | 24 +- packages/app/src/file-panels/plugin-host.tsx | 52 +- packages/app/src/file-panels/resolve.test.ts | 132 +--- packages/app/src/file-panels/resolve.ts | 46 +- .../app/src/file-panels/settings/rows.test.ts | 13 +- packages/app/src/file-panels/settings/rows.ts | 8 +- .../source}/conflict-alert.tsx | 21 +- .../source}/editor/extensions.web.ts | 0 .../file-panels/source/editor/model.test.ts | 240 ++++++ .../src/file-panels/source/editor/model.ts | 287 +++++++ .../source}/editor/view.tsx | 1 + .../source}/editor/view.web.tsx | 13 +- .../source}/find/index.web.tsx | 0 .../source}/find/model.web.ts | 0 packages/app/src/file-panels/source/panel.tsx | 224 ++++++ .../source/readonly}/presentation.test.ts | 0 .../source/readonly}/presentation.ts | 0 .../source/readonly/source-lines.test.ts | 67 ++ .../source/readonly/source-lines.ts | 64 ++ .../source/readonly/view.native.tsx | 136 ++++ .../source/readonly}/view.tsx | 0 .../source/readonly}/view.web.tsx | 49 +- .../app/src/file-panels/source/status.tsx | 122 +++ packages/app/src/file-panels/view.tsx | 37 +- .../src/hooks/use-file-explorer-actions.ts | 68 -- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- packages/app/src/i18n/resources/es.ts | 2 +- packages/app/src/i18n/resources/fr.ts | 2 +- packages/app/src/i18n/resources/ja.ts | 2 +- packages/app/src/i18n/resources/ko.ts | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 2 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- packages/app/src/plugins/evaluate.test.ts | 23 +- packages/app/src/plugins/evaluate.ts | 8 - packages/app/src/plugins/react-native/ui.ts | 2 + packages/app/src/plugins/types.ts | 6 +- packages/app/src/stores/session-store.ts | 18 +- .../app/src/utils/decode-utf8.native.test.ts | 27 + packages/app/src/utils/decode-utf8.native.ts | 24 + packages/app/src/utils/decode-utf8.ts | 4 + packages/app/src/utils/format-file-size.ts | 5 + packages/client/src/daemon-client.ts | 2 + .../src/__tests__/highlighter.test.ts | 66 +- packages/highlight/src/highlighter.ts | 186 +++-- packages/highlight/src/index.ts | 2 +- packages/plugin/src/client/contracts.ts | 36 +- packages/plugin/src/client/file.ts | 150 ++++ packages/plugin/src/client/index.ts | 13 +- packages/plugin/src/client/ui.ts | 3 + packages/protocol/src/messages.ts | 8 + .../src/server/file-explorer/service.test.ts | 56 ++ .../src/server/file-explorer/service.ts | 89 ++- .../session/files/workspace-files-session.ts | 1 + .../server/src/server/websocket-server.ts | 2 + plugin-examples/markdown-panel/README.md | 8 +- .../markdown-panel/client/rich-markdown.tsx | 164 +++- plugin-examples/markdown-panel/client/web.ts | 22 +- .../markdown-panel/index.client.tsx | 1 - plugin-examples/pdf-panel/README.md | 5 +- .../pdf-panel/client/pdf-panel.tsx | 39 +- plugin-examples/pdf-panel/index.client.tsx | 2 - public-docs/plugins/reference.md | 199 +++-- skills/paseo-plugin/SKILL.md | 2 +- 103 files changed, 3432 insertions(+), 4264 deletions(-) delete mode 100644 packages/app/src/file-explorer/read-result.test.ts delete mode 100644 packages/app/src/file-explorer/read-result.ts delete mode 100644 packages/app/src/file-pane/editor/model.test.ts delete mode 100644 packages/app/src/file-pane/editor/model.ts delete mode 100644 packages/app/src/file-pane/editor/observation-source.test.ts delete mode 100644 packages/app/src/file-pane/editor/observation-source.ts delete mode 100644 packages/app/src/file-pane/live-file/hook.ts delete mode 100644 packages/app/src/file-pane/live-file/model.test.ts delete mode 100644 packages/app/src/file-pane/live-file/model.ts delete mode 100644 packages/app/src/file-pane/preview-lifecycle/hook.ts delete mode 100644 packages/app/src/file-pane/preview-lifecycle/model.test.ts delete mode 100644 packages/app/src/file-pane/preview-lifecycle/model.ts delete mode 100644 packages/app/src/file-pane/source/view.native.tsx delete mode 100644 packages/app/src/file-panels/document.test.ts delete mode 100644 packages/app/src/file-panels/document.ts create mode 100644 packages/app/src/file-panels/file/daemon-session.ts create mode 100644 packages/app/src/file-panels/file/handle.test.ts create mode 100644 packages/app/src/file-panels/file/handle.ts create mode 100644 packages/app/src/file-panels/file/hook.ts create mode 100644 packages/app/src/file-panels/file/hooks.test.tsx create mode 100644 packages/app/src/file-panels/file/index.ts create mode 100644 packages/app/src/file-panels/file/testing.ts create mode 100644 packages/app/src/file-panels/loading.tsx rename packages/app/src/{file-pane => file-panels/source}/conflict-alert.tsx (78%) rename packages/app/src/{file-pane => file-panels/source}/editor/extensions.web.ts (100%) create mode 100644 packages/app/src/file-panels/source/editor/model.test.ts create mode 100644 packages/app/src/file-panels/source/editor/model.ts rename packages/app/src/{file-pane => file-panels/source}/editor/view.tsx (98%) rename packages/app/src/{file-pane => file-panels/source}/editor/view.web.tsx (96%) rename packages/app/src/{file-pane => file-panels/source}/find/index.web.tsx (100%) rename packages/app/src/{file-pane => file-panels/source}/find/model.web.ts (100%) create mode 100644 packages/app/src/file-panels/source/panel.tsx rename packages/app/src/{file-pane/source => file-panels/source/readonly}/presentation.test.ts (100%) rename packages/app/src/{file-pane/source => file-panels/source/readonly}/presentation.ts (100%) create mode 100644 packages/app/src/file-panels/source/readonly/source-lines.test.ts create mode 100644 packages/app/src/file-panels/source/readonly/source-lines.ts create mode 100644 packages/app/src/file-panels/source/readonly/view.native.tsx rename packages/app/src/{file-pane/source => file-panels/source/readonly}/view.tsx (100%) rename packages/app/src/{file-pane/source => file-panels/source/readonly}/view.web.tsx (79%) create mode 100644 packages/app/src/file-panels/source/status.tsx create mode 100644 packages/app/src/utils/decode-utf8.native.test.ts create mode 100644 packages/app/src/utils/decode-utf8.native.ts create mode 100644 packages/app/src/utils/decode-utf8.ts create mode 100644 packages/app/src/utils/format-file-size.ts create mode 100644 packages/plugin/src/client/file.ts diff --git a/docs/plugins.md b/docs/plugins.md index d65b7b2a84..765a84dac1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 diff --git a/packages/app/e2e/browser/file-change-conflicts.spec.ts b/packages/app/e2e/browser/file-change-conflicts.spec.ts index 976139f432..1a372af35d 100644 --- a/packages/app/e2e/browser/file-change-conflicts.spec.ts +++ b/packages/app/e2e/browser/file-change-conflicts.spec.ts @@ -56,45 +56,12 @@ async function replaceEditorText(page: Page, content: string): Promise { await editor.type(content); } -async function replaceFileAfterDeletionWasObserved(input: { - page: Page; - gate: Awaited>; - filePath: string; - relativePath: string; - content: string; -}): Promise { - 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 { 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 { - 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 { 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 ({ diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index dfa968c24e..f06c244ba3 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -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); diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts index 1519267b44..b22b06c312 100644 --- a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -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 diff --git a/packages/app/e2e/browser/plugin-file-panels.spec.ts b/packages/app/e2e/browser/plugin-file-panels.spec.ts index 62a0f2c6a4..74d4e41329 100644 --- a/packages/app/e2e/browser/plugin-file-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panels.spec.ts @@ -17,39 +17,61 @@ const PLUGIN_ID = "file-panel-e2e"; const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<>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 ; return PDF plugin: {file.name} Path {file.path} - Bytes {document.bytes.length}; URL bytes {urlBytes} + Bytes {bytes.bytes.length}; URL bytes {urlBytes} Workspace {workspace?.id} - + ; } -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 ; return - Plugin Markdown: {document.text} - {document.editable ? "Plugin document editable" : "Plugin document readonly"} - document.setText("# Saved by plugin\\n")}> + Plugin Markdown: {text} + save("# Saved by plugin\\n")}> Edit Markdown through plugin + {status} ; } 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", { diff --git a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts index 33fd191aea..9cf67326be 100644 --- a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts +++ b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts @@ -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 () => {}; }`; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 7190b42462..bccd4d3ee0 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -56,10 +56,7 @@ export type { DraftInput } from "@/stores/draft-store"; export type { Agent, ExplorerEntry, - ExplorerFile, ExplorerEntryKind, - ExplorerFileKind, - ExplorerEncoding, AgentFileExplorerState, } from "@/stores/session-store"; diff --git a/packages/app/src/file-explorer/read-result.test.ts b/packages/app/src/file-explorer/read-result.test.ts deleted file mode 100644 index fa5d3bb882..0000000000 --- a/packages/app/src/file-explorer/read-result.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts deleted file mode 100644 index 2e36add959..0000000000 --- a/packages/app/src/file-explorer/read-result.ts +++ /dev/null @@ -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; -} diff --git a/packages/app/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx index 41f64e0e1b..ab1fa41943 100644 --- a/packages/app/src/file-pane/bar.tsx +++ b/packages/app/src/file-pane/bar.tsx @@ -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 ( - - - {formatFileSize(size)} - - {lineCount !== undefined ? ( - - {t("panels.file.editor.lines", { count: lineCount })} - - ) : null} - - - {editorStatus === "dirty" ? ( - - ) : null} - {editorStatus === "saving" ? ( - <> - - {t("panels.file.editor.saving")} - - ) : null} - {editorStatus === "error" ? ( - {t("panels.file.editor.saveFailed")} - ) : null} - {trailing} - + + {modified ? ( + + ) : null} - {conflict ? : null} ); } -/** 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 ? ( - - {vimMode} - - ) : null} - - Ln {cursor.line}, Col {cursor.column} - - - ); -} - -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, - }, })); diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts deleted file mode 100644 index e2f6ea4dbd..0000000000 --- a/packages/app/src/file-pane/editor/model.test.ts +++ /dev/null @@ -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 { - this.callback = callback; - return 1 as unknown as ReturnType; - } - - 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 | 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 { - 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 { - 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, - }; - 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, - }; - - 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, - }; - - 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" }); - }); -}); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts deleted file mode 100644 index 6207e28890..0000000000 --- a/packages/app/src/file-pane/editor/model.ts +++ /dev/null @@ -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; -} - -export interface FileEditorSession { - write(input: { - content: string; - expectedModifiedAt: string; - expectedRevision?: string; - }): Promise; -} - -export type FileEditorObservation = - | { status: "ready"; file: FileEditorFile } - | Extract; - -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; - clearTimeout(handle: ReturnType): 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 | 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 { - 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 { - if (this.disposed || this.snapshot.status !== "conflict") return; - if (this.snapshot.observedVersion.status !== "ready") return; - await this.performWrite(this.snapshot.observedVersion); - } - - async reload(): Promise { - 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, - ): Promise { - 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 = { - 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 - ); -} diff --git a/packages/app/src/file-pane/editor/observation-source.test.ts b/packages/app/src/file-pane/editor/observation-source.test.ts deleted file mode 100644 index 30a751b5c1..0000000000 --- a/packages/app/src/file-pane/editor/observation-source.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/file-pane/editor/observation-source.ts b/packages/app/src/file-pane/editor/observation-source.ts deleted file mode 100644 index 478c5d3e76..0000000000 --- a/packages/app/src/file-pane/editor/observation-source.ts +++ /dev/null @@ -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, - }, - }; -} diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts deleted file mode 100644 index 7e64facbde..0000000000 --- a/packages/app/src/file-pane/live-file/hook.ts +++ /dev/null @@ -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(() => { - 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, - }; -} diff --git a/packages/app/src/file-pane/live-file/model.test.ts b/packages/app/src/file-pane/live-file/model.test.ts deleted file mode 100644 index c84f73e8ed..0000000000 --- a/packages/app/src/file-pane/live-file/model.test.ts +++ /dev/null @@ -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 { - promise: Promise; - resolve(value: T): void; - reject(error: Error): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((onResolve, onReject) => { - resolve = onResolve; - reject = onReject; - }); - return { promise, resolve, reject }; -} - -function ready(revision = "revision-1"): Extract { - 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[] = []; - 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 { - this.subscribeAttempts += 1; - this.onVersion = onVersion; - return { initial: this.initial, unsubscribe: () => (this.onVersion = null) }; - } - - read(): Promise { - const request = deferred(); - this.reads.push(request); - return request.promise; - } - - emit(version: FileVersion): void { - this.onVersion?.(version); - } -} - -class DeferredSubscribeSession implements LiveFileSession { - readonly reads: Deferred[] = []; - readonly subscription = deferred(); - private onVersion: ((version: FileVersion) => void) | null = null; - - subscribe( - _target: { cwd: string; path: string }, - onVersion: (version: FileVersion) => void, - ): Promise { - this.onVersion = onVersion; - return this.subscription.promise; - } - - read(): Promise { - const request = deferred(); - 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 { - if (this.subscribeAttempts === 0) { - this.subscribeAttempts += 1; - throw new Error("Connection closed."); - } - return super.subscribe(target, onVersion); - } -} - -async function waitForRead( - session: { reads: Deferred[] }, - count: number, -): Promise { - 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" }, - }); - }); -}); diff --git a/packages/app/src/file-pane/live-file/model.ts b/packages/app/src/file-pane/live-file/model.ts deleted file mode 100644 index cbd0757490..0000000000 --- a/packages/app/src/file-pane/live-file/model.ts +++ /dev/null @@ -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; - read(target: LiveFileTarget): Promise; -} - -export type LiveFileReadState = - | { status: "idle" } - | { status: "pending"; requested: boolean } - | { status: "error"; error: string }; - -export type LiveFileObservation = - | { - status: "ready"; - version: Extract; - file: FileReadResult; - } - | Extract; - -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 { - 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 { - 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); -} diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index ebb78bb702..68273491d7 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -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 ( - - ); -} - -function TooLargeSource({ size }: { size?: number }) { - const { t } = useTranslation(); - return ( - - {t("panels.file.tooLargeToDisplay")} - {size ? {formatFileSize({ size })} : null} - - ); -} - -/** 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 ( - - - {t("panels.file.loading")} - - ); - } - - if (!preview) { - return ( - - {t("panels.file.noPreview")} - - ); - } - - if (!panel) { - return ( - - {t("panels.file.binaryPreviewUnavailable")} - {formatFileSize({ size: preview.size })} - - ); - } - if (isSourceFilePanel(panel)) { - return ( - - ); - } - return ( - - ); -} +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 ( - <> - - - + 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 = {t("workspace.terminal.hostDisconnected")}; + } else if (!handle || snapshot.status === "loading") { return ( - - {disconnectedMessage} - + ); - } - - if (editable && client && readTarget && preview?.kind === "text") { - return ( - + } else if (snapshot.status === "missing" || snapshot.status === "error") { + body = ( + <> + + {snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")} + + + ); - } - - if (errorMessage) { - if (errorMessage === "File is too large to display") { - return ( - - - - ); - } + } else if (!panel) { + body = ( + <> + {t("panels.file.binaryPreviewUnavailable")} + {formatFileSize(snapshot.size)} + + ); + } else { return ( - - {errorMessage} - - - - ); - } - - return ( - - {preview ? : null} - - - ); -} - -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(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( - () => ({ - ...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 ? : null), - [cursor, sourceActive, vimMode], - ); - - return ( - - - {sourceActive ? ( - - ) : ( - + + - )} + + ); + } + return ( + + {body} ); } -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(() => ({ + setModified: (modified) => onModifiedChange(modified), + })); + useEffect(() => () => onModifiedChange(false), [onModifiedChange]); + return ; } 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, - }, })); diff --git a/packages/app/src/file-pane/preview-lifecycle/hook.ts b/packages/app/src/file-pane/preview-lifecycle/hook.ts deleted file mode 100644 index bbf68cc230..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/hook.ts +++ /dev/null @@ -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; -} diff --git a/packages/app/src/file-pane/preview-lifecycle/model.test.ts b/packages/app/src/file-pane/preview-lifecycle/model.test.ts deleted file mode 100644 index cef082337a..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.test.ts +++ /dev/null @@ -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 { - promise: Promise; - resolve(value: T): void; - reject(error: Error): void; -} - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((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(); - const second = deferred(); - const third = deferred(); - 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(); - const second = deferred(); - 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 { - await new Promise((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 = []; - 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 } }); -}); diff --git a/packages/app/src/file-pane/preview-lifecycle/model.ts b/packages/app/src/file-pane/preview-lifecycle/model.ts deleted file mode 100644 index b831653a81..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.ts +++ /dev/null @@ -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 { - 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, - ) {} - - 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 { - 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"; -} diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx deleted file mode 100644 index de7c5eeae1..0000000000 --- a/packages/app/src/file-pane/source/view.native.tsx +++ /dev/null @@ -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 ( - - {tooLargeMessage} - - ); - } - return ( - - ); -} - -function VirtualizedSource({ - content, - filename, - location, - navigationRevision, - presentation, -}: Omit & { - presentation: "highlighted" | "plain"; -}) { - const listRef = useRef>(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 ( - - ); -} - -function SourceLineView({ line }: { line: SourceLine }) { - return ( - - {line.number} - - {line.tokens.map((token) => ( - - {token.text} - - ))} - - - ); -} - -function sourceLineKey(line: SourceLine): string { - return String(line.number); -} - -function sourceLineLayout(_data: ArrayLike | null | undefined, index: number) { - return { length: 20, offset: index * 20, index }; -} - -const renderSourceLine: ListRenderItem = ({ 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, - }, -})); diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx index 2424262423..8a9466b45e 100644 --- a/packages/app/src/file-panels/builtin.tsx +++ b/packages/app/src/file-panels/builtin.tsx @@ -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 ; + if (text.status === "error") return ; return ( - - - + + + + + + {rendered ? null : } ); } -function HtmlPanel({ content }: BuiltinFilePanelProps) { +function HtmlPanel({ file }: BuiltinFilePanelProps) { + const text = useFileText(file); + if (text.status === "loading") return ; + if (text.status === "error") return ; return ( - - + + ); } -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 ; + if (url.status === "error") return ; + return ; +} -function ImagePanel({ documentPreviewUri }: BuiltinFilePanelProps) { - const { t } = useTranslation(); - if (!documentPreviewUri) { - return ( - - - {t("panels.file.loading")} - - ); - } - return ; +function FilePanelError({ message }: { message: string }) { + return ( + + {message} + + ); } 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 ; } 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", }, })); diff --git a/packages/app/src/file-panels/catalog.test.ts b/packages/app/src/file-panels/catalog.test.ts index ac96acf612..89a7e14cc7 100644 --- a/packages/app/src/file-panels/catalog.test.ts +++ b/packages/app/src/file-panels/catalog.test.ts @@ -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", - ]); - }); }); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts index d5e2a0cfc4..b48f03835b 100644 --- a/packages/app/src/file-panels/catalog.ts +++ b/packages/app/src/file-panels/catalog.ts @@ -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]; diff --git a/packages/app/src/file-panels/choice/hook.ts b/packages/app/src/file-panels/choice/hook.ts index 3ee7191391..0e51a69c4b 100644 --- a/packages/app/src/file-panels/choice/hook.ts +++ b/packages/app/src/file-panels/choice/hook.ts @@ -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(() => { diff --git a/packages/app/src/file-panels/choice/model.test.ts b/packages/app/src/file-panels/choice/model.test.ts index 86c31bbf7c..e9e5e83042 100644 --- a/packages/app/src/file-panels/choice/model.test.ts +++ b/packages/app/src/file-panels/choice/model.test.ts @@ -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), diff --git a/packages/app/src/file-panels/document.test.ts b/packages/app/src/file-panels/document.test.ts deleted file mode 100644 index a61e5a5bf0..0000000000 --- a/packages/app/src/file-panels/document.test.ts +++ /dev/null @@ -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 { - this.callback = callback; - return 1 as unknown as ReturnType; - } - 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 }); -}); diff --git a/packages/app/src/file-panels/document.ts b/packages/app/src/file-panels/document.ts deleted file mode 100644 index 9975fbc58f..0000000000 --- a/packages/app/src/file-panels/document.ts +++ /dev/null @@ -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 }; -} diff --git a/packages/app/src/file-panels/file/daemon-session.ts b/packages/app/src/file-panels/file/daemon-session.ts new file mode 100644 index 0000000000..341beef8e8 --- /dev/null +++ b/packages/app/src/file-panels/file/daemon-session.ts @@ -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 { + 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); +} diff --git a/packages/app/src/file-panels/file/handle.test.ts b/packages/app/src/file-panels/file/handle.test.ts new file mode 100644 index 0000000000..39a2dfcb77 --- /dev/null +++ b/packages/app/src/file-panels/file/handle.test.ts @@ -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 { + 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"]); + }); +}); diff --git a/packages/app/src/file-panels/file/handle.ts b/packages/app/src/file-panels/file/handle.ts new file mode 100644 index 0000000000..016b0aad9b --- /dev/null +++ b/packages/app/src/file-panels/file/handle.ts @@ -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 + | Extract; + +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; + write(input: { + target: FileTarget; + content: string | Uint8Array; + expectedModifiedAt: string; + expectedRevision?: string; + }): Promise; + 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(); + private deleted = false; + private confirmingDeletion = false; + private hasBom = false; + private writes: Promise = Promise.resolve(); + private writing = 0; + private holds = 0; + private releaseHold: (() => void) | null = null; + private held: Promise | null = null; + private observing: { generation: number; unsubscribe: (() => void) | null } | null = null; + private generation = 0; + private currentUrl: { key: string; url: Promise; 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 { + let subscription: Awaited>; + 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 { + 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 { + this.assertOpen(); + await this.writes; + const contents = await this.session.read(this.target); + this.assertOpen(); + this.adopt(contents); + return contents; + } + + private async readText(): Promise { + const { bytes } = await this.readContents(); + this.hasBom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; + return decodeUtf8(bytes); + } + + private async readBytes(): Promise { + return (await this.readContents()).bytes; + } + + private url(): Promise { + const current = this.currentUrl; + if (current && this.stat && current.key === versionKey(this.stat)) return current.url; + return this.createUrl(); + } + + private async createUrl(): Promise { + 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 { + return this.write(this.hasBom ? `${text}` : text, options); + } + + private write(content: string | Uint8Array, options?: PluginFileWriteOptions): Promise { + 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 { + 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); +} diff --git a/packages/app/src/file-panels/file/hook.ts b/packages/app/src/file-panels/file/hook.ts new file mode 100644 index 0000000000..b9af146f66 --- /dev/null +++ b/packages/app/src/file-panels/file/hook.ts @@ -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, + ); +} diff --git a/packages/app/src/file-panels/file/hooks.test.tsx b/packages/app/src/file-panels/file/hooks.test.tsx new file mode 100644 index 0000000000..ee8396a5fb --- /dev/null +++ b/packages/app/src/file-panels/file/hooks.test.tsx @@ -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"]); + }); +}); diff --git a/packages/app/src/file-panels/file/index.ts b/packages/app/src/file-panels/file/index.ts new file mode 100644 index 0000000000..e21e9908d0 --- /dev/null +++ b/packages/app/src/file-panels/file/index.ts @@ -0,0 +1,2 @@ +export { FileHandle, type FileHandleSnapshot, type FileStat, type FileTarget } from "./handle"; +export { useFileHandle, useFileHandleSnapshot } from "./hook"; diff --git a/packages/app/src/file-panels/file/testing.ts b/packages/app/src/file-panels/file/testing.ts new file mode 100644 index 0000000000..cde08d117e --- /dev/null +++ b/packages/app/src/file-panels/file/testing.ts @@ -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(); + 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 { + 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 { + 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), + }; +} diff --git a/packages/app/src/file-panels/index.ts b/packages/app/src/file-panels/index.ts index 67136d7a16..b555db82e7 100644 --- a/packages/app/src/file-panels/index.ts +++ b/packages/app/src/file-panels/index.ts @@ -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 { diff --git a/packages/app/src/file-panels/loading.tsx b/packages/app/src/file-panels/loading.tsx new file mode 100644 index 0000000000..9f8ead62eb --- /dev/null +++ b/packages/app/src/file-panels/loading.tsx @@ -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 ( + + + {t("panels.file.loading")} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/file-panels/panels.ts b/packages/app/src/file-panels/panels.ts index 0da0a552cd..6ed609e07c 100644 --- a/packages/app/src/file-panels/panels.ts +++ b/packages/app/src/file-panels/panels.ts @@ -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, })), diff --git a/packages/app/src/file-panels/plugin-host.tsx b/packages/app/src/file-panels/plugin-host.tsx index 8107cd09df..06b2260a8b 100644 --- a/packages/app/src/file-panels/plugin-host.tsx +++ b/packages/app/src/file-panels/plugin-host.tsx @@ -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 & { 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 ( @@ -78,9 +38,9 @@ function PluginHost({ layout={layout} navigation={navigation} workspaceId={workspaceId} - navigationRevision={navigationRevision} - document={document} file={file} + tab={tab} + location={location} /> diff --git a/packages/app/src/file-panels/resolve.test.ts b/packages/app/src/file-panels/resolve.test.ts index 3816dee910..b0712ff7c1 100644 --- a/packages/app/src/file-panels/resolve.test.ts +++ b/packages/app/src/file-panels/resolve.test.ts @@ -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>[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"); - }); }); diff --git a/packages/app/src/file-panels/resolve.ts b/packages/app/src/file-panels/resolve.ts index 9fad317aa7..6b0f2ffbd1 100644 --- a/packages/app/src/file-panels/resolve.ts +++ b/packages/app/src/file-panels/resolve.ts @@ -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 { @@ -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(input: { choice?: string | null; }): FilePanelResolution { 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(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(input: { - file: FileReadResult | null; - path: string; - candidates: readonly Candidate[]; - associations: Readonly>; - choice?: string | null; -}): FilePanelResolution | 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, - }); -} diff --git a/packages/app/src/file-panels/settings/rows.test.ts b/packages/app/src/file-panels/settings/rows.test.ts index d43cf9ab2d..989d8dae3e 100644 --- a/packages/app/src/file-panels/settings/rows.test.ts +++ b/packages/app/src/file-panels/settings/rows.test.ts @@ -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"))); diff --git a/packages/app/src/file-panels/settings/rows.ts b/packages/app/src/file-panels/settings/rows.ts index 3bee261852..e4a2c180e5 100644 --- a/packages/app/src/file-panels/settings/rows.ts +++ b/packages/app/src/file-panels/settings/rows.ts @@ -40,9 +40,9 @@ export function buildFilePanelSettingsRows } /** - * 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( candidates: readonly Candidate[], @@ -54,7 +54,7 @@ function offeredCandidates( ); return candidates.filter((candidate) => candidate.files === "all" - ? claiming.some((claimant) => claimant.document === candidate.document) + ? claiming.some((claimant) => claimant.fileKind === candidate.fileKind) : claiming.includes(candidate), ); } diff --git a/packages/app/src/file-pane/conflict-alert.tsx b/packages/app/src/file-panels/source/conflict-alert.tsx similarity index 78% rename from packages/app/src/file-pane/conflict-alert.tsx rename to packages/app/src/file-panels/source/conflict-alert.tsx index 12354f18f0..94a76f517d 100644 --- a/packages/app/src/file-pane/conflict-alert.tsx +++ b/packages/app/src/file-panels/source/conflict-alert.tsx @@ -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 }) {title} {description ? {description} : null} - {state.kind !== "deleted" ? ( + {state.kind === "changed" ? ( - {state.kind === "changed" && state.canOverwrite ? ( + {state.canOverwrite ? ( ) : null} - {state.kind === "changed" ? ( - - ) : null} - {state.kind === "checkFailed" ? ( - - ) : null} + ) : null} diff --git a/packages/app/src/file-pane/editor/extensions.web.ts b/packages/app/src/file-panels/source/editor/extensions.web.ts similarity index 100% rename from packages/app/src/file-pane/editor/extensions.web.ts rename to packages/app/src/file-panels/source/editor/extensions.web.ts diff --git a/packages/app/src/file-panels/source/editor/model.test.ts b/packages/app/src/file-panels/source/editor/model.test.ts new file mode 100644 index 0000000000..a01dae5d9d --- /dev/null +++ b/packages/app/src/file-panels/source/editor/model.test.ts @@ -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 { + this.callback = callback; + return 1 as unknown as ReturnType; + } + + 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 { + 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"); + }); +}); diff --git a/packages/app/src/file-panels/source/editor/model.ts b/packages/app/src/file-panels/source/editor/model.ts new file mode 100644 index 0000000000..f208799b60 --- /dev/null +++ b/packages/app/src/file-panels/source/editor/model.ts @@ -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; + clearTimeout(handle: ReturnType): 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 | 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 { + 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 { + if (this.snapshot.conflict !== "changed") return; + await this.write({ force: true }); + } + + /** Replaces the buffer with the file on disk. */ + async reload(): Promise { + 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 { + 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 { + 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); +} diff --git a/packages/app/src/file-pane/editor/view.tsx b/packages/app/src/file-panels/source/editor/view.tsx similarity index 98% rename from packages/app/src/file-pane/editor/view.tsx rename to packages/app/src/file-panels/source/editor/view.tsx index 8c27bbd8bb..217afd26c0 100644 --- a/packages/app/src/file-pane/editor/view.tsx +++ b/packages/app/src/file-panels/source/editor/view.tsx @@ -22,6 +22,7 @@ export function FileEditorView(_props: { }; onCursorChange(position: { line: number; column: number }): void; onVimModeChange(mode: string | null): void; + onReady(): void; }) { return ( diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-panels/source/editor/view.web.tsx similarity index 96% rename from packages/app/src/file-pane/editor/view.web.tsx rename to packages/app/src/file-panels/source/editor/view.web.tsx index 13d447f3fc..ca8ee0ed6a 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-panels/source/editor/view.web.tsx @@ -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(null); const viewRef = useRef(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; diff --git a/packages/app/src/file-pane/find/index.web.tsx b/packages/app/src/file-panels/source/find/index.web.tsx similarity index 100% rename from packages/app/src/file-pane/find/index.web.tsx rename to packages/app/src/file-panels/source/find/index.web.tsx diff --git a/packages/app/src/file-pane/find/model.web.ts b/packages/app/src/file-panels/source/find/model.web.ts similarity index 100% rename from packages/app/src/file-pane/find/model.web.ts rename to packages/app/src/file-panels/source/find/model.web.ts diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx new file mode 100644 index 0000000000..e2c88d967b --- /dev/null +++ b/packages/app/src/file-panels/source/panel.tsx @@ -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 ; + if (isWeb && size <= MAX_EDITABLE_BYTES) return ; + return ; +} + +function TooLargeSource({ size }: { size: number }) { + const { t } = useTranslation(); + return ( + + {t("panels.file.tooLargeToDisplay")} + {formatFileSize(size)} + + ); +} + +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 ; + if (text.status === "error") { + return ( + + {text.error.message || t("panels.file.failedToLoad")} + + ); + } + return ( + + + + + ); +} + +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(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 ; + if (snapshot.status === "failed") { + return ( + + {snapshot.error ?? t("panels.file.failedToLoad")} + + + ); + } + + 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 ( + + {conflict ? : null} + + + {editorReady ? null : } + + + + ); +} + +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", + }, +})); diff --git a/packages/app/src/file-pane/source/presentation.test.ts b/packages/app/src/file-panels/source/readonly/presentation.test.ts similarity index 100% rename from packages/app/src/file-pane/source/presentation.test.ts rename to packages/app/src/file-panels/source/readonly/presentation.test.ts diff --git a/packages/app/src/file-pane/source/presentation.ts b/packages/app/src/file-panels/source/readonly/presentation.ts similarity index 100% rename from packages/app/src/file-pane/source/presentation.ts rename to packages/app/src/file-panels/source/readonly/presentation.ts diff --git a/packages/app/src/file-panels/source/readonly/source-lines.test.ts b/packages/app/src/file-panels/source/readonly/source-lines.test.ts new file mode 100644 index 0000000000..dfacbeae49 --- /dev/null +++ b/packages/app/src/file-panels/source/readonly/source-lines.test.ts @@ -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((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); + }); +}); diff --git a/packages/app/src/file-panels/source/readonly/source-lines.ts b/packages/app/src/file-panels/source/readonly/source-lines.ts new file mode 100644 index 0000000000..609a597584 --- /dev/null +++ b/packages/app/src/file-panels/source/readonly/source-lines.ts @@ -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 { + 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; + } +} diff --git a/packages/app/src/file-panels/source/readonly/view.native.tsx b/packages/app/src/file-panels/source/readonly/view.native.tsx new file mode 100644 index 0000000000..1e9cd76a8b --- /dev/null +++ b/packages/app/src/file-panels/source/readonly/view.native.tsx @@ -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; + 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>(null); + const [lines, setLines] = useState(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 ; + return ( + + ); +} + +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 ( + + {number} + + {tokens.map((token) => { + const key = offset; + offset += token.text.length; + return ( + + {token.text} + + ); + })} + + + ); +}, 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 | null | undefined, index: number) { + return { length: 20, offset: index * 20, index }; +} + +const renderSourceLine: ListRenderItem = ({ item, index }) => ( + +); + +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, + }, +})); diff --git a/packages/app/src/file-pane/source/view.tsx b/packages/app/src/file-panels/source/readonly/view.tsx similarity index 100% rename from packages/app/src/file-pane/source/view.tsx rename to packages/app/src/file-panels/source/readonly/view.tsx diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-panels/source/readonly/view.web.tsx similarity index 79% rename from packages/app/src/file-pane/source/view.web.tsx rename to packages/app/src/file-panels/source/readonly/view.web.tsx index 3499ac7d8e..4f84dda5fd 100644 --- a/packages/app/src/file-pane/source/view.web.tsx +++ b/packages/app/src/file-panels/source/readonly/view.web.tsx @@ -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; 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 ( -
- {tooLargeMessage} -
- ); - } - return ( - - ); -} - -function ReadonlyCodeMirror({ content, filename, location, navigationRevision, presentation, theme, -}: Omit & { - presentation: Exclude; -}) { +}: FileSourceViewProps) { const [find] = useState(() => new FileFindModel()); const hostRef = useRef(null); const viewRef = useRef(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({
+ {ready ? null : }
); } @@ -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; diff --git a/packages/app/src/file-panels/source/status.tsx b/packages/app/src/file-panels/source/status.tsx new file mode 100644 index 0000000000..a3582f3c5d --- /dev/null +++ b/packages/app/src/file-panels/source/status.tsx @@ -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 ( + + + + {formatFileSize(size)} + + + {t("panels.file.editor.lines", { count: lineCount })} + + + + {editorStatus === "dirty" ? ( + + ) : null} + {editorStatus === "saving" ? ( + <> + + {t("panels.file.editor.saving")} + + ) : null} + {editorStatus === "error" ? ( + {t("panels.file.editor.saveFailed")} + ) : null} + {vimMode ? ( + + {vimMode} + + ) : null} + {cursor ? ( + + Ln {cursor.line}, Col {cursor.column} + + ) : null} + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/file-panels/view.tsx b/packages/app/src/file-panels/view.tsx index e45fcecf90..47587effa6 100644 --- a/packages/app/src/file-panels/view.tsx +++ b/packages/app/src/file-panels/view.tsx @@ -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, "panel"> & { - panel: Exclude; - 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 ; - 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 ( - - ); + return ; } diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index ca3c99649b..d54131eb4a 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -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, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 4bd114c38f..72c7e7fae7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "استبدال", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c5c3a4422e..41bfa7ebd6 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index a9b52ddc2f..dab7b74df3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4e24fb04ed..cc56618dc9 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ed0e22b956..ce2cc966ae 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "上書き", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index dea08fcbc4..24449d4db5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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: "덮어쓰기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 6a39673332..795dfdc528 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 18b89fe842..fcdd622cf3 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Локальный буфер сохранён в редакторе. Выберите, какую версию оставить.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b140df6ff2..868a65ea2f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "覆盖", diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index ad897e8cd1..4d12358ae4 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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) => { diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 7d1137d6b4..39905203af 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -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), ); diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index fcff17e7c0..77128b731a 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -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"; diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 29743c48ce..fb910b7a97 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -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//sidebar/` routes keep resolving. */ legacySidebarItems: PluginSidebarContribution[]; - filePanels: EvaluatedPluginFilePanelContribution[]; + filePanels: PluginFilePanelContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; clientSlashCommands: PluginClientSlashCommandContribution[]; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index f7c54e07b2..dfeeadb7b0 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -235,8 +235,6 @@ function preserveMapIdentity( } 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; - files: Map; isLoading: boolean; lastError: string | null; pendingRequest: ExplorerRequestState | null; diff --git a/packages/app/src/utils/decode-utf8.native.test.ts b/packages/app/src/utils/decode-utf8.native.test.ts new file mode 100644 index 0000000000..fc159594dd --- /dev/null +++ b/packages/app/src/utils/decode-utf8.native.test.ts @@ -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(""); + }); +}); diff --git a/packages/app/src/utils/decode-utf8.native.ts b/packages/app/src/utils/decode-utf8.native.ts new file mode 100644 index 0000000000..1c6241014d --- /dev/null +++ b/packages/app/src/utils/decode-utf8.native.ts @@ -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; +} diff --git a/packages/app/src/utils/decode-utf8.ts b/packages/app/src/utils/decode-utf8.ts new file mode 100644 index 0000000000..75556f09f2 --- /dev/null +++ b/packages/app/src/utils/decode-utf8.ts @@ -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); +} diff --git a/packages/app/src/utils/format-file-size.ts b/packages/app/src/utils/format-file-size.ts new file mode 100644 index 0000000000..88be231eef --- /dev/null +++ b/packages/app/src/utils/format-file-size.ts @@ -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`; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 647e1fddf6..30d0db6cc6 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 { diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index a943e6dd4a..fde3f9d740 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -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(steps: Generator): Generator { + 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"); diff --git a/packages/highlight/src/highlighter.ts b/packages/highlight/src/highlighter.ts index 3929a2948a..5f2f525af6 100644 --- a/packages/highlight/src/highlighter.ts +++ b/packages/highlight/src/highlighter.ts @@ -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 { 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 { + 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 { + 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 = 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 }]; diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts index 3c004f97af..a69820ee28 100644 --- a/packages/highlight/src/index.ts +++ b/packages/highlight/src/index.ts @@ -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 { diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index e66e52b8d3..0a5ae3caa0 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -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; } diff --git a/packages/plugin/src/client/file.ts b/packages/plugin/src/client/file.ts new file mode 100644 index 0000000000..719ce6543d --- /dev/null +++ b/packages/plugin/src/client/file.ts @@ -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; + readBytes(): Promise; + /** + * 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; + /** + * 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; + writeBytes(bytes: Uint8Array, options?: PluginFileWriteOptions): Promise; + /** + * 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 { + 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)); +} diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 5603e102df..54fce017c8 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -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"; diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 2f1f4a8d62..186b706eb6 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -86,5 +86,8 @@ export declare const SidebarRow: ComponentType; /** 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 }>; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f7c43f224a..e8df79eab1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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. diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index 4ba4b57c54..da11a87bc2 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -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 { diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index a4771c2e7a..9d81c9ccfb 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -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 { - 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); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index a0bcb6ed2d..2ab928dc10 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -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, }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b95e7696e2..13d439526b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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. diff --git a/plugin-examples/markdown-panel/README.md b/plugin-examples/markdown-panel/README.md index d480720bc8..ab5e2b9941 100644 --- a/plugin-examples/markdown-panel/README.md +++ b/plugin-examples/markdown-panel/README.md @@ -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`. diff --git a/plugin-examples/markdown-panel/client/rich-markdown.tsx b/plugin-examples/markdown-panel/client/rich-markdown.tsx index 7fc91b9763..33de818e52 100644 --- a/plugin-examples/markdown-panel/client/rich-markdown.tsx +++ b/plugin-examples/markdown-panel/client/rich-markdown.tsx @@ -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(null); - const editor = useRef>(null); - const currentDocument = useRef(document); - currentDocument.current = document; + const editor = useRef(null); + const text = useRef(""); + const unsaved = useRef(null); + const saveTimer = useRef | undefined>(undefined); + const [state, setState] = useState("loading"); + const [error, setError] = useState(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 ; + 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 ( + + {state === "conflict" ? ( + + The file changed on disk. + + Reload + + + Overwrite + + + ) : null} + {state === "deleted" ? ( + + The file was deleted. Edits are kept here. + + ) : null} + {error ? {error} : null} + + {state === "loading" && !error ? : null} + + ); } -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 ; + if (text.status === "loading") return ; + if (text.status === "error") return {text.error.message}; return ( - {document.text} + {text.text} ); } + +export function RichMarkdownPanel(props: PluginFilePanelProps) { + if (props.layout.platform === "web") return ; + return ; +} + +function messageOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} diff --git a/plugin-examples/markdown-panel/client/web.ts b/plugin-examples/markdown-panel/client/web.ts index 79ce8fb42c..5164862404 100644 --- a/plugin-examples/markdown-panel/client/web.ts +++ b/plugin-examples/markdown-panel/client/web.ts @@ -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; } diff --git a/plugin-examples/markdown-panel/index.client.tsx b/plugin-examples/markdown-panel/index.client.tsx index c6cf4ffec9..663d4e1534 100644 --- a/plugin-examples/markdown-panel/index.client.tsx +++ b/plugin-examples/markdown-panel/index.client.tsx @@ -7,7 +7,6 @@ export default function contribute(client: PluginClientContext) { title: "Rich Markdown", icon: "FileType", files: { extensions: ["md", "markdown"] }, - document: "text", Component: RichMarkdownPanel, }); return () => {}; diff --git a/plugin-examples/pdf-panel/README.md b/plugin-examples/pdf-panel/README.md index c7f34b4fb7..ce0acdfdc1 100644 --- a/plugin-examples/pdf-panel/README.md +++ b/plugin-examples/pdf-panel/README.md @@ -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). diff --git a/plugin-examples/pdf-panel/client/pdf-panel.tsx b/plugin-examples/pdf-panel/client/pdf-panel.tsx index 7bff4312b4..de1c5accbd 100644 --- a/plugin-examples/pdf-panel/client/pdf-panel.tsx +++ b/plugin-examples/pdf-panel/client/pdf-panel.tsx @@ -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 ; + return ; +} + +function InlinePdf({ file, theme }: PluginFilePanelProps) { + const url = useFileUrl(file); + const textStyle = useMemo(() => ({ color: theme.colors.statusDanger }), [theme]); + if (url.status === "loading") return ; + if (url.status === "error") { return ( - - - {file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline. - + + {url.error.message} ); } - return ; + return ; +} + +function AndroidPdf({ file, theme }: PluginFilePanelProps) { + const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]); + return ( + + + {file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline. + + + ); } diff --git a/plugin-examples/pdf-panel/index.client.tsx b/plugin-examples/pdf-panel/index.client.tsx index 759bff9d16..c91db3a21f 100644 --- a/plugin-examples/pdf-panel/index.client.tsx +++ b/plugin-examples/pdf-panel/index.client.tsx @@ -7,8 +7,6 @@ export default function contribute(client: PluginClientContext) { title: "PDF", icon: "FileText", files: { extensions: ["pdf"] }, - document: "bytes", - maxBytes: 50 * 1024 * 1024, Component: PdfPanel, }); return () => {}; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index abef4e8b39..2c63391f1f 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1758,23 +1758,22 @@ Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC ## File panels -Register a component that renders a file as the body of its file tab. Paseo loads the document -from the selected host, including remote hosts. Your plugin receives its contents and edits -through the host document. The tab's bar keeps the file's size, line count, save state, and -conflict alerts, and switches between every panel that can open the file. +Register a component that renders a file as the body of its file tab. Your component receives a +handle to the file on the selected host, including remote hosts. It reads the file, shows its own +loading state, and writes the file when the user edits it. The tab's bar shows the panel +switcher, the overflow menu, and a dot while your panel reports unsaved changes. ```tsx import type { PluginClientContext } from "@getpaseo/plugin/client"; -import { RichMarkdownPanel } from "./client/rich-markdown"; +import { PdfPanel } from "./client/pdf-panel"; export default function contribute(client: PluginClientContext) { return client.addFilePanel({ - id: "rich-markdown", - title: "Rich Markdown", - icon: "FileType", - files: { extensions: ["md", "markdown"] }, - document: "text", - Component: RichMarkdownPanel, + id: "pdf", + title: "PDF", + icon: "FileText", + files: { extensions: ["pdf"] }, + Component: PdfPanel, }); } ``` @@ -1782,15 +1781,13 @@ export default function contribute(client: PluginClientContext) { `addFilePanel` takes a `PluginFilePanelContribution` and returns an idempotent cleanup function. Paseo also removes the registration when the client entry is unloaded. -| Field | Required | Meaning | -| ----------- | -------- | -------------------------------------------------------------------------------------- | -| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. | -| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. | -| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | -| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. | -| `document` | Yes | `"text"` or `"bytes"`; selects the document passed to the component. | -| `maxBytes` | No | Positive finite size limit in bytes. Defaults to 10 MiB. Larger files skip this panel. | -| `Component` | Yes | Component receiving `PluginFilePanelProps`. | +| Field | Required | Meaning | +| ----------- | -------- | -------------------------------------------------------------------------- | +| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. | +| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. | +| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | +| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. | +| `Component` | Yes | Component receiving `PluginFilePanelProps`. | Extensions have no leading dot and match without case sensitivity. Filenames match the exact basename, including case. Selectors contain no paths, globs, or wildcards. Use `extensions: []` @@ -1801,51 +1798,92 @@ for a filename-only selector. At least one extension or filename is required. `PluginFilePanelProps` includes the common `theme`, `host`, `layout`, and optional `navigation` props described under [Theme and layout](#theme-and-layout), plus: -| Prop | Type | Meaning | -| -------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- | -| `workspaceId` | `string` | Workspace containing the file tab. | -| `file.path` | `string` | Absolute path on the selected host. | -| `file.name` | `string` | Basename. | -| `file.extension` | `string` | Lowercase extension without the dot, or `""`. | -| `file.mimeType` | `string` | Host-reported MIME type; binary files can report `application/octet-stream`. | -| `file.size` | `number` | Size in bytes. | -| `file.modifiedAt` | `string` | Modification timestamp. | -| `file.lineStart`, `file.lineEnd` | `number` (optional) | Requested line range. | -| `navigationRevision` | `number` | Increments when the same tab receives another line navigation. | -| `document` | `PluginTextDocument` or `PluginBytesDocument` | Matches the registration's `document` field. | +| Prop | Type | Meaning | +| ------------- | -------------------- | ------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Workspace containing the file tab. | +| `file` | `PluginFile` | Handle to the file. Stable while the tab shows this file. | +| `tab` | `PluginFileTab` | `setModified(modified)` shows the tab's unsaved dot and confirms before close. | +| `location` | `PluginFileLocation` | `lineStart?`, `lineEnd?`, and `revision`, which increments on every navigation. | -A `PluginTextDocument` keeps the text owned by Paseo: +Paseo mounts your panel once it knows the file exists and its size, without reading it. The +`PluginFile` stat fields refresh when the file changes on disk: -| Field | Type | Meaning | -| ---------- | ------------------------ | -------------------------------------------------------------------------------------- | -| `kind` | `"text"` | Document discriminator. | -| `text` | `string` | Current editor text, including external updates. | -| `editable` | `boolean` | False on native, daemons without file editing, or files above 1 MiB. Render read-only. | -| `setText` | `(next: string) => void` | Replace editor text. Paseo owns autosave, dirty state, and conflict handling. | +| Member | Meaning | +| -------------------------------- | ------------------------------------------------------------------------------------------- | +| `path`, `name`, `extension` | Absolute path on the host, basename, and lowercase extension without the dot (or `""`). | +| `size`, `modifiedAt`, `mimeType` | Size in bytes, modification timestamp, and host-reported MIME type. | +| `readText()` | Reads the file as UTF-8. A byte order mark is dropped and written back by `writeText`. | +| `readBytes()` | Reads the file's bytes. | +| `url()` | A local `blob:` (web) or `file:` (native) URL of the current bytes, for `DocumentFrame`. | +| `writeText(text, options?)` | Writes UTF-8 text if the file has not changed on disk since this handle last read or wrote. | +| `writeBytes(bytes, options?)` | The same for bytes. | +| `watch(listener)` | Calls `listener("changed")` or `listener("deleted")`; returns the unsubscribe function. | -Call `document.setText(next)` from your editor's change callback when `editable` is true. -Update your editor when `document.text` changes; do not send that update back as a user edit. -Do not write the file through RPCs or maintain a second save workflow. The **Source** panel in -the switcher opens Paseo's text editor on the same document. +Your panel stays mounted when the file is deleted. `watch` reports `"deleted"` once a read confirms +the file is gone, so a program that removes the file and writes it back reports only `"changed"`. +A deleted file that comes back reports `"changed"`. -A `PluginBytesDocument` is read-only: +A write rejects with `PluginFileConflictError` when the file changed on disk after the handle's +last read or write. Pass `{ force: true }` to write anyway. The handle's own writes do not notify +its watchers. Paseo owns the URL from `url()`: it stays valid until a URL for a newer version +resolves or the tab closes. Do not build daemon URLs from `file.path`; local URLs also work when +the daemon is remote. -| Field | Type | Meaning | -| ------- | ------------ | ------------------------------------------------------------------------------------- | -| `kind` | `"bytes"` | Document discriminator. | -| `bytes` | `Uint8Array` | Loaded file bytes. | -| `url` | `string` | Host-owned local preview URL: `blob:` on web, `file:` on native. Valid while mounted. | +### Read the file -Use `bytes` for your own renderer or pass `url` to `DocumentFrame`. Do not reconstruct a daemon -URL from `file.path`; local document URLs also work when the daemon is remote. +Most panels read through a hook. Each hook reads when mounted, re-reads when the file changes on +disk, and keeps the current value during a re-read: + +| Hook | Ready state | +| -------------------- | ---------------------------------------- | +| `useFileText(file)` | `{ status: "ready", text: string }` | +| `useFileBytes(file)` | `{ status: "ready", bytes: Uint8Array }` | +| `useFileUrl(file)` | `{ status: "ready", url: string }` | + +Each also returns `{ status: "loading" }` before the first read and `{ status: "error", error }` +when a read fails or the file is deleted. Show `Loading` from `@getpaseo/plugin/client/ui` until +your content is on screen; it fills the panel, so render it over content that is still laying out. + +```tsx +import { type PluginFilePanelProps, useFileUrl } from "@getpaseo/plugin/client"; +import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui"; +import { Text } from "react-native"; + +export function PdfPanel({ file, theme }: PluginFilePanelProps) { + const url = useFileUrl(file); + if (url.status === "loading") return ; + if (url.status === "error") { + return {url.error.message}; + } + return ; +} +``` + +### Edit the file + +A hook's re-read adopts the version on disk, so the next write overwrites whatever changed there. +A panel that holds unsaved edits reads with `readText()` once, then handles `watch` itself: + +1. Read with `file.readText()` and show the text. +2. On each edit, call `tab.setModified(true)` and write with `file.writeText(text)`, debounced. +3. When the write resolves and no newer edit is pending, call `tab.setModified(false)`. +4. On `"changed"` with nothing unsaved, read again. With unsaved edits, show a conflict and let the + user reload or overwrite with `{ force: true }`. +5. On `PluginFileConflictError`, show the same conflict. +6. On `"deleted"`, tell the user the file is gone. + +Write pending edits before the component unmounts: switching panels or closing the tab unmounts +it. Paseo asks before closing a tab whose panel reports unsaved changes. The +[Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel) +example implements these steps. ### Selection and Open with -Text panels require a text file. Bytes panels accept any file with loaded bytes. Paseo filters -matching panels by document kind and size before selecting one. Candidates are ordered: the -built-in Markdown, HTML, and Image panels, then plugin panels in host catalog order, then the -built-in **Source** panel, which every text file has. Source opens a file by default only when -nothing else claims it. +A plugin panel is a candidate for every file its selector matches, text or binary. The built-in +panels require the host's file kind: Markdown, HTML, and **Source** open text files; Image opens +images. Candidates are ordered: the built-in Markdown, HTML, and Image panels, then plugin panels +in host catalog order, then **Source**, which every text file has. Source opens a file by default +only when nothing else claims it. A remembered association selects its panel when that panel is available on the current host. Otherwise Paseo opens the first candidate. When a plugin panel is among several candidates and no @@ -1853,58 +1891,49 @@ association applies, Paseo asks once per tab open, over the panel it already ope panels alone never ask: a Markdown file opens with Markdown and offers Source in the switcher. With a Markdown plugin installed, the picker offers **Markdown — Paseo**, **Rich Markdown — markdown-panel**, and **Source — Paseo**. A plugin that claims a text extension competes with -Source, so its first open asks; a bytes plugin that alone claims a binary file opens without -asking. +Source, so its first open asks; a plugin that alone claims a binary file opens without asking. **Don't ask me again** remembers the choice on this device for the extension. When a candidate matches an exact filename, the choice applies to that basename instead. An unavailable association is ignored on that host and retained for hosts where its panel is installed. Users switch the current tab with the bar's panel switcher, which shows up to three panels side by -side (two on a phone) and a menu beyond that, or with **Open with…** in the tab menu. That choice wins over the -remembered association for this tab until it is retargeted. **Always use**, in the bar's overflow -menu and in Open with, remembers the shown panel. Opening a file at a line, from a link or a -search result, switches the tab to Source. Files with no candidate show **Binary preview +side (two on a phone) and a menu beyond that, or with **Open with…** in the tab menu. That choice +wins over the remembered association for this tab until it is retargeted. **Always use**, in the +bar's overflow menu and in Open with, remembers the shown panel. Opening a file at a line, from a +link or a search result, switches the tab to Source. Files with no candidate show **Binary preview unavailable**; binary files have no Source panel. ### DocumentFrame -Import `DocumentFrame` from `@getpaseo/plugin/client/ui` to display a local document URL: +Import `DocumentFrame` from `@getpaseo/plugin/client/ui` to display a local file URL from +`file.url()` or `useFileUrl`: -```tsx -import type { PluginFilePanelProps } from "@getpaseo/plugin/client"; -import { DocumentFrame } from "@getpaseo/plugin/client/ui"; +| Prop | Required | Meaning | +| -------- | -------- | -------------------------------------------------------------------------------- | +| `url` | Yes | Local file URL; `blob:` on web or `file:` on native. Other schemes are rejected. | +| `testID` | No | Test identifier for the frame. | -export function PdfPanel({ document }: PluginFilePanelProps) { - if (document.kind !== "bytes") return null; - return ; -} -``` - -| Prop | Required | Meaning | -| -------- | -------- | ----------------------------------------------------------------------------------- | -| `url` | Yes | Host document URL; `blob:` on web or `file:` on native. Other schemes are rejected. | -| `testID` | No | Test identifier for the frame. | - -Web inspects the local document URL’s media type before mounting the iframe. PDF uses the -browser’s PDF viewer without a sandbox; every other type, including HTML and unknown types, -keeps a full sandbox with no scripts or same-origin access. Native uses a WebView with -JavaScript, storage, and further navigation disabled. iOS supports inline PDF rendering; Android's -WebView does not. Provide an Android fallback or bundle your own renderer. +Web inspects the local URL’s media type before mounting the iframe. PDF uses the browser’s PDF +viewer without a sandbox; every other type, including HTML and unknown types, keeps a full sandbox +with no scripts or same-origin access. Native uses a WebView with JavaScript, storage, and further +navigation disabled. iOS supports inline PDF rendering; Android's WebView does not. Provide an +Android fallback or bundle your own renderer. Inline PDF rendering needs a daemon from Paseo 0.13.0 or later; older daemons report PDFs as `application/octet-stream`. ### Version requirement and examples -File panels and `DocumentFrame` require Paseo 0.13.0 or later. Declare +File panels, `DocumentFrame`, and `Loading` require Paseo 0.13.0 or later. Declare `"requirements": { "paseo": ">=0.13.0" }` in `paseo-plugin.json`. Both daemon and app check the [version requirement](#requirements); older apps skip the client bundle before evaluation. +Writing with `writeBytes` needs a daemon from Paseo 0.13.0 or later. Complete directory examples: -- [PDF panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/pdf-panel): bytes with `DocumentFrame` and an Android fallback. -- [Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel): a bundled CodeMirror editor that calls the host document's `setText`, with read-only native text. +- [PDF panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/pdf-panel): `useFileUrl` with `DocumentFrame` and an Android fallback. +- [Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel): a bundled CodeMirror editor that reads once, saves with `writeText`, and resolves conflicts, with read-only native text. ## Command Center items diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index a3f9bf44bd..d19f9d56dc 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -31,7 +31,7 @@ Pick the contribution that matches the request. Each row names the registration, | ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | | Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | -| File panel | `addFilePanel` | View or edit a matching file inside the file tab, using the host document | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | +| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | | Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | | Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | | Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |