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 ( +