From 18d6d00d667583c82102362e397d67f10eeda226 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 13:39:56 +0200 Subject: [PATCH] feat(plugins): file panels Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor, which claims every text file and is always the last candidate. The panel is the tab body; the bar shows the document's status and a switcher between the file's panels (side by side up to three, two on a phone, a menu beyond that). Paseo asks which panel opens a file only when a plugin panel is among several candidates, once per open, and remembers the answer per extension or exact filename when told to. Open with in the tab menu and the switcher change the tab's panel; Always use remembers it. Opening a file at a line switches the tab to Source. Panels read and write the host's document, so dirty state, autosave and conflicts stay Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer. Replaces the file tab's Preview/Source mode toggle. --- packages/app/e2e/browser/file-editing.spec.ts | 30 +-- packages/app/e2e/browser/folder-links.spec.ts | 5 +- .../browser/plugin-file-panel-picker.spec.ts | 156 ++++++++++++ .../e2e/browser/plugin-file-panels.spec.ts | 185 ++++++++++++++ .../helpers/markdown-file-panel-plugin.ts | 77 ++++++ packages/app/src/file-pane/bar.tsx | 83 +++---- .../app/src/file-pane/editor/view.web.tsx | 7 +- packages/app/src/file-pane/pane.tsx | 231 +++++++++-------- .../src/file-pane/preview-lifecycle/hook.ts | 8 +- .../file-pane/preview-lifecycle/model.test.ts | 50 +++- .../src/file-pane/preview-lifecycle/model.ts | 59 ++++- packages/app/src/file-panels/associations.ts | 34 +++ packages/app/src/file-panels/builtin.tsx | 22 +- packages/app/src/file-panels/catalog.test.ts | 114 ++++----- packages/app/src/file-panels/catalog.ts | 50 +++- packages/app/src/file-panels/choice/hook.ts | 80 ++++++ .../app/src/file-panels/choice/model.test.ts | 205 +++++++++++++++ packages/app/src/file-panels/choice/model.ts | 195 +++++++++++++++ .../app/src/file-panels/choice/open-with.tsx | 175 +++++++++++++ .../app/src/file-panels/choice/picker.tsx | 233 ++++++++++++++++++ .../src/file-panels/choice/presentation.ts | 52 ++++ .../app/src/file-panels/choice/registry.ts | 42 ++++ .../choice/switcher-layout.test.ts | 31 +++ .../src/file-panels/choice/switcher-layout.ts | 24 ++ .../app/src/file-panels/choice/switcher.tsx | 138 +++++++++++ .../src/file-panels/document-frame/index.tsx | 39 +++ .../file-panels/document-frame/index.web.tsx | 41 +++ .../document-frame/sandbox.test.ts | 16 ++ .../src/file-panels/document-frame/sandbox.ts | 9 + packages/app/src/file-panels/document.test.ts | 73 ++++++ packages/app/src/file-panels/document.ts | 16 ++ .../app/src/file-panels/html-preview.web.tsx | 2 +- packages/app/src/file-panels/index.ts | 38 ++- packages/app/src/file-panels/panels.ts | 45 ++++ packages/app/src/file-panels/plugin-host.tsx | 94 +++++++ packages/app/src/file-panels/resolve.test.ts | 127 +++++++++- packages/app/src/file-panels/resolve.ts | 112 ++++++++- packages/app/src/file-panels/settings/hook.ts | 24 ++ .../app/src/file-panels/settings/rows.test.ts | 115 +++++++++ packages/app/src/file-panels/settings/rows.ts | 78 ++++++ packages/app/src/file-panels/view.tsx | 38 +++ packages/app/src/i18n/resources/ar.ts | 29 ++- packages/app/src/i18n/resources/en.ts | 29 ++- packages/app/src/i18n/resources/es.ts | 29 ++- packages/app/src/i18n/resources/fr.ts | 29 ++- packages/app/src/i18n/resources/ja.ts | 29 ++- packages/app/src/i18n/resources/ko.ts | 29 ++- packages/app/src/i18n/resources/pt-BR.ts | 29 ++- packages/app/src/i18n/resources/ru.ts | 29 ++- packages/app/src/i18n/resources/zh-CN.ts | 29 ++- .../app/src/plugins/buttons/model.test.ts | 1 + .../command-center/contributions.test.ts | 1 + packages/app/src/plugins/evaluate.test.ts | 56 +++++ packages/app/src/plugins/evaluate.ts | 55 +++++ .../plugins/installation-provider.test.tsx | 1 + packages/app/src/plugins/react-native/ui.ts | 2 + .../src/plugins/registry-requirements.test.ts | 20 ++ packages/app/src/plugins/registry.ts | 1 + .../app/src/plugins/sidebar-groups.test.ts | 1 + .../src/plugins/surface-contribution.test.ts | 1 + packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + packages/app/src/plugins/types.ts | 6 + .../workspace-panels/resolution.test.ts | 1 + .../compact-workspace-header/tab-switcher.tsx | 8 +- .../workspace/workspace-desktop-tabs-row.tsx | 34 ++- .../workspace/workspace-tab-menu.test.ts | 1 + .../screens/workspace/workspace-tab-menu.ts | 6 + .../workspace-tab-trailing-accessory.tsx | 21 +- packages/plugin/package.json | 5 +- packages/plugin/src/boundaries.test.ts | 4 +- packages/plugin/src/client/contracts.ts | 43 ++++ packages/plugin/src/client/index.ts | 4 + packages/plugin/src/client/ui.ts | 3 + 74 files changed, 3348 insertions(+), 343 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-file-panel-picker.spec.ts create mode 100644 packages/app/e2e/browser/plugin-file-panels.spec.ts create mode 100644 packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts create mode 100644 packages/app/src/file-panels/associations.ts create mode 100644 packages/app/src/file-panels/choice/hook.ts create mode 100644 packages/app/src/file-panels/choice/model.test.ts create mode 100644 packages/app/src/file-panels/choice/model.ts create mode 100644 packages/app/src/file-panels/choice/open-with.tsx create mode 100644 packages/app/src/file-panels/choice/picker.tsx create mode 100644 packages/app/src/file-panels/choice/presentation.ts create mode 100644 packages/app/src/file-panels/choice/registry.ts create mode 100644 packages/app/src/file-panels/choice/switcher-layout.test.ts create mode 100644 packages/app/src/file-panels/choice/switcher-layout.ts create mode 100644 packages/app/src/file-panels/choice/switcher.tsx create mode 100644 packages/app/src/file-panels/document-frame/index.tsx create mode 100644 packages/app/src/file-panels/document-frame/index.web.tsx create mode 100644 packages/app/src/file-panels/document-frame/sandbox.test.ts create mode 100644 packages/app/src/file-panels/document-frame/sandbox.ts create mode 100644 packages/app/src/file-panels/document.test.ts create mode 100644 packages/app/src/file-panels/document.ts create mode 100644 packages/app/src/file-panels/panels.ts create mode 100644 packages/app/src/file-panels/plugin-host.tsx create mode 100644 packages/app/src/file-panels/settings/hook.ts create mode 100644 packages/app/src/file-panels/settings/rows.test.ts create mode 100644 packages/app/src/file-panels/settings/rows.ts create mode 100644 packages/app/src/file-panels/view.tsx diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 0d63336d59..dfa968c24e 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"); } @@ -484,9 +486,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 +550,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 +682,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 +697,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 +788,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-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-panels.spec.ts b/packages/app/e2e/browser/plugin-file-panels.spec.ts new file mode 100644 index 0000000000..62a0f2c6a4 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panels.spec.ts @@ -0,0 +1,185 @@ +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 { usePaseo, useWorkspace } from "@getpaseo/plugin/client"; +import { useQueryClient } from "@tanstack/react-query"; +import { DocumentFrame } from "@getpaseo/plugin/client/ui"; +function Pdf({ document, file, workspaceId }) { + usePaseo(); + useQueryClient(); + const workspace = useWorkspace(workspaceId, value => ({ id: value.id })); + const [urlBytes, setUrlBytes] = useState(-1); + useEffect(() => { + fetch(document.url).then(response => response.arrayBuffer()).then(bytes => setUrlBytes(bytes.byteLength)); + }, [document.url]); + return + PDF plugin: {file.name} + Path {file.path} + Bytes {document.bytes.length}; URL bytes {urlBytes} + Workspace {workspace?.id} + + ; +} +function Markdown({ document }) { + return + Plugin Markdown: {document.text} + {document.editable ? "Plugin document editable" : "Plugin document readonly"} + document.setText("# Saved by plugin\\n")}> + Edit Markdown through plugin + + ; +} +export default function contribute(client) { + client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText", + files: { extensions: ["pdf", "txt"] }, document: "bytes", Component: Pdf }); + client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText", + files: { extensions: ["md"] }, document: "text", 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 edit associated Markdown through the host document", 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 document editable", { 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 selectSource(page); + await expect( + page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"), + ).toHaveText("# Saved by plugin"); + 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..33fd191aea --- /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"] }, document: "text", 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/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx index 3ce1b8323f..41f64e0e1b 100644 --- a/packages/app/src/file-pane/bar.tsx +++ b/packages/app/src/file-pane/bar.tsx @@ -1,9 +1,10 @@ +import type { ReactNode } from "react"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar"; -import { SegmentedControl } from "@/components/ui/segmented-control"; +import { FilePanelBarMenu, FilePanelSwitcher, type FilePanelChoice } from "@/file-panels"; import type { Theme } from "@/styles/theme"; import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; import type { FileEditorStatus } from "./editor/model"; @@ -11,34 +12,26 @@ import type { FileEditorStatus } from "./editor/model"; const ThemedSpinner = withUnistyles(LoadingSpinner); const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +/** + * The file tab's bar: the document's status, which the host owns whatever panel shows it, and the + * switcher between the file's panels. `trailing` is the active panel's own status. + */ export function FilePanelBar({ size, lineCount, - mode, - onModeChange, editorStatus, - cursor, - vimMode, conflict, + choice, + trailing, }: { size: number; lineCount?: number; - mode?: "preview" | "source"; - onModeChange?(mode: "preview" | "source"): void; editorStatus?: FileEditorStatus; - cursor?: { line: number; column: number }; - vimMode?: string | null; conflict?: FileConflictAlertState; + choice: FilePanelChoice; + trailing?: ReactNode; }) { 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" }, - ]; return ( @@ -46,6 +39,7 @@ export function FilePanelBar({ {formatFileSize(size)} @@ -53,6 +47,7 @@ export function FilePanelBar({ {lineCount !== undefined ? ( {t("panels.file.editor.lines", { count: lineCount })} @@ -82,32 +77,10 @@ export function FilePanelBar({ {editorStatus === "error" ? ( {t("panels.file.editor.saveFailed")} ) : null} - {vimMode ? ( - - {vimMode} - - ) : null} - {cursor ? ( - - Ln {cursor.line}, Col {cursor.column} - - ) : null} + {trailing} - {mode && onModeChange ? ( - - ) : null} + + {conflict ? : null} @@ -115,6 +88,32 @@ export function FilePanelBar({ ); } +/** Source's status in the bar: the vim mode and the cursor position. */ +export function FileSourceStatus({ + cursor, + vimMode, +}: { + cursor: { line: number; column: number }; + vimMode: string | null; +}) { + const { t } = useTranslation(); + return ( + <> + {vimMode ? ( + + {vimMode} + + ) : null} + + Ln {cursor.line}, Col {cursor.column} + + + ); +} + function formatFileSize(size: number): string { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index c2749b9c05..13d447f3fc 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -4,7 +4,6 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; import { getCM, vim } from "@replit/codemirror-vim"; -import { isRenderedMarkdownFile } from "@/file-panels"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; @@ -25,8 +24,12 @@ const wrappingCompartment = new Compartment(); const themeCompartment = new Compartment(); const vimCompartment = new Compartment(); +/** Markdown is prose, so its paragraphs wrap; code keeps its lines. */ +const WRAPPED_EXTENSIONS = new Set(["md", "markdown"]); + function wrappingForFile(filename: string) { - return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : []; + const extension = filename.split(".").pop()?.toLowerCase() ?? ""; + return WRAPPED_EXTENSIONS.has(extension) ? EditorView.lineWrapping : []; } export function FileEditorView({ diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 8e43931677..ebb78bb702 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,13 +1,19 @@ 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 type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; import { Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, withUnistyles } 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 { + FilePanelPicker, + FilePanelView, + isSourceFilePanel, + useFilePanelChoice, + type FilePanel, + type FilePanelChoice, +} from "@/file-panels"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { getFileNameFromPath } from "@/attachments/utils"; import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; @@ -20,13 +26,13 @@ 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 { FilePanelBar, FileSourceStatus } from "./bar"; import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; import { FileSourceView } from "./source/view"; import type { FileConflictAlertState } from "./conflict-alert"; -import type { LiveFileModel } from "./live-file/model"; +import type { LiveFileModel, LiveFileSnapshot } from "./live-file/model"; import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import type { Theme } from "@/styles/theme"; @@ -36,15 +42,15 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -interface FilePreviewBodyProps { +interface FilePanelBodyProps { + bytes: Uint8Array | null; + setText?: (text: string) => void; panel: FilePanel | null; preview: ExplorerFile | null; - mode?: "preview" | "source"; isLoading: boolean; - isMobile: boolean; location: WorkspaceFileLocation; navigationRevision: number; - imagePreviewUri: string | null; + documentPreviewUri: string | null; workspaceRoot: string; client: DaemonClient | null; serverId: string; @@ -121,19 +127,20 @@ function TooLargeSource({ size }: { size?: number }) { ); } -function FilePreviewBody({ +/** The tab body: the file's panel, Source read-only when the tab cannot edit. */ +function FilePanelBody({ + bytes, + setText, panel, preview, - mode, isLoading, - isMobile: _isMobile, location, navigationRevision, - imagePreviewUri, + documentPreviewUri, workspaceRoot, client, serverId, -}: FilePreviewBodyProps) { +}: FilePanelBodyProps) { const { t } = useTranslation(); const filePath = location.path; @@ -154,21 +161,15 @@ function FilePreviewBody({ ); } - const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source"); - if (panel && !showSource) { + if (!panel) { return ( - + + {t("panels.file.binaryPreviewUnavailable")} + {formatFileSize({ size: preview.size })} + ); } - if (preview.kind === "text") { + if (isSourceFilePanel(panel)) { return ( ); } - return ( - - {t("panels.file.binaryPreviewUnavailable")} - {formatFileSize({ size: preview.size })} - + ); } @@ -199,9 +209,6 @@ 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. @@ -240,26 +247,31 @@ export function FilePane({ }); const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null; + const { panel: resolvedPanel, choice } = useFilePanelChoice({ + serverId, + targetKey, + path: location.path, + file: readyFile(liveFile.snapshot, targetKey), + presentable: isActive, + lineNavigation: location.lineStart ? navigationRevision : null, + }); const previewLifecycle = useFilePreview({ + panel: resolvedPanel, targetKey, liveFileSnapshot: liveFile.snapshot, }); - 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 { + file: preview, + panel, + bytes, + documentAttachment, + } = resolveFilePreviewLifecycle(previewLifecycle); + const documentPreviewUri = useAttachmentPreviewUrl(documentAttachment); 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; @@ -269,33 +281,43 @@ export function FilePane({ previewLifecycle.status === "preparing"; return ( - + <> + + + ); } +/** The read for `targetKey`; right after a retarget the snapshot still holds the previous file. */ +function readyFile(snapshot: LiveFileSnapshot, targetKey: string | null): FileReadResult | null { + const observation = snapshot.observation; + if (observation?.status !== "ready") return null; + const { cwd, path } = observation.version; + return targetKey === `${cwd}:${path}` ? observation.file : null; +} + function isEditableTextFile(input: { preview: ExplorerFile | null; supportsEditing: boolean; @@ -309,7 +331,9 @@ function isEditableTextFile(input: { } function FilePanePresentation({ + bytes, panel, + choice, serverId, workspaceRoot, client, @@ -320,19 +344,18 @@ function FilePanePresentation({ retryingRead, retryLabel, filename, - previewMode, - onPreviewModeChange, lineCount, editable, disconnectedMessage, errorMessage, isLoading, - isMobile, location, navigationRevision, - imagePreviewUri, + documentPreviewUri, }: { + bytes: Uint8Array | null; panel: FilePanel | null; + choice: FilePanelChoice; serverId: string; workspaceRoot: string; client: DaemonClient | null; @@ -343,17 +366,14 @@ function FilePanePresentation({ 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; + documentPreviewUri: string | null; }) { if (!client && readTarget) { return ( @@ -368,7 +388,10 @@ function FilePanePresentation({ if (editable && client && readTarget && preview?.kind === "text") { return ( @@ -412,23 +432,15 @@ function FilePanePresentation({ return ( - {preview ? ( - - ) : null} - : null} + void; retryingRead: boolean; filename: string; - mode?: "preview" | "source"; - onModeChange?: (mode: "preview" | "source") => void; isLoading: boolean; - isMobile: boolean; location: WorkspaceFileLocation; navigationRevision: number; }) { @@ -564,6 +576,7 @@ function EditableFilePane({ onRetry: onRetryRead, retrying: retryingRead, }); + const setText = useCallback((text: string) => model.edit(text), [model]); const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []); const renderedPreview = useMemo( () => ({ @@ -575,7 +588,11 @@ function EditableFilePane({ }), [preview, snapshot.content, snapshot.version], ); - const showSource = mode !== "preview"; + const sourceActive = panel !== null && isSourceFilePanel(panel); + const sourceStatus = useMemo( + () => (sourceActive ? : null), + [cursor, sourceActive, vimMode], + ); return ( @@ -585,13 +602,11 @@ function EditableFilePane({ } lineCount={snapshot.content.split("\n").length} editorStatus={snapshot.status} - cursor={showSource ? cursor : undefined} - vimMode={showSource ? vimMode : null} conflict={conflict} - mode={mode} - onModeChange={onModeChange} + choice={choice} + trailing={sourceStatus} /> - {showSource ? ( + {sourceActive ? ( ) : ( - new FilePreviewLifecycleModel(createFilePanePreview)); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const { targetKey, liveFileSnapshot } = input; + const { targetKey, liveFileSnapshot, panel } = input; useEffect(() => { - model.setSource({ targetKey, liveFileSnapshot }); - }, [liveFileSnapshot, model, targetKey]); + model.setSource({ targetKey, liveFileSnapshot, panel }); + }, [liveFileSnapshot, model, targetKey, panel]); return snapshot; } diff --git a/packages/app/src/file-pane/preview-lifecycle/model.test.ts b/packages/app/src/file-pane/preview-lifecycle/model.test.ts index a934e69b58..cef082337a 100644 --- a/packages/app/src/file-pane/preview-lifecycle/model.test.ts +++ b/packages/app/src/file-pane/preview-lifecycle/model.test.ts @@ -2,6 +2,7 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; import type { LiveFileSnapshot } from "../live-file/model"; import { describe, expect, it } from "vitest"; import { + createFilePanePreview, FilePreviewLifecycleModel, type FilePanePreview, type FilePreviewLifecycleSnapshot, @@ -75,8 +76,9 @@ function source( ): { targetKey: string; liveFileSnapshot: LiveFileSnapshot; + panel: null; } { - return { targetKey, liveFileSnapshot: snapshot }; + return { targetKey, liveFileSnapshot: snapshot, panel: null }; } describe("FilePreviewLifecycleModel", () => { @@ -88,7 +90,8 @@ describe("FilePreviewLifecycleModel", () => { const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise); const preview: FilePanePreview = { file: previewFile("preview"), - imageAttachment: null, + panel: null, + bytesPreview: null, }; model.setSource(source("/workspace:file.ts", pending())); @@ -134,12 +137,13 @@ describe("FilePreviewLifecycleModel", () => { ); const nextPreview: FilePanePreview = { file: previewFile("two"), - imageAttachment: null, + panel: null, + bytesPreview: null, }; model.setSource(source("/workspace:file.ts", completed(file("one")))); model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts")))); - first.resolve({ file: previewFile("one"), imageAttachment: null }); + first.resolve({ file: previewFile("one"), panel: null, bytesPreview: null }); await Promise.resolve(); expect(model.getSnapshot()).toEqual({ status: "preparing" }); @@ -155,3 +159,41 @@ async function expectSnapshot( await new Promise((resolve) => queueMicrotask(resolve)); expect(model.getSnapshot()).toEqual(expected); } + +it("does not persist attachments when a text file has no selected panel", async () => { + let writes = 0; + await createFilePanePreview(file(), null, async () => { + writes++; + return { + id: "attachment", + mimeType: "text/plain", + storageType: "web-indexeddb", + storageKey: "attachment", + createdAt: 0, + }; + }); + expect(writes).toBe(0); +}); + +it("reprepares the same file when its selected panel changes", async () => { + const preparations: Array = []; + const model = new FilePreviewLifecycleModel(async (raw, panel) => { + preparations.push(panel?.id ?? null); + return { file: previewFile("preview"), panel, bytesPreview: null }; + }); + const initial = source("/workspace:file.ts", completed(file())); + model.setSource(initial); + await Promise.resolve(); + const textPanel = { + id: "builtin:markdown", + builtin: true, + files: { extensions: ["md", "markdown"] }, + document: "text", + maxBytes: Infinity, + } as const; + model.setSource({ ...initial, panel: textPanel }); + await Promise.resolve(); + model.setSource({ ...initial, panel: textPanel }); + expect(preparations).toEqual([null, "builtin:markdown"]); + expect(model.getSnapshot()).toMatchObject({ status: "ready", preview: { panel: textPanel } }); +}); diff --git a/packages/app/src/file-pane/preview-lifecycle/model.ts b/packages/app/src/file-pane/preview-lifecycle/model.ts index fe69f2227b..b831653a81 100644 --- a/packages/app/src/file-pane/preview-lifecycle/model.ts +++ b/packages/app/src/file-pane/preview-lifecycle/model.ts @@ -3,12 +3,14 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { persistAttachmentFromBytes } from "@/attachments/service"; import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import type { FilePanel } from "@/file-panels"; import type { ExplorerFile } from "@/stores/session-store"; import type { LiveFileSnapshot } from "../live-file/model"; export interface FilePanePreview { file: ExplorerFile; - imageAttachment: AttachmentMetadata | null; + panel: FilePanel | null; + bytesPreview: { bytes: Uint8Array; attachment: AttachmentMetadata } | null; } export type FilePreviewLifecycleSnapshot = @@ -22,13 +24,17 @@ export type FilePreviewLifecycleSnapshot = const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" }; /** Converts a completed raw read into the preview resources consumed by FilePane. */ -export async function createFilePanePreview(file: FileReadResult): Promise { +export async function createFilePanePreview( + file: FileReadResult, + panel: FilePanel | null, + persist: typeof persistAttachmentFromBytes = persistAttachmentFromBytes, +): Promise { const explorerFile = explorerFileFromReadResult(file); - if (file.kind !== "image") { - return { file: explorerFile, imageAttachment: null }; + if (panel?.document !== "bytes") { + return { file: explorerFile, panel, bytesPreview: null }; } - const imageAttachment = await persistAttachmentFromBytes({ + const documentAttachment = await persist({ id: createPreviewAttachmentId({ mimeType: file.mime, path: file.path, @@ -41,7 +47,11 @@ export async function createFilePanePreview(file: FileReadResult): Promise Promise, + private readonly prepare: ( + file: FileReadResult, + panel: FilePanel | null, + ) => Promise, ) {} subscribe = (listener: () => void): (() => void) => { @@ -63,7 +77,11 @@ export class FilePreviewLifecycleModel { getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot; - setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void { + setSource(input: { + targetKey: string | null; + liveFileSnapshot: LiveFileSnapshot; + panel: FilePanel | null; + }): void { const targetChanged = input.targetKey !== this.targetKey; const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot); if (targetChanged) { @@ -74,16 +92,22 @@ export class FilePreviewLifecycleModel { const source = getReadySource(input); if (source) { - if (source.key === this.preparedSourceKey) { + if (source.key === this.preparedSourceKey && input.panel === this.preparedPanel) { return; } this.preparedSourceKey = source.key; + this.preparedPanel = input.panel; const generation = ++this.generation; this.setSnapshot({ status: "preparing", ...(retainedPreview ? { preview: retainedPreview } : {}), }); - void this.prepareSource({ file: source.file, generation, retainedPreview }); + void this.prepareSource({ + file: source.file, + panel: input.panel, + generation, + retainedPreview, + }); return; } @@ -109,11 +133,12 @@ export class FilePreviewLifecycleModel { private async prepareSource(input: { file: FileReadResult; + panel: FilePanel | null; generation: number; retainedPreview?: FilePanePreview; }): Promise { try { - const preview = await this.prepare(input.file); + const preview = await this.prepare(input.file, input.panel); if (input.generation !== this.generation) return; this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" }); } catch (error) { @@ -135,10 +160,18 @@ export function filePreviewFromLifecycle( export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): { file: ExplorerFile | null; - imageAttachment: AttachmentMetadata | null; + panel: FilePanel | null; + bytes: Uint8Array | null; + documentAttachment: AttachmentMetadata | null; } { const preview = filePreviewFromLifecycle(snapshot); - return preview ?? { file: null, imageAttachment: null }; + if (!preview) return { file: null, panel: null, bytes: null, documentAttachment: null }; + return { + file: preview.file, + panel: preview.panel, + bytes: preview.bytesPreview ? preview.bytesPreview.bytes : null, + documentAttachment: preview.bytesPreview ? preview.bytesPreview.attachment : null, + }; } function getReadySource(input: { 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..2424262423 100644 --- a/packages/app/src/file-panels/builtin.tsx +++ b/packages/app/src/file-panels/builtin.tsx @@ -7,18 +7,18 @@ import { ZoomableImage } from "@/components/zoomable-viewport/image"; import type { Theme } from "@/styles/theme"; import { FileHtmlPreview } from "./html-preview"; import { FileMarkdownPreview } from "./markdown-preview"; -import type { builtinFilePanels } from "./catalog"; +import type { RenderingFilePanel } from "./catalog"; -interface FilePanelPreviewProps { +interface BuiltinFilePanelProps { content: string; filePath: string; workspaceRoot: string; client: DaemonClient | null; serverId: string; - imagePreviewUri: string | null; + documentPreviewUri: string | null; } -function MarkdownPanel(props: FilePanelPreviewProps) { +function MarkdownPanel(props: BuiltinFilePanelProps) { return ( @@ -34,7 +34,7 @@ function MarkdownPanel(props: FilePanelPreviewProps) { ); } -function HtmlPanel({ content }: FilePanelPreviewProps) { +function HtmlPanel({ content }: BuiltinFilePanelProps) { return ( @@ -45,9 +45,9 @@ function HtmlPanel({ content }: FilePanelPreviewProps) { const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) { +function ImagePanel({ documentPreviewUri }: BuiltinFilePanelProps) { const { t } = useTranslation(); - if (!imagePreviewUri) { + if (!documentPreviewUri) { return ( @@ -55,7 +55,7 @@ function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) { ); } - return ; + return ; } const components = { @@ -64,11 +64,11 @@ const components = { "builtin:image": ImagePanel, }; -export function FilePanelPreview({ +export function BuiltinFilePanelView({ panel, ...props -}: FilePanelPreviewProps & { - panel: (typeof builtinFilePanels)[number]; +}: BuiltinFilePanelProps & { + panel: RenderingFilePanel; }) { const Component = components[panel.id]; return ; diff --git a/packages/app/src/file-panels/catalog.test.ts b/packages/app/src/file-panels/catalog.test.ts index 2052224db2..ac96acf612 100644 --- a/packages/app/src/file-panels/catalog.test.ts +++ b/packages/app/src/file-panels/catalog.test.ts @@ -1,55 +1,62 @@ 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"] }, + document: "text", + maxBytes: Infinity, +}; + +function textFile(path: string): FilePanelFile { + return { path, kind: "text", size: 100, hasBytes: true }; } -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", size: 512, hasBytes: true }), + ).toEqual([]); + }); + it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])( "registers the image extension %s", (extension) => { @@ -60,27 +67,24 @@ describe("built-in file panels", () => { size: 512, hasBytes: true, }, - candidates: builtinFilePanels, + 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, + 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"); + it("does not impose a size limit on built-in text panels", () => { + expect(candidateIds({ ...textFile("notes.md"), size: 11 * 1024 * 1024 })).toEqual([ + "builtin:markdown", + "builtin:source", + ]); }); }); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts index fd350ec1ff..d5e2a0cfc4 100644 --- a/packages/app/src/file-panels/catalog.ts +++ b/packages/app/src/file-panels/catalog.ts @@ -1,28 +1,54 @@ -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"] }, + document: "text", + maxBytes: Infinity, + }, { id: "builtin:html", + builtin: true, files: { extensions: ["html", "htm"] }, document: "text", maxBytes: Infinity, }, { id: "builtin:image", + builtin: true, files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] }, document: "bytes", maxBytes: Infinity, }, ] 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", + document: "text", + maxBytes: Infinity, +} 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]; +} + +/** Source is Paseo's own editor, so the file tab renders it rather than the panel host. */ +export function isSourceFilePanel( + panel: Panel | SourceFilePanel, +): panel is SourceFilePanel { + return panel.id === sourceFilePanel.id; } 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..3ee7191391 --- /dev/null +++ b/packages/app/src/file-panels/choice/hook.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; +import { useFilePanelAssociations } from "../associations"; +import { sourceFilePanel } from "../catalog"; +import { useFilePanels, type FilePanel } from "../panels"; +import { resolveFileReadPanels } from "../resolve"; +import { 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; + file: FileReadResult | null; + /** The tab is on screen. */ + presentable: boolean; + /** Identifies the tab's latest request to show a line, or null when it targets no line. */ + lineNavigation: number | null; +}): { panel: FilePanel | null; choice: FilePanelChoice } { + const { targetKey, path, file, presentable, lineNavigation } = input; + const { 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( + () => resolveFileReadPanels({ file, path, candidates, associations, choice: chosenPanelId }), + [associations, candidates, chosenPanelId, file, 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..86c31bbf7c --- /dev/null +++ b/packages/app/src/file-panels/choice/model.test.ts @@ -0,0 +1,205 @@ +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"] }, + document: "text", + maxBytes: Infinity, +}; +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", size: 10, hasBytes: true }, + 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..aae416031b --- /dev/null +++ b/packages/app/src/file-panels/document-frame/index.tsx @@ -0,0 +1,39 @@ +import { useCallback, useMemo, useRef } from "react"; +import { WebView } from "react-native-webview"; + +const frameStyle = { flex: 1, backgroundColor: "white" }; +const originWhitelist = ["*"]; + +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; + return ( + + ); +} 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 ( +