diff --git a/docs/plugins.md b/docs/plugins.md index f74b4b0022..765a84dac1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -556,6 +556,24 @@ one section's order from built-ins, plugin groups, and the section's preference remembers under its own key. It shares header ids and the `plugin::` settings key with new-API items. +## Contribute file panels + +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 Header buttons and composer pills share the client-only descriptor and registration lifecycle in 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 0d63336d59..f06c244ba3 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -55,6 +55,10 @@ async function openWorkspaceFile(page: Page, filename: string): Promise { await expectFileTabOpen(page, filename); } +function filePanelSwitcher(page: Page) { + return page.getByTestId("file-panel-switcher").filter({ visible: true }); +} + function htmlPreview(page: Page) { return { host: page.getByTestId("file-html-preview"), @@ -62,11 +66,9 @@ function htmlPreview(page: Page) { }; } -async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { - const option = page.getByTestId("file-panel-bar").getByRole("button", { - name: view, - exact: true, - }); +/** Switches the file tab to one of its panels from the bar's switcher. */ +async function selectFilePanel(page: Page, title: string): Promise { + const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true }); await option.click(); await expect(option).toHaveAttribute("aria-selected", "true"); } @@ -128,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 { @@ -317,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); @@ -484,9 +482,9 @@ test.describe("CodeMirror workspace file editing", () => { relativePath, ); await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md"); - const modeControl = page.getByTestId("file-preview-mode"); + const modeControl = filePanelSwitcher(page); await expect(modeControl).toBeVisible(); - await selectFileView(page, "Source"); + await selectFilePanel(page, "Source"); const editorHost = page.getByTestId("file-source-editor"); const content = editor(page); @@ -548,7 +546,7 @@ test.describe("CodeMirror workspace file editing", () => { ); await workspace.navigateTo(); await openWorkspaceFile(page, "notes.md"); - await selectFileView(page, "Source"); + await selectFilePanel(page, "Source"); const markdownScroller = page .getByTestId("file-source-editor") @@ -680,7 +678,7 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible(); }); - test("refreshes Markdown and images while preserving Preview and Source behavior", async ({ + test("refreshes Markdown and images while switching between Markdown and Source", async ({ page, withWorkspace, }) => { @@ -695,15 +693,15 @@ test.describe("CodeMirror workspace file editing", () => { const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible(); + await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible(); await writeFile(markdownPath, "# Updated heading\n", "utf8"); await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible(); - await selectFileView(page, "Source"); + await selectFilePanel(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); await replaceEditorText(page, "# Saved from source\n"); await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); - await selectFileView(page, "Preview"); + await selectFilePanel(page, "Markdown"); await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible(); await openWorkspaceFile(page, "pixel.png"); @@ -786,10 +784,10 @@ test.describe("CodeMirror workspace file editing", () => { ); await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible(); - await selectFileView(page, "Source"); + await selectFilePanel(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(preview.host).toHaveCount(0); - await selectFileView(page, "Preview"); + await selectFilePanel(page, "HTML"); await expect(preview.host).toBeVisible(); }); diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index a893fbcb68..f557d97737 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -14,8 +14,9 @@ async function openMarkdownPreview(page: Page) { await openFileFromExplorer(page, "README.md"); await expectFileTabOpen(page, "README.md"); await page - .getByTestId("file-panel-bar") - .getByRole("button", { name: "Preview", exact: true }) + .getByTestId("file-panel-switcher") + .filter({ visible: true }) + .getByRole("button", { name: "Markdown", exact: true }) .click(); await ensureWorkspaceAgentPaneVisible(page); } diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts new file mode 100644 index 0000000000..b22b06c312 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -0,0 +1,272 @@ +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Frame, TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { copyPluginExample } from "../support/helpers/plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { + openFileExplorer, + openFileFromExplorer, + expectFileTabOpen, +} from "../support/helpers/file-explorer"; + +// Full Chromium includes the PDF viewer; the default headless shell does not. +test.use({ channel: "chromium" }); + +function samplePdf() { + const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET"; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + ]; + let pdf = "%PDF-1.4\n%\u0000\u00ff\n"; + const offsets = objects.map((object, index) => { + const offset = Buffer.byteLength(pdf); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + return offset; + }); + const xref = Buffer.byteLength(pdf); + pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`; + pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return pdf; +} + +async function openFile(page: Page, name: string) { + await openFileFromExplorer(page, name); + await expectFileTabOpen(page, name); +} + +const WIDE = { width: 1280, height: 900 }; +const COMPACT = { width: 390, height: 844 }; + +async function capture(page: Page, testInfo: TestInfo, name: string) { + const file = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: file }); + await testInfo.attach(name, { path: file, contentType: "image/png" }); +} + +/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */ +async function captureWideAndCompact( + page: Page, + testInfo: TestInfo, + name: string, + ready: () => Promise, +) { + await capture(page, testInfo, `${name}-wide`); + await page.setViewportSize(COMPACT); + await ready(); + await capture(page, testInfo, `${name}-compact`); + await page.setViewportSize(WIDE); + await ready(); +} + +function filePanelSwitcher(page: Page) { + return page.getByTestId("file-panel-switcher").filter({ visible: true }); +} + +/** Turns on vim keybindings without discarding settings the test writes later. */ +async function enableVimKeybindings(page: Page) { + await page.addInitScript(() => { + const key = "@paseo:app-settings"; + const settings = JSON.parse(localStorage.getItem(key) ?? "{}"); + localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true })); + }); +} + +async function chooseRichMarkdown(page: Page) { + await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click(); + await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click(); + await page.getByRole("button", { name: "Use", exact: true }).click(); +} + +async function replaceMarkdown(page: Page, text: string) { + await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text); +} + +async function selectPanel(page: Page, title: string) { + await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click(); +} + +async function expectPanels(page: Page, titles: string[]) { + for (const title of titles) { + await expect( + filePanelSwitcher(page).getByRole("button", { name: title, exact: true }), + ).toBeInViewport({ ratio: 1 }); + } +} + +/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ +async function pdfViewer(page: Page): Promise { + const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/; + const find = () => + page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null; + await expect.poll(() => find() !== null).toBe(true); + return find()!; +} + +async function expectPdfViewerReady(page: Page) { + // A narrow viewer collapses its toolbar to Print and More actions. + const viewer = await pdfViewer(page); + await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled(); +} + +async function expectMarkdownEditor(page: Page, text: string) { + const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }); + await expect(editor).toBeVisible(); + await expect(editor).toContainText(text); + await expect(editor).toBeInViewport({ ratio: 1 }); + // Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu. + await expect(filePanelSwitcher(page)).toContainText("Rich Markdown"); + await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 }); +} + +test("directory examples mount the PDF frame and write Markdown through the file handle", async ({ + page, +}, testInfo) => { + test.setTimeout(180_000); + const pdf = await copyPluginExample("pdf-panel"); + const markdown = await copyPluginExample("markdown-panel"); + execFileSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund"], + { cwd: markdown.directory, stdio: "pipe" }, + ); + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-examples-", + repo: { + files: [ + { + path: "notes.md", + content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n", + }, + { path: "second.md", content: "# Remembered panel\n" }, + { path: "report.pdf", content: samplePdf() }, + ], + }, + }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pdf.directory); + await client.installDirectoryPlugin(markdown.directory); + await enableVimKeybindings(page); + await page.setViewportSize(WIDE); + const gate = await installDaemonWebSocketGate(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openFile(page, "report.pdf"); + await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute( + "src", + /^blob:/, + ); + await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute( + "sandbox", + ); + const documentUrl = await page + .getByTitle("Document preview", { exact: true }) + .getAttribute("src"); + expect( + await page.evaluate( + async (url) => (await fetch(url!)).blob().then((blob) => blob.type), + documentUrl, + ), + ).toBe("application/pdf"); + await expectPdfViewerReady(page); + await expect( + (await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }), + ).toHaveValue("1"); + await captureWideAndCompact(page, testInfo, "pdf-example", async () => { + await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport(); + await expectPdfViewerReady(page); + }); + + await openFile(page, "notes.md"); + await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked(); + await expect( + page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible(); + await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => { + await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({ + ratio: 1, + }); + }); + await chooseRichMarkdown(page); + await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText( + "Markdown panel example", + ); + gate.holdNextClientRequest("fs.file.write.request"); + 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\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 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-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"); + // The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source. + await page.setViewportSize(COMPACT); + await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown"); + await filePanelSwitcher(page).click(); + await page.getByRole("menuitem", { name: "Source", exact: true }).click(); + await expect(filePanelSwitcher(page)).toHaveText("Source"); + await source.locator(".cm-line").nth(2).click(); + await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport(); + await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport(); + await capture(page, testInfo, "markdown-example-source-compact"); + await page.setViewportSize(WIDE); + await selectPanel(page, "Rich Markdown"); + await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n"); + await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText( + "# External update", + ); + await replaceMarkdown(page, "# Local edit\n"); + await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n"); + 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 + .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) + .toBe("# Local edit\n"); + + await openFile(page, "second.md"); + await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText( + "Remembered panel", + ); + await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden(); + await page.setViewportSize(COMPACT); + await expectMarkdownEditor(page, "Remembered panel"); + await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown"); + } finally { + await client.removePlugin("pdf-panel"); + await client.removePlugin("markdown-panel"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + await client.close(); + await workspace.cleanup(); + await pdf.cleanup(); + await markdown.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts new file mode 100644 index 0000000000..792ace7700 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts @@ -0,0 +1,156 @@ +import type { TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer"; +import { + BUILTIN_MARKDOWN_OPTION, + MARKDOWN_PANEL_ID, + PLUGIN_MARKDOWN_OPTION, + expectBuiltinMarkdown, + expectPluginMarkdown, + filePanelPicker, + installMarkdownPanelPlugin, + openMarkdown, + storedFilePanelAssociations, +} from "../support/helpers/markdown-file-panel-plugin"; + +async function choosePanel(page: Page, option: string, options: { remember: boolean }) { + await page.getByRole("radio", { name: option, exact: true }).click(); + if (options.remember) { + await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click(); + } + await page.getByRole("button", { name: "Use", exact: true }).click(); + await expect(filePanelPicker(page)).toBeHidden(); +} + +async function openWithFromTabMenu(page: Page, name: string, option: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open with…" }).hover(); + await page.getByRole("menuitem", { name: option }).click(); +} + +async function toggleAlwaysUseFromFileBar(page: Page) { + await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click(); + await page.getByRole("menuitem", { name: "Always use for .md" }).click(); +} + +function filePanelSwitcher(page: Page) { + return page.getByTestId("file-panel-switcher").filter({ visible: true }); +} + +async function expectSwitcherPanels(page: Page, titles: string[]) { + await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles); +} + +async function capture(page: Page, testInfo: TestInfo, name: string) { + const file = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: file }); + await testInfo.attach(name, { path: file, contentType: "image/png" }); +} + +test("opens Markdown with the built-in panel and Source beside it without asking", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-builtins-", + repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] }, + }); + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openMarkdown(page, "notes.md"); + await expectBuiltinMarkdown(page, "notes heading"); + await expectSwitcherPanels(page, ["Markdown", "Source"]); + await expect(filePanelPicker(page)).toBeHidden(); + await capture(page, testInfo, "file-panel-builtins-wide"); + + await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click(); + await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible(); + await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click(); + await expectBuiltinMarkdown(page, "notes heading"); + + await page.setViewportSize({ width: 390, height: 844 }); + await expectBuiltinMarkdown(page, "notes heading"); + await expect( + filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }), + ).toBeInViewport({ ratio: 1 }); + await expect(filePanelPicker(page)).toBeHidden(); + await capture(page, testInfo, "file-panel-builtins-compact"); + } finally { + await workspace.cleanup(); + } +}); + +test("asks once which panel opens Markdown and remembers it when told to", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-picker-", + repo: { + files: ["first", "second", "third", "fourth"].map((name) => ({ + path: `${name}.md`, + content: `# ${name} heading\n`, + })), + }, + }); + const plugin = await installMarkdownPanelPlugin(); + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + // First open: the built-in renders under the picker. + await openMarkdown(page, "first.md"); + await expect(filePanelPicker(page)).toBeVisible(); + await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked(); + await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked(); + await expectBuiltinMarkdown(page, "first heading"); + await capture(page, testInfo, "file-panel-picker-wide"); + + // Don't ask me again: the plugin renders and later opens use it silently. + await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true }); + await expectPluginMarkdown(page, "first.md"); + expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID }); + await openMarkdown(page, "second.md"); + await expectPluginMarkdown(page, "second.md"); + await expect(filePanelPicker(page)).toBeHidden(); + + // Forgetting from the file bar keeps the tab on the plugin. + await toggleAlwaysUseFromFileBar(page); + await expect.poll(() => storedFilePanelAssociations(page)).toEqual({}); + await expectPluginMarkdown(page, "second.md"); + + // An unremembered choice applies to this tab only; the next open asks again. + await openMarkdown(page, "third.md"); + await expect(filePanelPicker(page)).toBeVisible(); + await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false }); + await expectPluginMarkdown(page, "third.md"); + expect(await storedFilePanelAssociations(page)).toEqual({}); + + // "Open with…" switches the open tab back to the built-in in place. + await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION); + await expectBuiltinMarkdown(page, "third heading"); + await expectFileTabOpen(page, "third.md"); + + // Dismissing keeps the built-in; reopening the tab after a reload asks again. + await openMarkdown(page, "fourth.md"); + await expect(filePanelPicker(page)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(filePanelPicker(page)).toBeHidden(); + await expectBuiltinMarkdown(page, "fourth heading"); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({ + ratio: 1, + }); + await capture(page, testInfo, "file-panel-picker-compact"); + } finally { + await plugin.uninstall(); + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts new file mode 100644 index 0000000000..d6b6f6c986 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts @@ -0,0 +1,150 @@ +import type { TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { openSettings } from "../support/helpers/app"; +import { openFileExplorer } from "../support/helpers/file-explorer"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + BUILTIN_MARKDOWN_OPTION, + MARKDOWN_PANEL_ID, + PLUGIN_MARKDOWN_OPTION, + SOURCE_OPTION, + expectPluginMarkdown, + filePanelPicker, + installMarkdownPanelPlugin, + openMarkdown, + storedFilePanelAssociations, +} from "../support/helpers/markdown-file-panel-plugin"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { openSettingsSection } from "../support/helpers/settings"; + +const ASK_EVERY_TIME = "Ask every time"; + +function markdownRow(page: Page) { + return page.getByTestId("file-panel-settings-row-ext:md"); +} + +function markdownDropdown(page: Page, value: string) { + return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true }); +} + +function askAgain(page: Page) { + return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true }); +} + +async function openFilesSettings(page: Page) { + await openSettings(page); + await openSettingsSection(page, "files"); + await expect(page.getByTestId("file-panel-settings")).toBeVisible(); +} + +async function capture(page: Page, testInfo: TestInfo, name: string) { + const file = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: file }); + await testInfo.attach(name, { path: file, contentType: "image/png" }); +} + +async function expectMenuOpen(page: Page) { + await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1"); +} + +test("Settings > Files chooses which panel opens .md and asks again on request", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const workspace = await seedWorkspace({ + repoPrefix: "file-panel-settings-", + repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] }, + }); + const plugin = await installMarkdownPanelPlugin(); + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await openFilesSettings(page); + + // Nothing remembered: the row asks every time and has nothing to forget. + await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 }); + await expect(askAgain(page)).toBeDisabled(); + await capture(page, testInfo, "file-panel-settings-wide"); + + await markdownDropdown(page, ASK_EVERY_TIME).click(); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await expectMenuOpen(page); + await capture(page, testInfo, "file-panel-settings-wide-menu"); + await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click(); + await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible(); + await expect + .poll(() => storedFilePanelAssociations(page)) + .toEqual({ + "ext:md": MARKDOWN_PANEL_ID, + }); + await capture(page, testInfo, "file-panel-settings-wide-chosen"); + + // The next open uses the plugin without asking. + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openMarkdown(page, "notes.md"); + await expectPluginMarkdown(page, "notes.md"); + await expect(filePanelPicker(page)).toBeHidden(); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/settings/files"); + await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 }); + await expect(askAgain(page)).toBeInViewport({ ratio: 1 }); + await capture(page, testInfo, "file-panel-settings-compact"); + await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click(); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await expectMenuOpen(page); + await capture(page, testInfo, "file-panel-settings-compact-menu"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden(); + + // Ask again forgets the choice; the next open asks. + await askAgain(page).click(); + await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible(); + await expect.poll(() => storedFilePanelAssociations(page)).toEqual({}); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 }); + } finally { + await plugin.uninstall(); + await workspace.cleanup(); + } +}); + +test("Settings > Files explains where file panels come from when no plugin adds one", async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/settings/files"); + const empty = page.getByTestId("file-panel-settings-empty"); + await expect(empty).toHaveText("File panels appear here when a plugin adds one", { + timeout: 30_000, + }); + await capture(page, testInfo, "file-panel-settings-empty-wide"); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(empty).toBeVisible(); + await capture(page, testInfo, "file-panel-settings-empty-compact"); +}); + +test("Settings > Files offers Source in place of a panel no host has, and forgets it", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }), + ); + }); + await page.goto("/settings/files"); + const row = page.getByTestId("file-panel-settings-row-ext:pdf"); + const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" }); + await expect(dropdown).toBeEnabled({ timeout: 30_000 }); + await dropdown.click(); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden(); + await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click(); + await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible(); +}); diff --git a/packages/app/e2e/browser/plugin-file-panels.spec.ts b/packages/app/e2e/browser/plugin-file-panels.spec.ts new file mode 100644 index 0000000000..74d4e41329 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panels.spec.ts @@ -0,0 +1,232 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { pluginRequirements } from "../support/helpers/plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + openFileExplorer, + openFileFromExplorer, + expectFileTabOpen, +} from "../support/helpers/file-explorer"; + +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 { + PluginFileConflictError, + useFileBytes, + useFileUrl, + usePaseo, + useWorkspace, +} from "@getpaseo/plugin/client"; +import { useQueryClient } from "@tanstack/react-query"; +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(() => { + 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 {bytes.bytes.length}; URL bytes {urlBytes} + Workspace {workspace?.id} + + ; +} +// 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: {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"] }, Component: Pdf }); + client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText", + files: { extensions: ["md"] }, Component: Markdown }); + return () => {}; +}`; + +async function openWorkspaceFile(page: Page, name: string) { + await openFileFromExplorer(page, name); + await expectFileTabOpen(page, name); +} +async function associateMarkdownWithPlugin(page: Page) { + await page.evaluate(() => { + const key = "@paseo:app-settings"; + const settings = JSON.parse(localStorage.getItem(key) ?? "{}"); + settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" }; + localStorage.setItem(key, JSON.stringify(settings)); + }); + await page.reload(); +} +async function editMarkdownThroughPlugin(page: Page) { + await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click(); +} +async function selectSource(page: Page) { + await page + .getByTestId("file-panel-switcher") + .filter({ visible: true }) + .getByRole("button", { name: "Source", exact: true }) + .click(); +} + +test("directory plugins render PDF bytes and write Markdown through the file handle", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-")); + const workspace = await seedWorkspace({ + repoPrefix: "file-panels-", + repo: { + files: [ + { path: "notes.md", content: "# Built-in Markdown\n" }, + { path: "bytes.txt", content: "Bytes panel text document" }, + ], + }, + }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previousConfig = await client.getDaemonConfig(); + await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), SOURCE); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await page.setViewportSize({ width: 1280, height: 900 }); + const gate = await installDaemonWebSocketGate(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + await openWorkspaceFile(page, "report.pdf"); + await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible(); + await expect( + page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }), + ).toBeVisible(); + await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute( + "src", + /^blob:/, + ); + await expect( + page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }), + ).toBeVisible(); + const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png"); + await page.screenshot({ path: pdfScreenshot }); + await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" }); + + // A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin. + await openWorkspaceFile(page, "bytes.txt"); + await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden(); + await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible(); + await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible(); + await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute( + "sandbox", + "", + ); + await expect( + page + .frameLocator('[title="Document preview"]') + .getByText("Bytes panel text document", { exact: true }), + ).toBeVisible(); + + await openWorkspaceFile(page, "notes.md"); + await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden(); + await expect( + page.getByRole("heading", { name: "Built-in Markdown", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0); + await associateMarkdownWithPlugin(page); + await expect( + page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }), + ).toBeVisible(); + gate.holdNextClientRequest("fs.file.write.request"); + await editMarkdownThroughPlugin(page); + await gate.waitForHeldClientRequest(); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); + gate.releaseHeldClientRequest(); + await expect( + page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }), + ).toBeVisible(); + 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("# Changed outside"); + const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png"); + await page.screenshot({ path: markdownScreenshot }); + await testInfo.attach("plugin-markdown-source-web", { + path: markdownScreenshot, + contentType: "image/png", + }); + } finally { + await client.removePlugin(PLUGIN_ID); + await client.patchDaemonConfig({ + pluginsEnabled: previousConfig.config.pluginsEnabled ?? false, + }); + await client.close(); + await workspace.cleanup(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts new file mode 100644 index 0000000000..9cf67326be --- /dev/null +++ b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { pluginRequirements } from "./plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { expectFileTabOpen, openFileFromExplorer } from "./file-explorer"; + +/** A directory plugin whose "Rich Markdown" file panel claims `.md`, next to the built-in. */ +export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e"; +export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`; +export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo"; +export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`; +export const SOURCE_OPTION = "Source — Paseo"; + +const SOURCE = `import React from "react"; +import { Text, View } from "react-native"; +function RichMarkdown({ file }) { + return Rich Markdown plugin: {file.name}; +} +export default function contribute(client) { + client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType", + files: { extensions: ["md"] }, Component: RichMarkdown }); + return () => {}; +}`; + +/** Enables plugins on the test daemon and installs the plugin; `uninstall` restores both. */ +export async function installMarkdownPanelPlugin(): Promise<{ uninstall(): Promise }> { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-markdown-file-panel-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previousConfig = await client.getDaemonConfig(); + const uninstall = async () => { + await client.removePlugin(MARKDOWN_PANEL_PLUGIN_ID); + await client.patchDaemonConfig({ + pluginsEnabled: previousConfig.config.pluginsEnabled ?? false, + }); + await client.close(); + await rm(directory, { recursive: true, force: true }); + }; + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: MARKDOWN_PANEL_PLUGIN_ID, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), SOURCE); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + } catch (error) { + await uninstall(); + throw error; + } + return { uninstall }; +} + +export function filePanelPicker(page: Page) { + return page.getByText("Open .md files with", { exact: true }); +} + +export async function openMarkdown(page: Page, name: string): Promise { + await openFileFromExplorer(page, name); + await expectFileTabOpen(page, name); +} + +export async function expectBuiltinMarkdown(page: Page, heading: string): Promise { + await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible(); +} + +export async function expectPluginMarkdown(page: Page, name: string): Promise { + await expect(page.getByText(`Rich Markdown plugin: ${name}`, { exact: true })).toBeVisible(); +} + +export async function storedFilePanelAssociations(page: Page): Promise { + return page.evaluate(() => { + const settings = JSON.parse(localStorage.getItem("@paseo:app-settings") ?? "{}"); + return settings.filePanelAssociations ?? {}; + }); +} diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 6b810e6d13..952cc5b69d 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -24,6 +24,7 @@ const SECTION_LABELS = { appearance: "Appearance", sidebar: "Sidebar", editor: "Editor", + files: "Files", terminal: "Terminal", browser: "Browser", shortcuts: "Shortcuts", 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 3ce1b8323f..ab1fa41943 100644 --- a/packages/app/src/file-pane/bar.tsx +++ b/packages/app/src/file-pane/bar.tsx @@ -1,126 +1,42 @@ -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 { SegmentedControl } from "@/components/ui/segmented-control"; -import type { Theme } from "@/styles/theme"; -import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; -import type { FileEditorStatus } from "./editor/model"; +import { + FilePanelBarMenu, + FilePanelSwitcher, + useFilePanelChoiceSnapshot, + type FilePanelChoice, +} from "@/file-panels"; -const ThemedSpinner = withUnistyles(LoadingSpinner); -const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -export function FilePanelBar({ - size, - lineCount, - mode, - onModeChange, - editorStatus, - cursor, - vimMode, - conflict, -}: { - size: number; - lineCount?: number; - mode?: "preview" | "source"; - onModeChange?(mode: "preview" | "source"): void; - editorStatus?: FileEditorStatus; - cursor?: { line: number; column: number }; - vimMode?: string | null; - conflict?: FileConflictAlertState; -}) { +/** + * 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({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) { const { t } = useTranslation(); - const previewModes = [ - { - value: "preview" as const, - label: t("panels.file.editor.preview"), - testID: "file-mode-preview", - }, - { value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" }, - ]; + 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} - {vimMode ? ( - - {vimMode} - - ) : null} - {cursor ? ( - - Ln {cursor.line}, Col {cursor.column} - - ) : null} - - {mode && onModeChange ? ( - + {modified ? ( + ) : null} + + - {conflict ? : null} ); } -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, @@ -132,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 04707d561b..68273491d7 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,194 +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 } 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, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; -import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } 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 } 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 } from "./live-file/model"; -import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; -import type { Theme } from "@/styles/theme"; - -// CodeMirror and native source rendering consume appearance as props. Subscribe at the -// rendering leaf so theme updates reconfigure it without replacing the editor/model. -const sourceAppearance = (theme: Theme) => ({ - theme: { - 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, - }, -}); -const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance); -const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance); - -const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); - -interface FilePreviewBodyProps { - panel: FilePanel | null; - preview: ExplorerFile | null; - mode?: "preview" | "source"; - isLoading: boolean; - isMobile: boolean; - location: WorkspaceFileLocation; - navigationRevision: number; - imagePreviewUri: 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 { t } = useTranslation(); - return ( - - ); -} - -function TooLargeSource({ size }: { size?: number }) { - const { t } = useTranslation(); - return ( - - {t("panels.file.tooLargeToDisplay")} - {size ? {formatFileSize({ size })} : null} - - ); -} - -function FilePreviewBody({ - panel, - preview, - mode, - isLoading, - isMobile: _isMobile, - location, - navigationRevision, - imagePreviewUri, - workspaceRoot, - client, - serverId, -}: FilePreviewBodyProps) { - const { t } = useTranslation(); - const filePath = location.path; - - if (isLoading && !preview) { - return ( - - - {t("panels.file.loading")} - - ); - } - - if (!preview) { - return ( - - {t("panels.file.noPreview")} - - ); - } - - const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source"); - if (panel && !showSource) { - return ( - - ); - } - if (preview.kind === "text") { - return ( - - ); - } - - return ( - - {t("panels.file.binaryPreviewUnavailable")} - {formatFileSize({ size: preview.size })} - - ); -} +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, @@ -201,417 +34,120 @@ export function FilePane({ navigationRevision: number; }) { const { t } = useTranslation(); - const { settings } = useAppSettings(); - const isMobile = useIsCompactFormFactor(); - const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview"); - - 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 previewLifecycle = useFilePreview({ + const snapshot = useFileHandleSnapshot(handle); + const targetKey = target ? `${target.cwd}:${target.path}` : null; + const { panel, choice } = useFilePanelChoice({ + serverId, targetKey, - liveFileSnapshot: liveFile.snapshot, + path, + fileKind: snapshot.status === "ready" ? snapshot.kind : null, + presentable: isActive, + lineNavigation: location.lineStart ? navigationRevision : null, }); - - useEffect(() => setPreviewMode("preview"), [targetKey]); - - const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle); - const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment); - const panel = resolveFilePreviewPanel({ - preview, - path: location.path, - associations: settings.filePanelAssociations, - }); - const isRenderable = preview?.kind === "text" && panel !== null; - const editable = isEditableTextFile({ - preview, - supportsEditing, - }); - const canTogglePreviewMode = isRenderable && !location.lineStart; - 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], ); -} - -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({ - panel, - serverId, - workspaceRoot, - client, - readTarget, - preview, - liveFile, - onRetryRead, - retryingRead, - retryLabel, - filename, - previewMode, - onPreviewModeChange, - lineCount, - editable, - disconnectedMessage, - errorMessage, - isLoading, - isMobile, - location, - navigationRevision, - imagePreviewUri, -}: { - panel: FilePanel | null; - 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; - previewMode?: "preview" | "source"; - onPreviewModeChange?: (mode: "preview" | "source") => void; - lineCount?: number; - editable: boolean; - disconnectedMessage: string; - errorMessage: string | null; - isLoading: boolean; - isMobile: boolean; - location: WorkspaceFileLocation; - navigationRevision: number; - imagePreviewUri: 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({ - panel, - serverId, - workspaceRoot, - client, - cwd, - path, - preview, - liveFile, - onRetryRead, - retryingRead, - filename, - mode, - onModeChange, - isLoading, - isMobile, - location, - navigationRevision, -}: { - panel: FilePanel | null; - serverId: string; - workspaceRoot: string; - client: DaemonClient; - cwd: string; - path: string; - preview: TextExplorerFile; - liveFile: LiveFileModel; - onRetryRead: () => void; - retryingRead: boolean; - filename: string; - mode?: "preview" | "source"; - onModeChange?: (mode: "preview" | "source") => void; - isLoading: boolean; - isMobile: 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 }); - 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 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 showSource = mode !== "preview"; - - return ( - - - {showSource ? ( - - ) : ( - + + - )} + + ); + } + 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) => ({ @@ -627,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 04d8545f73..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/hook.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useState, useSyncExternalStore } from "react"; -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; -}): FilePreviewLifecycleSnapshot { - const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview)); - const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const { targetKey, liveFileSnapshot } = input; - - useEffect(() => { - model.setSource({ targetKey, liveFileSnapshot }); - }, [liveFileSnapshot, model, targetKey]); - - 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 a934e69b58..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.test.ts +++ /dev/null @@ -1,157 +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 { - 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; -} { - return { targetKey, liveFileSnapshot: snapshot }; -} - -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"), - imageAttachment: 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"), - imageAttachment: 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"), imageAttachment: 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); -} 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 fe69f2227b..0000000000 --- a/packages/app/src/file-pane/preview-lifecycle/model.ts +++ /dev/null @@ -1,190 +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 { ExplorerFile } from "@/stores/session-store"; -import type { LiveFileSnapshot } from "../live-file/model"; - -export interface FilePanePreview { - file: ExplorerFile; - imageAttachment: 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): Promise { - const explorerFile = explorerFileFromReadResult(file); - if (file.kind !== "image") { - return { file: explorerFile, imageAttachment: null }; - } - - const imageAttachment = await persistAttachmentFromBytes({ - 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, imageAttachment }; -} - -/** 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; - - constructor( - private readonly prepare: (file: FileReadResult) => 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 }): 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) { - return; - } - this.preparedSourceKey = source.key; - const generation = ++this.generation; - this.setSnapshot({ - status: "preparing", - ...(retainedPreview ? { preview: retainedPreview } : {}), - }); - void this.prepareSource({ file: source.file, 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; - generation: number; - retainedPreview?: FilePanePreview; - }): Promise { - try { - const preview = await this.prepare(input.file); - 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; - imageAttachment: AttachmentMetadata | null; -} { - const preview = filePreviewFromLifecycle(snapshot); - return preview ?? { file: null, imageAttachment: 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/associations.ts b/packages/app/src/file-panels/associations.ts new file mode 100644 index 0000000000..6280bf5556 --- /dev/null +++ b/packages/app/src/file-panels/associations.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; + +export interface FilePanelAssociations { + /** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */ + associations: Readonly>; + /** Writes the association for a selector key, or forgets it with null. */ + remember(selectorKey: string, panelId: string | null): void; +} + +/** The one reader and writer of the device's file panel associations. */ +export function useFilePanelAssociations(): FilePanelAssociations { + const { settings, updateSettings } = useAppSettings(); + const remember = useCallback( + (selectorKey: string, panelId: string | null) => { + void updateSettings((current) => ({ + filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId), + })); + }, + [updateSettings], + ); + return { associations: settings.filePanelAssociations, remember }; +} + +function withAssociation( + associations: Readonly>, + selectorKey: string, + panelId: string | null, +): Record { + const next = { ...associations }; + if (panelId === null) delete next[selectorKey]; + else next[selectorKey] = panelId; + return next; +} diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx index ba1c4e2cd3..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 { builtinFilePanels } from "./catalog"; +import type { BuiltinFilePanelProps } from "./panels"; +import { SourcePanel } from "./source/panel"; +import type { RenderingFilePanel, SourceFilePanel } from "./catalog"; -interface FilePanelPreviewProps { - content: string; - filePath: string; - workspaceRoot: string; - client: DaemonClient | null; - serverId: string; - imagePreviewUri: string | null; -} - -function MarkdownPanel(props: FilePanelPreviewProps) { +/** 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 }: FilePanelPreviewProps) { +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({ imagePreviewUri }: FilePanelPreviewProps) { - const { t } = useTranslation(); - if (!imagePreviewUri) { - 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 FilePanelPreview({ +export function BuiltinFilePanelView({ panel, ...props -}: FilePanelPreviewProps & { - panel: (typeof builtinFilePanels)[number]; -}) { +}: 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 2052224db2..89a7e14cc7 100644 --- a/packages/app/src/file-panels/catalog.test.ts +++ b/packages/app/src/file-panels/catalog.test.ts @@ -1,86 +1,74 @@ import { describe, expect, it } from "vitest"; -import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog"; -import { resolveFilePanel } from "./resolve"; +import { filePanelCatalog } from "./catalog"; +import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve"; -function filePreviewRenderKind(path: string) { - const { panel } = resolveFilePanel({ - file: { path, kind: "text", size: 100, hasBytes: false }, - candidates: builtinFilePanels, - associations: {}, - }); - return panel ? panel.id.slice("builtin:".length) : null; +const builtins = filePanelCatalog([]); + +const richMarkdown: FilePanelCandidate = { + id: "plugin:notes/rich", + builtin: false, + files: { extensions: ["md"] }, +}; + +function textFile(path: string): FilePanelFile { + return { path, kind: "text" }; } -describe("isRenderedMarkdownFile", () => { - it("detects .md files", () => { - expect(isRenderedMarkdownFile("README.md")).toBe(true); - expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true); - }); +function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) { + return resolveFilePanel({ file, candidates, associations: {} }).candidates.map( + (candidate) => candidate.id, + ); +} - it("detects .markdown files", () => { - expect(isRenderedMarkdownFile("notes.markdown")).toBe(true); - expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true); - }); - - it("does not treat .mdx files as rendered markdown", () => { - expect(isRenderedMarkdownFile("page.mdx")).toBe(false); - }); - - it("does not treat other text files as rendered markdown", () => { - expect(isRenderedMarkdownFile("src/index.ts")).toBe(false); - expect(isRenderedMarkdownFile("README.md.txt")).toBe(false); - expect(isRenderedMarkdownFile("plan.html")).toBe(false); - }); -}); - -describe("filePreviewRenderKind", () => { - it("maps each renderable extension to its kind", () => { - expect(filePreviewRenderKind("README.md")).toBe("markdown"); - expect(filePreviewRenderKind("notes.markdown")).toBe("markdown"); - expect(filePreviewRenderKind("plan.html")).toBe("html"); - expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html"); - expect(filePreviewRenderKind("plan.htm")).toBe("html"); - }); - - it("returns null for files without a rendered preview", () => { - expect(filePreviewRenderKind("src/index.ts")).toBe(null); - expect(filePreviewRenderKind("page.mdx")).toBe(null); - expect(filePreviewRenderKind("index.html.erb")).toBe(null); - }); -}); +function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) { + return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null; +} describe("built-in file panels", () => { + it("opens each rendered extension with its panel and Source after it", () => { + expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]); + expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown"); + expect(panelId(textFile("plan.html"))).toBe("builtin:html"); + expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html"); + expect(panelId(textFile("plan.htm"))).toBe("builtin:html"); + }); + + it("opens every other text file with Source, whatever its name", () => { + for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) { + expect(candidateIds(textFile(path))).toEqual(["builtin:source"]); + } + }); + + it("puts plugin panels after the rendering built-ins and before Source", () => { + expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([ + "builtin:markdown", + richMarkdown.id, + "builtin:source", + ]); + const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]); + expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id); + }); + + it("has no Source for files that are not text", () => { + 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, - }, - candidates: builtinFilePanels, + 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 }, - candidates: builtinFilePanels, + file: { path: "capture.png", kind: "binary" }, + candidates: builtins, associations: {}, }).panel, ).toBeNull(); }, ); - - it("does not impose a new preview size limit on built-in text panels", () => { - expect( - resolveFilePanel({ - file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false }, - candidates: builtinFilePanels, - associations: {}, - }).panel?.id, - ).toBe("builtin:markdown"); - }); }); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts index fd350ec1ff..6ff5cd8d5a 100644 --- a/packages/app/src/file-panels/catalog.ts +++ b/packages/app/src/file-panels/catalog.ts @@ -1,28 +1,43 @@ -import { filePanelExtension, type FilePanelCandidate } from "./resolve"; +import type { FilePanelCandidate } from "./resolve"; -const markdownPanel = { - id: "builtin:markdown", - files: { extensions: ["md", "markdown"] }, - document: "text", - maxBytes: Infinity, -} as const satisfies FilePanelCandidate; - -export const builtinFilePanels = [ - markdownPanel, +const renderingPanels = [ + { + id: "builtin:markdown", + builtin: true, + files: { extensions: ["md", "markdown"] }, + 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[]; -export function isRenderedMarkdownFile(path: string): boolean { - return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path)); +/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */ +export const sourceFilePanel = { + id: "builtin:source", + builtin: true, + files: "all", + fileKind: "text", +} as const satisfies FilePanelCandidate; + +export type RenderingFilePanel = (typeof renderingPanels)[number]; +export type SourceFilePanel = typeof sourceFilePanel; + +/** + * Every panel a host offers, in resolver order: the built-ins that render a file, then plugin + * panels, then Source. Source is last so it is the default only when nothing else claims the file. + */ +export function filePanelCatalog( + pluginPanels: readonly Plugin[], +): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] { + return [...renderingPanels, ...pluginPanels, sourceFilePanel]; } diff --git a/packages/app/src/file-panels/choice/hook.ts b/packages/app/src/file-panels/choice/hook.ts new file mode 100644 index 0000000000..0e51a69c4b --- /dev/null +++ b/packages/app/src/file-panels/choice/hook.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +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 { resolveFilePanel, type FilePanelFileKind } from "../resolve"; +import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model"; +import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry"; + +export type FilePanelChoice = FilePanelChoiceModel; + +/** + * The file tab's panel: resolves it from the catalog, the device associations and this tab's + * choice, and owns the picker and "Open with…" for it. + */ +export function useFilePanelChoice(input: { + serverId: string; + targetKey: string | null; + path: string; + /** 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, fileKind, presentable, lineNavigation } = input; + const { serverId, workspaceId, tabId } = usePaneContext(); + const candidates = useFilePanels(input.serverId); + const { associations, remember } = useFilePanelAssociations(); + const [choice] = useState( + () => new FilePanelChoiceModel({ remember, linePanelId: sourceFilePanel.id }), + ); + // From the subscribed snapshot: React Compiler caches reads off the stable model object. + const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey); + const resolution = useMemo( + () => + fileKind + ? resolveFilePanel({ + file: { path, kind: fileKind }, + candidates, + associations, + choice: chosenPanelId, + }) + : null, + [associations, candidates, chosenPanelId, fileKind, path], + ); + + useEffect(() => { + choice.observe({ targetKey, resolution, presentable, lineNavigation }); + }, [choice, lineNavigation, presentable, resolution, targetKey]); + useEffect( + () => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice), + [choice, serverId, tabId, workspaceId], + ); + + return { panel: resolution?.panel ?? null, choice }; +} + +const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot = { + targetKey: null, + chosenPanelId: null, + picker: null, + openWith: null, +}; +const subscribeNothing = () => () => {}; +const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT; + +/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */ +export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null { + const { serverId, workspaceId, tabId } = identity; + const getSnapshot = useCallback( + () => getFilePanelChoice({ serverId, workspaceId, tabId }), + [serverId, tabId, workspaceId], + ); + return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot); +} + +export function useFilePanelChoiceSnapshot( + choice: FilePanelChoice | null, +): FilePanelChoiceSnapshot { + return useSyncExternalStore( + choice?.subscribe ?? subscribeNothing, + choice?.getSnapshot ?? getNoChoiceSnapshot, + choice?.getSnapshot ?? getNoChoiceSnapshot, + ); +} diff --git a/packages/app/src/file-panels/choice/model.test.ts b/packages/app/src/file-panels/choice/model.test.ts new file mode 100644 index 0000000000..e9e5e83042 --- /dev/null +++ b/packages/app/src/file-panels/choice/model.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { filePanelCatalog, sourceFilePanel } from "../catalog"; +import { resolveFilePanel, type FilePanelCandidate } from "../resolve"; +import { chosenPanelFor, FilePanelChoiceModel } from "./model"; + +const rich: FilePanelCandidate = { + id: "plugin:notes/rich", + builtin: false, + files: { extensions: ["md", "markdown"] }, +}; +const markdown = "builtin:markdown"; +const source = sourceFilePanel.id; + +/** A file tab: resolves against the shared associations and feeds the result to its model. */ +function openTab( + associations: Record, + candidates: FilePanelCandidate[] = [...filePanelCatalog([rich])], + options: { presentable?: boolean; path?: string; lineNavigation?: number | null } = {}, +) { + const model = new FilePanelChoiceModel({ + remember(selectorKey, panelId) { + if (panelId === null) delete associations[selectorKey]; + else associations[selectorKey] = panelId; + }, + linePanelId: source, + }); + let targetKey = `/repo:${options.path ?? "notes.md"}`; + let presentable = options.presentable ?? true; + let lineNavigation = options.lineNavigation ?? null; + const resolve = () => + resolveFilePanel({ + file: { path: targetKey.split(":")[1]!, kind: "text" }, + candidates, + associations, + choice: chosenPanelFor(model.getSnapshot(), targetKey), + }); + // Like the hook: observe, then render what the model's choice resolves to. + const render = () => { + model.observe({ targetKey, resolution: resolve(), presentable, lineNavigation }); + return resolve().panel?.id ?? null; + }; + render(); + return { + model, + render, + panel: () => render(), + picker: () => model.getSnapshot().picker, + retarget(path: string) { + targetKey = `/repo:${path}`; + return render(); + }, + setPresentable(next: boolean) { + presentable = next; + return render(); + }, + navigateToLine(revision: number) { + lineNavigation = revision; + return render(); + }, + }; +} + +describe("FilePanelChoiceModel", () => { + it("asks once per tab open over the first candidate, and dismissing keeps it", () => { + const associations = {}; + const tab = openTab(associations); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toMatchObject({ + selectorKey: "ext:md", + selectedPanelId: markdown, + remember: false, + }); + expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([ + markdown, + rich.id, + source, + ]); + tab.model.dismissPicker(); + expect(tab.picker()).toBeNull(); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toBeNull(); + expect(associations).toEqual({}); + + expect(openTab(associations).picker()).not.toBeNull(); + }); + + it("never asks between built-in panels, and switches between them", () => { + const tab = openTab({}, [...filePanelCatalog([])]); + expect(tab.panel()).toBe(markdown); + expect(tab.picker()).toBeNull(); + expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([ + markdown, + source, + ]); + tab.model.openWith(source); + expect(tab.panel()).toBe(source); + expect(tab.picker()).toBeNull(); + }); + + it("opens a file only Source claims with Source and nothing to choose", () => { + const tab = openTab({}, [...filePanelCatalog([rich])], { path: "index.ts" }); + expect(tab.panel()).toBe(source); + expect(tab.picker()).toBeNull(); + expect(tab.model.getSnapshot().openWith).toBeNull(); + }); + + it("remembers the choice when Don't ask me again is checked", () => { + const associations: Record = {}; + const tab = openTab(associations); + tab.model.selectInPicker(rich.id); + tab.model.setRemember(true); + tab.model.confirmPicker(); + expect(tab.panel()).toBe(rich.id); + expect(associations).toEqual({ "ext:md": rich.id }); + + const next = openTab(associations); + expect(next.panel()).toBe(rich.id); + expect(next.picker()).toBeNull(); + }); + + it("uses an unremembered choice for this tab only and asks again on the next open", () => { + const associations = {}; + const tab = openTab(associations); + tab.model.selectInPicker(rich.id); + tab.model.confirmPicker(); + expect(tab.panel()).toBe(rich.id); + expect(associations).toEqual({}); + + const next = openTab(associations); + expect(next.panel()).toBe(markdown); + expect(next.picker()).not.toBeNull(); + }); + + it("does not ask with an association or while the tab is in the background", () => { + expect(openTab({ "ext:md": rich.id }).picker()).toBeNull(); + expect(openTab({ "ext:md": source }).panel()).toBe(source); + + const background = openTab({}, undefined, { presentable: false }); + expect(background.picker()).toBeNull(); + background.setPresentable(true); + expect(background.picker()).not.toBeNull(); + }); + + it("opens Source for a line target without asking, and again on each new one", () => { + const tab = openTab({}, undefined, { lineNavigation: 1 }); + expect(tab.panel()).toBe(source); + expect(tab.picker()).toBeNull(); + + tab.model.openWith(rich.id); + expect(tab.panel()).toBe(rich.id); + expect(tab.navigateToLine(1)).toBe(rich.id); + expect(tab.navigateToLine(2)).toBe(source); + }); + + it("opens Source for the same line target in a retargeted tab", () => { + const tab = openTab({}, undefined, { lineNavigation: 1 }); + tab.model.openWith(markdown); + expect(tab.retarget("other.md")).toBe(source); + }); + + it("starts a new open when the tab is retargeted", () => { + const tab = openTab({}); + tab.model.selectInPicker(rich.id); + tab.model.confirmPicker(); + expect(tab.retarget("other.md")).toBe(markdown); + expect(tab.picker()).not.toBeNull(); + }); + + it("switches with Open with and toggles Always use without moving the tab", () => { + const associations: Record = {}; + const tab = openTab(associations); + tab.model.dismissPicker(); + expect(tab.model.getSnapshot().openWith).toMatchObject({ + activePanelId: markdown, + remembered: false, + }); + + tab.model.openWith(rich.id); + expect(tab.panel()).toBe(rich.id); + tab.model.toggleAlwaysUse(); + expect(associations).toEqual({ "ext:md": rich.id }); + tab.render(); + expect(tab.model.getSnapshot().openWith?.remembered).toBe(true); + + tab.model.openWith(markdown); + expect(tab.panel()).toBe(markdown); + expect(tab.model.getSnapshot().openWith?.remembered).toBe(false); + tab.model.openWith(rich.id); + tab.render(); + tab.model.toggleAlwaysUse(); + expect(associations).toEqual({}); + expect(tab.panel()).toBe(rich.id); + }); + + it("asks when a plugin panel arrives after the file first rendered", () => { + const candidates: FilePanelCandidate[] = [...filePanelCatalog([])]; + const tab = openTab({}, candidates); + expect(tab.picker()).toBeNull(); + candidates.splice(1, 0, rich); + tab.render(); + expect(tab.picker()).not.toBeNull(); + }); +}); diff --git a/packages/app/src/file-panels/choice/model.ts b/packages/app/src/file-panels/choice/model.ts new file mode 100644 index 0000000000..52cc4072ce --- /dev/null +++ b/packages/app/src/file-panels/choice/model.ts @@ -0,0 +1,195 @@ +import { asksForFilePanel, type FilePanelCandidate, type FilePanelResolution } from "../resolve"; + +/** What the file tab currently resolves; `resolution` is null until the file has been read. */ +export interface FilePanelChoiceSource { + targetKey: string | null; + resolution: FilePanelResolution | null; + /** The tab is on screen, so a picker shown now is seen over the panel it chooses for. */ + presentable: boolean; + /** Identifies the tab's latest request to show a line, or null when it targets no line. */ + lineNavigation: number | null; +} + +export interface FilePanelPickerState { + selectorKey: string; + candidates: readonly Candidate[]; + selectedPanelId: string; + remember: boolean; +} + +export interface FilePanelOpenWithState { + selectorKey: string; + candidates: readonly Candidate[]; + activePanelId: string; + /** The active panel is the one remembered for `selectorKey`. */ + remembered: boolean; +} + +export interface FilePanelChoiceSnapshot { + targetKey: string | null; + /** The panel picked for this tab open. Resolution prefers it over the association. */ + chosenPanelId: string | null; + picker: FilePanelPickerState | null; + /** Present only when the file has more than one candidate; drives the bar's panel switcher. */ + openWith: FilePanelOpenWithState | null; +} + +export interface FilePanelChoiceDeps { + /** Writes (or clears, with null) the device-wide association for a selector key. */ + remember(selectorKey: string, panelId: string | null): void; + /** The panel a line target opens: Source, the panel that shows lines. */ + linePanelId: string; +} + +/** The tab choice that applies to `targetKey`; a choice never outlives its open. */ +export function chosenPanelFor( + snapshot: FilePanelChoiceSnapshot, + targetKey: string | null, +): string | null { + return snapshot.targetKey === targetKey ? snapshot.chosenPanelId : null; +} + +const EMPTY: FilePanelChoiceSnapshot = { + targetKey: null, + chosenPanelId: null, + picker: null, + openWith: null, +}; + +/** + * Which file panel one file tab shows, and whether to ask. + * + * A tab open is one target in one tab: the picker is offered at most once per open, the first + * time the tab is on screen with a plugin panel among several candidates and nothing remembered. + * Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does. + * Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me + * again" and "Always use" write the association every later open reads. + */ +export class FilePanelChoiceModel { + private readonly listeners = new Set<() => void>(); + private snapshot: FilePanelChoiceSnapshot = EMPTY; + private asked = false; + private openedLineNavigation: number | null = null; + + constructor(private readonly deps: FilePanelChoiceDeps) {} + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): FilePanelChoiceSnapshot => this.snapshot; + + observe(source: FilePanelChoiceSource): void { + let current = this.snapshot; + if (source.targetKey !== current.targetKey) { + this.asked = false; + this.openedLineNavigation = null; + current = { ...EMPTY, targetKey: source.targetKey }; + } + if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) { + this.openedLineNavigation = source.lineNavigation; + current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null }; + } + const openWith = stableOpenWith(current.openWith, openWithState(source.resolution)); + let picker = openWith ? current.picker : null; + // Decided once per open, the first time there is something to ask about on screen. An + // association forgotten while the tab stays open is asked about on the next open. + if (!this.asked && source.presentable && openWith && asksForFilePanel(openWith.candidates)) { + this.asked = true; + const remembered = Boolean(source.resolution?.associatedPanelId); + if (!remembered && current.chosenPanelId === null) { + picker = { + selectorKey: openWith.selectorKey, + candidates: openWith.candidates, + selectedPanelId: openWith.activePanelId, + remember: false, + }; + } + } + this.publish({ ...current, openWith, picker }); + } + + selectInPicker(panelId: string): void { + const picker = this.snapshot.picker; + if (!picker) return; + this.publish({ ...this.snapshot, picker: { ...picker, selectedPanelId: panelId } }); + } + + setRemember(remember: boolean): void { + const picker = this.snapshot.picker; + if (!picker) return; + this.publish({ ...this.snapshot, picker: { ...picker, remember } }); + } + + confirmPicker(): void { + const picker = this.snapshot.picker; + if (!picker) return; + if (picker.remember) this.deps.remember(picker.selectorKey, picker.selectedPanelId); + this.publish({ ...this.snapshot, chosenPanelId: picker.selectedPanelId, picker: null }); + } + + /** Keeps whatever the tab shows now; the next open asks again. */ + dismissPicker(): void { + if (!this.snapshot.picker) return; + this.publish({ ...this.snapshot, picker: null }); + } + + openWith(panelId: string): void { + this.publish({ ...this.snapshot, chosenPanelId: panelId, picker: null }); + } + + /** Remembers the active panel, or forgets it when it already is the remembered one. */ + toggleAlwaysUse(): void { + const openWith = this.snapshot.openWith; + if (!openWith) return; + if (!openWith.remembered) { + this.deps.remember(openWith.selectorKey, openWith.activePanelId); + return; + } + // Forgetting must not move the tab off the panel it is showing. + this.publish({ ...this.snapshot, chosenPanelId: openWith.activePanelId }); + this.deps.remember(openWith.selectorKey, null); + } + + private publish(next: FilePanelChoiceSnapshot): void { + const current = this.snapshot; + if ( + current.targetKey === next.targetKey && + current.chosenPanelId === next.chosenPanelId && + current.picker === next.picker && + current.openWith === next.openWith + ) { + return; + } + this.snapshot = next; + for (const listener of this.listeners) listener(); + } +} + +function openWithState( + resolution: FilePanelResolution | null, +): FilePanelOpenWithState | null { + if (!resolution?.panel || resolution.candidates.length < 2) return null; + return { + selectorKey: resolution.selectorKey, + candidates: resolution.candidates, + activePanelId: resolution.panel.id, + remembered: resolution.associatedPanelId === resolution.panel.id, + }; +} + +/** Keeps the published object when nothing in it changed, so subscribers do not re-render. */ +function stableOpenWith( + current: FilePanelOpenWithState | null, + next: FilePanelOpenWithState | null, +): FilePanelOpenWithState | null { + if (!current || !next) return next; + const same = + current.selectorKey === next.selectorKey && + current.activePanelId === next.activePanelId && + current.remembered === next.remembered && + current.candidates.length === next.candidates.length && + current.candidates.every((candidate, index) => candidate === next.candidates[index]); + return same ? current : next; +} diff --git a/packages/app/src/file-panels/choice/open-with.tsx b/packages/app/src/file-panels/choice/open-with.tsx new file mode 100644 index 0000000000..b793b4f445 --- /dev/null +++ b/packages/app/src/file-panels/choice/open-with.tsx @@ -0,0 +1,175 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import { Ellipsis } from "lucide-react-native"; +import { + MenuItem, + MenuSeparator, + MenuSubTrigger, + type MenuPageDefinition, +} from "@/components/ui/menu"; +import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; +import { PluginIconView } from "@/plugins/icons"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import { useFilePanelChoiceSnapshot, useTabFilePanelChoice, type FilePanelChoice } from "./hook"; +import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./presentation"; + +const OPEN_WITH_PAGE_ID = "filePanelOpenWith"; +const MENU_ICON_SIZE = 16; + +const ThemedEllipsis = withUnistyles(Ellipsis); +const ThemedPanelIcon = withUnistyles(PluginIconView); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const NO_PAGES: readonly MenuPageDefinition[] = []; + +export interface FilePanelOpenWithMenu { + /** The "Open with…" row, or null when the file has one panel or none. */ + trigger: ReactElement | null; + /** The page the row opens; pass to the menu surface. */ + pages: readonly MenuPageDefinition[]; +} + +/** "Open with…" for a file tab's context menu, found by the tab's identity. */ +export function useFilePanelOpenWithMenu( + identity: PanelInstanceIdentity, + testIDBase: string, +): FilePanelOpenWithMenu { + return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase); +} + +/** + * The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless + * the file has more than one panel. + */ +export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) { + const { t } = useTranslation(); + const compact = useIsCompactFormFactor(); + const { openWith } = useFilePanelChoiceSnapshot(choice); + if (!openWith) return null; + const label = t("panels.file.filePanels.actions"); + return ( + + + + + + + + + ); +} + +function useOpenWithMenu( + choice: FilePanelChoice | null, + testIDBase: string, +): FilePanelOpenWithMenu { + const { t } = useTranslation(); + const { openWith } = useFilePanelChoiceSnapshot(choice); + return useMemo(() => { + if (!choice || !openWith) return { trigger: null, pages: NO_PAGES }; + const title = t("panels.file.filePanels.openWith"); + return { + trigger: ( + + {title} + + ), + pages: [ + { + id: OPEN_WITH_PAGE_ID, + title, + content: ( + <> + {openWith.candidates.map((panel) => ( + + ))} + + + + ), + }, + ], + }; + }, [choice, openWith, t, testIDBase]); +} + +function OpenWithItem({ + choice, + panel, + label, + selected, + testID, +}: { + choice: FilePanelChoice; + panel: FilePanel; + label: string; + selected: boolean; + testID: string; +}) { + const handleSelect = useCallback(() => choice.openWith(panel.id), [choice, panel.id]); + const leading = useMemo( + () => ( + + ), + [panel], + ); + return ( + + {label} + + ); +} + +function AlwaysUseItem({ + choice, + label, + selected, + testID, +}: { + choice: FilePanelChoice; + label: string; + selected: boolean; + testID: string; +}) { + const handleSelect = useCallback(() => choice.toggleAlwaysUse(), [choice]); + return ( + + {label} + + ); +} diff --git a/packages/app/src/file-panels/choice/picker.tsx b/packages/app/src/file-panels/choice/picker.tsx new file mode 100644 index 0000000000..61d5aba8b6 --- /dev/null +++ b/packages/app/src/file-panels/choice/picker.tsx @@ -0,0 +1,233 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check } from "lucide-react-native"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { PluginIconView } from "@/plugins/icons"; +import { settingsStyles } from "@/styles/settings"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import type { FilePanelPickerState } from "./model"; +import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook"; +import { filePanelIcon, filePanelOptionLabel, filePanelPickerTitle } from "./presentation"; + +const ThemedCheck = withUnistyles(Check); +const ThemedPanelIcon = withUnistyles(PluginIconView); +const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The first-open question for a file with several panels, over the panel already showing. */ +export function FilePanelPicker({ choice }: { choice: FilePanelChoice }) { + const { t } = useTranslation(); + const { picker } = useFilePanelChoiceSnapshot(choice); + // The sheet animates out after the model has closed the picker; keep drawing what it asked. + const [shown, setShown] = useState(picker); + if (picker && picker !== shown) setShown(picker); + const content = picker ?? shown; + + const dismiss = useCallback(() => choice.dismissPicker(), [choice]); + const confirm = useCallback(() => choice.confirmPicker(), [choice]); + const toggleRemember = useCallback( + () => choice.setRemember(!choice.getSnapshot().picker?.remember), + [choice], + ); + const header = useMemo( + () => ({ title: content ? filePanelPickerTitle(t, content.selectorKey) : "" }), + [content, t], + ); + const footer = useMemo( + () => ( + + ), + [confirm, t], + ); + + return ( + + {content ? ( + + ) : null} + + ); +} + +const PICKER_SNAP_POINTS = ["60%", "90%"]; + +function PickerBody({ + choice, + picker, + onToggleRemember, +}: { + choice: FilePanelChoice; + picker: FilePanelPickerState; + onToggleRemember: () => void; +}) { + const { t } = useTranslation(); + const select = useCallback((panelId: string) => choice.selectInPicker(panelId), [choice]); + return ( + <> + + {picker.candidates.map((panel, index) => ( + 0} + onSelect={select} + /> + ))} + + + + ); +} + +function PanelRadioRow({ + panel, + label, + selected, + withBorder, + onSelect, +}: { + panel: FilePanel; + label: string; + selected: boolean; + withBorder: boolean; + onSelect: (panelId: string) => void; +}) { + const handlePress = useCallback(() => onSelect(panel.id), [onSelect, panel.id]); + const accessibilityState = useMemo(() => ({ checked: selected }), [selected]); + const rowStyle = useMemo( + () => [settingsStyles.row, withBorder ? settingsStyles.rowBorder : null, styles.optionRow], + [withBorder], + ); + return ( + + + {selected ? : null} + + + + {label} + + + ); +} + +function CheckboxRow({ + label, + checked, + onToggle, +}: { + label: string; + checked: boolean; + onToggle: () => void; +}) { + const accessibilityState = useMemo(() => ({ checked }), [checked]); + return ( + + + {checked ? : null} + + {label} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + footerButton: { + flex: 1, + }, + optionRow: { + justifyContent: "flex-start", + gap: theme.spacing[3], + }, + radio: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + radioSelected: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, + radioDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + }, + checkboxRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + }, + checkbox: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + checkboxChecked: { + width: 18, + height: 18, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.accent, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, +})); diff --git a/packages/app/src/file-panels/choice/presentation.ts b/packages/app/src/file-panels/choice/presentation.ts new file mode 100644 index 0000000000..9c0de9170b --- /dev/null +++ b/packages/app/src/file-panels/choice/presentation.ts @@ -0,0 +1,52 @@ +import type { TFunction } from "i18next"; +import type { PluginIcon } from "@getpaseo/plugin/client"; +import type { FilePanel } from "../panels"; +import { parseFilePanelSelectorKey } from "../resolve"; + +const BUILTIN_PRESENTATION = { + "builtin:markdown": { title: "markdown", icon: "FileText" }, + "builtin:html": { title: "html", icon: "FileCode" }, + "builtin:image": { title: "image", icon: "Image" }, + "builtin:source": { title: "source", icon: "Code" }, +} as const; + +export function filePanelIcon(panel: FilePanel): PluginIcon { + return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon; +} + +/** "Markdown", "Rich Markdown": the panel switcher's label. */ +export function filePanelTitle(t: TFunction, panel: FilePanel): string { + return panel.builtin + ? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`) + : panel.contribution.title; +} + +/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */ +export function filePanelOptionLabel(t: TFunction, panel: FilePanel): string { + return t("panels.file.filePanels.option", { + title: filePanelTitle(t, panel), + owner: panel.builtin ? t("panels.file.filePanels.builtin.owner") : panel.plugin.id, + }); +} + +/** ".md" for an extension key, "Dockerfile" for a filename key. */ +export function filePanelSelectorLabel(selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" ? selector.value : `.${selector.value}`; +} + +/** "Open .md files with" for an extension key, "Open Dockerfile with" for a filename key. */ +export function filePanelPickerTitle(t: TFunction, selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" + ? t("panels.file.filePanels.pickerTitleFilename", { filename: selector.value }) + : t("panels.file.filePanels.pickerTitleExtension", { extension: selector.value }); +} + +/** "Always use for .md", "Always use for Dockerfile". */ +export function filePanelAlwaysUseLabel(t: TFunction, selectorKey: string): string { + const selector = parseFilePanelSelectorKey(selectorKey); + return selector.kind === "name" + ? t("panels.file.filePanels.alwaysUseFilename", { filename: selector.value }) + : t("panels.file.filePanels.alwaysUseExtension", { extension: selector.value }); +} diff --git a/packages/app/src/file-panels/choice/registry.ts b/packages/app/src/file-panels/choice/registry.ts new file mode 100644 index 0000000000..43a73984b6 --- /dev/null +++ b/packages/app/src/file-panels/choice/registry.ts @@ -0,0 +1,42 @@ +import { + buildPanelInstanceKey, + type PanelInstanceIdentity, +} from "@/panels/panel-instance-attributes"; +import type { FilePanel } from "../panels"; +import type { FilePanelChoiceModel } from "./model"; + +/** + * The open file tabs' choice models, so tab chrome outside the pane (the tab's context menu) + * reads and drives the same choice the pane renders. + */ +const models = new Map>(); +const listeners = new Set<() => void>(); + +export function registerFilePanelChoice( + identity: PanelInstanceIdentity, + model: FilePanelChoiceModel, +): () => void { + const key = buildPanelInstanceKey(identity); + models.set(key, model); + notify(); + return () => { + if (models.get(key) !== model) return; + models.delete(key); + notify(); + }; +} + +export function getFilePanelChoice( + identity: PanelInstanceIdentity, +): FilePanelChoiceModel | null { + return models.get(buildPanelInstanceKey(identity)) ?? null; +} + +export function subscribeFilePanelChoices(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notify(): void { + for (const listener of listeners) listener(); +} diff --git a/packages/app/src/file-panels/choice/switcher-layout.test.ts b/packages/app/src/file-panels/choice/switcher-layout.test.ts new file mode 100644 index 0000000000..196f03bf17 --- /dev/null +++ b/packages/app/src/file-panels/choice/switcher-layout.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { FilePanelOpenWithState } from "./model"; +import { filePanelSwitcherState } from "./switcher-layout"; + +function openWith(count: number): FilePanelOpenWithState<{ id: string }> { + const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` })); + return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false }; +} + +describe("filePanelSwitcherState", () => { + it("shows nothing when the file has one panel or none", () => { + expect(filePanelSwitcherState(null, { compact: false })).toBeNull(); + }); + + it("sets up to three panels side by side and more behind a menu", () => { + const wide = { compact: false }; + expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented"); + expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented"); + expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({ + layout: "menu", + activePanelId: "panel-1", + candidates: openWith(4).candidates, + }); + }); + + it("sets two panels side by side on a compact screen and more behind a menu", () => { + const compact = { compact: true }; + expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented"); + expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu"); + }); +}); diff --git a/packages/app/src/file-panels/choice/switcher-layout.ts b/packages/app/src/file-panels/choice/switcher-layout.ts new file mode 100644 index 0000000000..16afae0fe9 --- /dev/null +++ b/packages/app/src/file-panels/choice/switcher-layout.ts @@ -0,0 +1,24 @@ +import type { FilePanelOpenWithState } from "./model"; + +/** Up to this many panels sit side by side in the bar; more go behind a menu. */ +const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 }; + +export interface FilePanelSwitcherState { + layout: "segmented" | "menu"; + candidates: readonly Candidate[]; + activePanelId: string; +} + +/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */ +export function filePanelSwitcherState( + openWith: FilePanelOpenWithState | null, + { compact }: { compact: boolean }, +): FilePanelSwitcherState | null { + if (!openWith) return null; + const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide; + return { + layout: openWith.candidates.length > limit ? "menu" : "segmented", + candidates: openWith.candidates, + activePanelId: openWith.activePanelId, + }; +} diff --git a/packages/app/src/file-panels/choice/switcher.tsx b/packages/app/src/file-panels/choice/switcher.tsx new file mode 100644 index 0000000000..75aef62727 --- /dev/null +++ b/packages/app/src/file-panels/choice/switcher.tsx @@ -0,0 +1,138 @@ +import { useCallback, useMemo } from "react"; +import { Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import { ChevronDown } from "lucide-react-native"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { MenuItem } from "@/components/ui/menu"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { + ToolbarLabelTriggerIcon, + isToolbarLabelTriggerHighlighted, + toolbarLabelTriggerStyle, + toolbarLabelTriggerTextStyle, +} from "@/components/ui/toolbar-label-trigger"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { PluginIconView } from "@/plugins/icons"; +import type { Theme } from "@/styles/theme"; +import type { FilePanel } from "../panels"; +import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook"; +import { filePanelIcon, filePanelTitle } from "./presentation"; +import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout"; + +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedPanelIcon = withUnistyles(PluginIconView); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */ +export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) { + const { openWith } = useFilePanelChoiceSnapshot(choice); + const compact = useIsCompactFormFactor(); + const switcher = filePanelSwitcherState(openWith, { compact }); + const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]); + if (!switcher) return null; + return switcher.layout === "segmented" ? ( + + ) : ( + + ); +} + +interface SwitcherProps { + switcher: FilePanelSwitcherState; + onSelect(panelId: string): void; +} + +function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) { + const { t } = useTranslation(); + const options = useMemo( + () => + switcher.candidates.map((panel) => ({ + value: panel.id, + label: filePanelTitle(t, panel), + testID: `file-panel-switcher-${panel.id}`, + })), + [switcher.candidates, t], + ); + return ( + + ); +} + +function MenuSwitcher({ switcher, onSelect }: SwitcherProps) { + const { t } = useTranslation(); + const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId); + const activeTitle = active ? filePanelTitle(t, active) : ""; + return ( + + + {(state) => ( + <> + + {activeTitle} + + + + + + )} + + + {switcher.candidates.map((panel) => ( + + ))} + + + ); +} + +function SwitcherMenuItem({ + panel, + selected, + onSelect, +}: { + panel: FilePanel; + selected: boolean; + onSelect(panelId: string): void; +}) { + const { t } = useTranslation(); + const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]); + const leading = useMemo( + () => , + [panel], + ); + return ( + + {filePanelTitle(t, panel)} + + ); +} diff --git a/packages/app/src/file-panels/document-frame/index.tsx b/packages/app/src/file-panels/document-frame/index.tsx new file mode 100644 index 0000000000..c931ef5f08 --- /dev/null +++ b/packages/app/src/file-panels/document-frame/index.tsx @@ -0,0 +1,98 @@ +import { useCallback, useMemo, useRef } from "react"; +import { Platform, Text, View } from "react-native"; +import { WebView } from "react-native-webview"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; + +const frameStyle = { flex: 1, backgroundColor: "white" }; +const originWhitelist = ["*"]; + +/** What Android's WebView shows inline. It has no viewer for PDF or other documents. */ +const ANDROID_INLINE_EXTENSIONS = new Set([ + "html", + "htm", + "xhtml", + "xml", + "svg", + "txt", + "json", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "avif", +]); + +export function DocumentFrame({ url, testID }: { url: string; testID?: string }) { + const source = useMemo(() => ({ uri: url }), [url]); + const loaded = useRef(null); + const allowDocument = useCallback( + (request: { url: string }) => { + if (!url.startsWith("file:") || request.url !== url || loaded.current === url) return false; + loaded.current = url; + return true; + }, + [url], + ); + if (!url.startsWith("file:")) return null; + // The host names a file URL after the file, so its extension is the document's type. + const extension = urlExtension(url); + if (Platform.OS === "android" && !ANDROID_INLINE_EXTENSIONS.has(extension)) { + return ; + } + return ( + + ); +} + +function UnsupportedDocument({ extension, testID }: { extension: string; testID?: string }) { + const { t } = useTranslation(); + return ( + + + {t("panels.file.documentUnsupported", { type: extension.toUpperCase() })} + + + ); +} + +function urlExtension(url: string): string { + const path = url.split(/[?#]/)[0]; + const name = path.slice(path.lastIndexOf("/") + 1); + const dot = name.lastIndexOf("."); + return dot < 0 ? "" : name.slice(dot + 1).toLowerCase(); +} + +const styles = StyleSheet.create((theme) => ({ + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + backgroundColor: theme.colors.surface0, + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-panels/document-frame/index.web.tsx b/packages/app/src/file-panels/document-frame/index.web.tsx new file mode 100644 index 0000000000..a4cf9ac83d --- /dev/null +++ b/packages/app/src/file-panels/document-frame/index.web.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { documentFrameSandbox } from "./sandbox"; + +const frameStyle = { flex: 1, minHeight: 0, border: "none", backgroundColor: "white" } as const; + +interface ResolvedDocument { + url: string; + sandbox: string | null; +} + +export function DocumentFrame({ url, testID }: { url: string; testID?: string }) { + const [document, setDocument] = useState(null); + + useEffect(() => { + if (!url.startsWith("blob:")) return; + const lifetime = new AbortController(); + void fetch(url, { signal: lifetime.signal }) + .then(async (response) => { + const sandbox = documentFrameSandbox(response.headers.get("content-type")); + await response.body?.cancel(); + if (!lifetime.signal.aborted) setDocument({ url, sandbox }); + return undefined; + }) + .catch(() => { + if (!lifetime.signal.aborted) setDocument({ url, sandbox: "" }); + }); + return () => lifetime.abort(); + }, [url]); + + if (!url.startsWith("blob:") || document?.url !== url) return null; + return ( +