From 76b32544c6ba804b135acef86bd4ed627c654ae2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 13:04:59 +0700 Subject: [PATCH] Give Markdown and HTML file tabs back their Preview and Source views (#7614) * fix(app): give Markdown and HTML file tabs back their Preview and Source views The built-in Markdown and HTML panels own one toolbar again: file size, line count, save status, vim mode, cursor, and a Preview | Source control over a body that shows the rendered preview or the editor from the same loaded content and editor model. Plain text files keep the toolbar on top. The host no longer renders a panel switcher. Source is the built-in panel for text files no other built-in claims, so it is never offered beside Markdown or HTML in the picker, the Open with menu, or Settings > Files. A line target opens the file's built-in panel in its Source view. * refactor(app): name the shared text file panel TextFilePanel Markdown and HTML own a Source view; they are not Source panels. The composite in file-panels/source/panel.tsx is now TextFilePanel, and SourcePanel is TextFilePanel without a Preview. Names only. --- docs/plugins.md | 8 +- .../e2e/browser/file-change-conflicts.spec.ts | 3 +- packages/app/e2e/browser/file-editing.spec.ts | 69 ++++++-- packages/app/e2e/browser/folder-links.spec.ts | 5 +- .../plugin-file-panel-examples.spec.ts | 52 ++---- .../browser/plugin-file-panel-picker.spec.ts | 45 +++-- .../plugin-file-panel-settings.spec.ts | 8 +- .../e2e/browser/plugin-file-panels.spec.ts | 10 +- .../helpers/markdown-file-panel-plugin.ts | 1 - packages/app/src/file-pane/bar.tsx | 58 ------- packages/app/src/file-pane/pane.tsx | 2 - packages/app/src/file-panels/builtin.tsx | 36 ++-- packages/app/src/file-panels/catalog.test.ts | 22 ++- packages/app/src/file-panels/catalog.ts | 7 +- packages/app/src/file-panels/choice/hook.ts | 5 +- .../app/src/file-panels/choice/model.test.ts | 46 ++--- packages/app/src/file-panels/choice/model.ts | 17 +- .../app/src/file-panels/choice/open-with.tsx | 41 ----- .../src/file-panels/choice/presentation.ts | 2 +- .../choice/switcher-layout.test.ts | 31 ---- .../src/file-panels/choice/switcher-layout.ts | 24 --- .../app/src/file-panels/choice/switcher.tsx | 138 --------------- packages/app/src/file-panels/index.ts | 7 +- packages/app/src/file-panels/resolve.test.ts | 11 ++ packages/app/src/file-panels/resolve.ts | 16 +- .../app/src/file-panels/settings/rows.test.ts | 16 +- packages/app/src/file-panels/settings/rows.ts | 9 +- packages/app/src/file-panels/source/panel.tsx | 144 +++++++++++----- .../app/src/file-panels/source/status.tsx | 122 ------------- .../app/src/file-panels/source/toolbar.tsx | 163 ++++++++++++++++++ packages/app/src/i18n/resources/ar.ts | 3 +- packages/app/src/i18n/resources/en.ts | 3 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/ko.ts | 3 +- packages/app/src/i18n/resources/pt-BR.ts | 3 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 3 +- plugin-examples/markdown-panel/README.md | 5 +- public-docs/plugins/reference.md | 52 +++--- public-docs/plugins/reference.zh-CN.md | 12 +- 42 files changed, 569 insertions(+), 645 deletions(-) delete mode 100644 packages/app/src/file-pane/bar.tsx delete mode 100644 packages/app/src/file-panels/choice/switcher-layout.test.ts delete mode 100644 packages/app/src/file-panels/choice/switcher-layout.ts delete mode 100644 packages/app/src/file-panels/choice/switcher.tsx delete mode 100644 packages/app/src/file-panels/source/status.tsx create mode 100644 packages/app/src/file-panels/source/toolbar.tsx diff --git a/docs/plugins.md b/docs/plugins.md index facc815ef6..30d99a00a8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -566,9 +566,11 @@ one section's order from built-ins, plugin groups, and the section's preference Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes, -and resolves conflicts. The bar only switches panels and shows the dot the panel sets through -`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the -same handle as plugin panels, so a fix to reading, watching, or writing lands in one place: +and resolves conflicts. The host has no bar and no notion of a panel's views: the Markdown and HTML +panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the +dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in +claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's +editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place: `packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel; make that panel faster instead. Source highlights a large file on native in idle-time slices, the lines around the opening line first. diff --git a/packages/app/e2e/browser/file-change-conflicts.spec.ts b/packages/app/e2e/browser/file-change-conflicts.spec.ts index 1a372af35d..cfc9ea1a55 100644 --- a/packages/app/e2e/browser/file-change-conflicts.spec.ts +++ b/packages/app/e2e/browser/file-change-conflicts.spec.ts @@ -182,7 +182,8 @@ test.describe("Workspace file change conflicts", () => { await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); await unlink(filePath); - await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible(); + await expectDeletedFileNotice(page); + await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); await writeFile(filePath, "# After\n", "utf8"); await expect(filePane(page).getByText("After", { exact: true })).toBeVisible(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index f06c244ba3..0266d02691 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { expandFolder, @@ -55,10 +56,6 @@ 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"), @@ -66,13 +63,32 @@ function htmlPreview(page: Page) { }; } -/** 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 }); +async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { + const option = page.getByTestId("file-panel-bar").getByRole("button", { + name: view, + exact: true, + }); await option.click(); await expect(option).toHaveAttribute("aria-selected", "true"); } +async function expectAbove(upper: Locator, lower: Locator): Promise { + const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]); + expect(upperBox).not.toBeNull(); + expect(lowerBox).not.toBeNull(); + expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y); +} + +/** How many times the app has read the file's contents from the daemon. */ +function fileReadCount( + gate: Awaited>, + fileName: string, +): number { + return gate + .getClientRequests("file_explorer_request") + .filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length; +} + async function expectMarkdownImageLoaded(page: Page, name: string): Promise { const image = page.getByRole("button", { name, exact: true }); await expect(image).toBeVisible(); @@ -482,9 +498,9 @@ test.describe("CodeMirror workspace file editing", () => { relativePath, ); await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md"); - const modeControl = filePanelSwitcher(page); + const modeControl = page.getByTestId("file-preview-mode"); await expect(modeControl).toBeVisible(); - await selectFilePanel(page, "Source"); + await selectFileView(page, "Source"); const editorHost = page.getByTestId("file-source-editor"); const content = editor(page); @@ -546,7 +562,7 @@ test.describe("CodeMirror workspace file editing", () => { ); await workspace.navigateTo(); await openWorkspaceFile(page, "notes.md"); - await selectFilePanel(page, "Source"); + await selectFileView(page, "Source"); const markdownScroller = page .getByTestId("file-source-editor") @@ -582,6 +598,11 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(page.getByLabel(/File size/)).toBeVisible(); await expect(page.getByLabel(/lines/)).toBeVisible(); + await expect(page.getByTestId("file-preview-mode")).toHaveCount(0); + await expectAbove( + page.getByTestId("file-panel-bar").filter({ visible: true }), + page.getByTestId("file-source-editor").filter({ visible: true }), + ); await replaceEditorText(page, "const autosaved = 2;\n"); await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible(); @@ -678,11 +699,12 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible(); }); - test("refreshes Markdown and images while switching between Markdown and Source", async ({ + test("refreshes Markdown and images while preserving Preview and Source behavior", async ({ page, withWorkspace, }) => { test.setTimeout(90_000); + const gate = await installDaemonWebSocketGate(page); const workspace = await withWorkspace({ prefix: "file-editing-preview-" }); const markdownPath = path.join(workspace.repoPath, "notes.md"); const imagePath = path.join(workspace.repoPath, "pixel.png"); @@ -692,17 +714,30 @@ test.describe("CodeMirror workspace file editing", () => { await openWorkspaceFile(page, "notes.md"); const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); + const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true }); await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible(); - await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible(); + await expect(toolbar.getByLabel(/^File size/)).toBeVisible(); + await expect(toolbar.getByLabel("2 lines")).toBeVisible(); await writeFile(markdownPath, "# Updated heading\n", "utf8"); await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible(); - await selectFilePanel(page, "Source"); + // Source shows the same read in the editor; the file is not read again. + const reads = fileReadCount(gate, "notes.md"); + await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); + await expect(editor(page)).toContainText("# Updated heading"); + await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible(); + expect(fileReadCount(gate, "notes.md")).toBe(reads); + + // Preview shows the edits made in Source. await replaceEditorText(page, "# Saved from source\n"); - await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); - await selectFilePanel(page, "Markdown"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); + await selectFileView(page, "Preview"); await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible(); + await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0); + await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible(); await openWorkspaceFile(page, "pixel.png"); const image = visibleFilePane.locator("img"); @@ -784,10 +819,10 @@ test.describe("CodeMirror workspace file editing", () => { ); await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible(); - await selectFilePanel(page, "Source"); + await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(preview.host).toHaveCount(0); - await selectFilePanel(page, "HTML"); + await selectFileView(page, "Preview"); 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 f557d97737..a893fbcb68 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -14,9 +14,8 @@ async function openMarkdownPreview(page: Page) { await openFileFromExplorer(page, "README.md"); await expectFileTabOpen(page, "README.md"); await page - .getByTestId("file-panel-switcher") - .filter({ visible: true }) - .getByRole("button", { name: "Markdown", exact: true }) + .getByTestId("file-panel-bar") + .getByRole("button", { name: "Preview", exact: true }) .click(); await ensureWorkspaceAgentPaneVisible(page); } diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts index 0d1ab23476..9159a60644 100644 --- a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -71,10 +71,6 @@ async function captureWideAndCompact( await ready(); } -function filePanelSwitcher(page: Page) { - return page.getByTestId("file-panel-switcher").filter({ visible: true }); -} - /** Turns on vim keybindings without discarding settings the test writes later. */ async function enableVimKeybindings(page: Page) { await page.addInitScript(() => { @@ -94,16 +90,19 @@ async function replaceMarkdown(page: Page, text: string) { await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text); } -async function selectPanel(page: Page, title: string) { - await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click(); +async function 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 expectPanels(page: Page, titles: string[]) { - for (const title of titles) { - await expect( - filePanelSwitcher(page).getByRole("button", { name: title, exact: true }), - ).toBeInViewport({ ratio: 1 }); - } +async function selectFileView(page: Page, view: "Preview" | "Source") { + const option = page + .getByTestId("file-preview-mode") + .filter({ visible: true }) + .getByRole("button", { name: view, exact: true }); + await option.click(); + await expect(option).toHaveAttribute("aria-selected", "true"); } /** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ @@ -126,9 +125,6 @@ async function expectMarkdownEditor(page: Page, text: string) { await expect(editor).toBeVisible(); await expect(editor).toContainText(text); await expect(editor).toBeInViewport({ ratio: 1 }); - // Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu. - await expect(filePanelSwitcher(page)).toContainText("Rich Markdown"); - await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 }); } test("directory examples mount the PDF frame and write Markdown through the file handle", async ({ @@ -197,7 +193,7 @@ test("directory examples mount the PDF frame and write Markdown through the file await expect( page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }), ).toBeVisible(); - await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible(); + await expect(page.getByRole("radio")).toHaveCount(2); await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => { // The previous layout's picker animates out (the sheet slides down, the dialog fades), so // both pickers are mounted for a moment after a resize. @@ -220,32 +216,23 @@ test("directory examples mount the PDF frame and write Markdown through the file .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) .toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n"); await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden(); - await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]); await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => { await expectMarkdownEditor(page, "Edited by Rich Markdown"); }); - // Source is Paseo's editor; its cursor and vim mode are in its own status row. - await selectPanel(page, "Source"); + // "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the + // cursor and vim mode in the panel's toolbar. + await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo"); + await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); + await selectFileView(page, "Source"); const source = page.getByTestId("file-source-editor").filter({ visible: true }); await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown"); await source.locator(".cm-line").nth(2).click(); - const bar = page.getByTestId("file-source-status").filter({ visible: true }); + const bar = page.getByTestId("file-panel-bar").filter({ visible: true }); await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible(); await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible(); await capture(page, testInfo, "markdown-example-source-wide"); - // The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source. - await page.setViewportSize(COMPACT); - await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown"); - await filePanelSwitcher(page).click(); - await page.getByRole("menuitem", { name: "Source", exact: true }).click(); - await expect(filePanelSwitcher(page)).toHaveText("Source"); - await source.locator(".cm-line").nth(2).click(); - await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport(); - await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport(); - await capture(page, testInfo, "markdown-example-source-compact"); - await page.setViewportSize(WIDE); - await selectPanel(page, "Rich Markdown"); + await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel"); await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n"); await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText( "# External update", @@ -266,7 +253,6 @@ test("directory examples mount the PDF frame and write Markdown through the file await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden(); await page.setViewportSize(COMPACT); await expectMarkdownEditor(page, "Remembered panel"); - await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown"); } finally { await client.removePlugin("pdf-panel"); await client.removePlugin("markdown-panel"); diff --git a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts index 792ace7700..0fc47d7a16 100644 --- a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts @@ -30,17 +30,26 @@ async function openWithFromTabMenu(page: Page, name: string, option: string) { 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(); +async function toggleAlwaysUseFromTabMenu(page: Page, name: string) { + await openWithFromTabMenu(page, name, "Always use for .md"); } -function filePanelSwitcher(page: Page) { - return page.getByTestId("file-panel-switcher").filter({ visible: true }); +/** The built-in panel's toolbar control between its Preview and Source views. */ +function viewControl(page: Page) { + return page.getByTestId("file-preview-mode").filter({ visible: true }); } -async function expectSwitcherPanels(page: Page, titles: string[]) { - await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles); +async function selectFileView(page: Page, view: "Preview" | "Source") { + const option = viewControl(page).getByRole("button", { name: view, exact: true }); + await option.click(); + await expect(option).toHaveAttribute("aria-selected", "true"); +} + +async function expectNoOpenWith(page: Page, name: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0); + await page.keyboard.press("Escape"); } async function capture(page: Page, testInfo: TestInfo, name: string) { @@ -49,7 +58,7 @@ async function capture(page: Page, testInfo: TestInfo, name: string) { await testInfo.attach(name, { path: file, contentType: "image/png" }); } -test("opens Markdown with the built-in panel and Source beside it without asking", async ({ +test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({ page, }, testInfo) => { const workspace = await seedWorkspace({ @@ -62,19 +71,20 @@ test("opens Markdown with the built-in panel and Source beside it without asking await openFileExplorer(page); await openMarkdown(page, "notes.md"); await expectBuiltinMarkdown(page, "notes heading"); - await expectSwitcherPanels(page, ["Markdown", "Source"]); + await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]); await expect(filePanelPicker(page)).toBeHidden(); + await expectNoOpenWith(page, "notes.md"); await capture(page, testInfo, "file-panel-builtins-wide"); - await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click(); + await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible(); - await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click(); + await selectFileView(page, "Preview"); 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 }), + viewControl(page).getByRole("button", { name: "Source", exact: true }), ).toBeInViewport({ ratio: 1 }); await expect(filePanelPicker(page)).toBeHidden(); await capture(page, testInfo, "file-panel-builtins-compact"); @@ -107,6 +117,7 @@ test("asks once which panel opens Markdown and remembers it when told to", async 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 expect(page.getByRole("radio")).toHaveCount(2); await expectBuiltinMarkdown(page, "first heading"); await capture(page, testInfo, "file-panel-picker-wide"); @@ -118,8 +129,8 @@ test("asks once which panel opens Markdown and remembers it when told to", async await expectPluginMarkdown(page, "second.md"); await expect(filePanelPicker(page)).toBeHidden(); - // Forgetting from the file bar keeps the tab on the plugin. - await toggleAlwaysUseFromFileBar(page); + // Forgetting from the tab menu keeps the tab on the plugin. + await toggleAlwaysUseFromTabMenu(page, "second.md"); await expect.poll(() => storedFilePanelAssociations(page)).toEqual({}); await expectPluginMarkdown(page, "second.md"); @@ -130,10 +141,14 @@ test("asks once which panel opens Markdown and remembers it when told to", async await expectPluginMarkdown(page, "third.md"); expect(await storedFilePanelAssociations(page)).toEqual({}); - // "Open with…" switches the open tab back to the built-in in place. + // "Open with…" switches the open tab back to the built-in in place, with its own views. await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION); await expectBuiltinMarkdown(page, "third heading"); await expectFileTabOpen(page, "third.md"); + await selectFileView(page, "Source"); + await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible(); + await selectFileView(page, "Preview"); + await expectBuiltinMarkdown(page, "third heading"); // Dismissing keeps the built-in; reopening the tab after a reload asks again. await openMarkdown(page, "fourth.md"); diff --git a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts index d6b6f6c986..7f9350ed05 100644 --- a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts @@ -7,7 +7,6 @@ import { BUILTIN_MARKDOWN_OPTION, MARKDOWN_PANEL_ID, PLUGIN_MARKDOWN_OPTION, - SOURCE_OPTION, expectPluginMarkdown, filePanelPicker, installMarkdownPanelPlugin, @@ -18,6 +17,7 @@ import { seedWorkspace } from "../support/helpers/seed-client"; import { openSettingsSection } from "../support/helpers/settings"; const ASK_EVERY_TIME = "Ask every time"; +const SOURCE_OPTION = "Source — Paseo"; function markdownRow(page: Page) { return page.getByTestId("file-panel-settings-row-ext:md"); @@ -69,7 +69,7 @@ test("Settings > Files chooses which panel opens .md and asks again on request", await markdownDropdown(page, ASK_EVERY_TIME).click(); await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible(); await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible(); - await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0); await expectMenuOpen(page); await capture(page, testInfo, "file-panel-settings-wide-menu"); await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click(); @@ -94,11 +94,11 @@ test("Settings > Files chooses which panel opens .md and asks again on request", await expect(askAgain(page)).toBeInViewport({ ratio: 1 }); await capture(page, testInfo, "file-panel-settings-compact"); await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click(); - await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible(); await expectMenuOpen(page); await capture(page, testInfo, "file-panel-settings-compact-menu"); await page.keyboard.press("Escape"); - await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden(); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden(); // Ask again forgets the choice; the next open asks. await askAgain(page).click(); diff --git a/packages/app/e2e/browser/plugin-file-panels.spec.ts b/packages/app/e2e/browser/plugin-file-panels.spec.ts index 74d4e41329..8567147cda 100644 --- a/packages/app/e2e/browser/plugin-file-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panels.spec.ts @@ -91,9 +91,13 @@ async function associateMarkdownWithPlugin(page: Page) { async function editMarkdownThroughPlugin(page: Page) { await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click(); } -async function selectSource(page: Page) { +/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */ +async function openBuiltinMarkdownSource(page: Page, name: 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: "Markdown — Paseo" }).click(); await page - .getByTestId("file-panel-switcher") + .getByTestId("file-preview-mode") .filter({ visible: true }) .getByRole("button", { name: "Source", exact: true }) .click(); @@ -210,7 +214,7 @@ test("directory plugins render PDF bytes and write Markdown through the file han await page.getByRole("menuitem", { name: "Close", exact: true }).click(); await expect.poll(() => closePrompt).toContain("discard"); - await selectSource(page); + await openBuiltinMarkdownSource(page, "notes.md"); await expect( page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"), ).toHaveText("# Changed outside"); diff --git a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts index 9cf67326be..29ae71d84d 100644 --- a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts +++ b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts @@ -11,7 +11,6 @@ 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"; diff --git a/packages/app/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx deleted file mode 100644 index ab1fa41943..0000000000 --- a/packages/app/src/file-pane/bar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar"; -import { - FilePanelBarMenu, - FilePanelSwitcher, - useFilePanelChoiceSnapshot, - type FilePanelChoice, -} from "@/file-panels"; - -/** - * The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel - * has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest. - */ -export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) { - const { t } = useTranslation(); - const { openWith } = useFilePanelChoiceSnapshot(choice); - if (!openWith) return null; - return ( - - - - - {modified ? ( - - ) : null} - - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - chrome: { - flexShrink: 0, - }, - row: { - height: "100%", - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - }, - spacer: { flex: 1 }, - modifiedDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.foregroundExtraMuted, - }, -})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 68273491d7..606b27368e 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -15,7 +15,6 @@ import { useSessionStore } from "@/stores/session-store"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { formatFileSize } from "@/utils/format-file-size"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; -import { FilePanelBar } from "./bar"; /** * The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows @@ -110,7 +109,6 @@ export function FilePane({ return ( - ; +} + +/** Markdown: the text file panel with the rendered document as its Preview. */ +function MarkdownPanel(props: BuiltinFilePanelProps) { + return ; +} + +/** HTML: the text file panel with the sandboxed page as its Preview. */ +function HtmlPanel(props: BuiltinFilePanelProps) { + return ; +} + +/** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */ +function MarkdownPreview({ text, target }: TextFilePreviewProps) { const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null); const [rendered, setRendered] = useState(false); const handleLayout = useCallback(() => setRendered(true), []); - if (text.status === "loading") return ; - if (text.status === "error") return ; return ( ; - if (text.status === "error") return ; +/** The page owns its scrolling, so there is no ScrollView around it. */ +function HtmlPreview({ text }: TextFilePreviewProps) { return ( - + ); } diff --git a/packages/app/src/file-panels/catalog.test.ts b/packages/app/src/file-panels/catalog.test.ts index 89a7e14cc7..31635e4a6d 100644 --- a/packages/app/src/file-panels/catalog.test.ts +++ b/packages/app/src/file-panels/catalog.test.ts @@ -25,8 +25,9 @@ function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] } 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"]); + it("opens each rendered extension with its panel alone, never with Source beside it", () => { + expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown"]); + expect(candidateIds(textFile("plan.html"))).toEqual(["builtin:html"]); 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"); @@ -39,16 +40,29 @@ describe("built-in file panels", () => { } }); - it("puts plugin panels after the rendering built-ins and before Source", () => { + it("puts plugin panels after the built-in that claims the file, 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(candidateIds(textFile("notes.txt"), onlyPlugin)).toEqual([ + richMarkdown.id, + "builtin:source", + ]); expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id); }); + it("ignores a remembered Source for a file another built-in claims", () => { + const resolution = resolveFilePanel({ + file: textFile("README.md"), + candidates: builtins, + associations: { "ext:md": "builtin:source" }, + }); + expect(resolution.panel?.id).toBe("builtin:markdown"); + expect(resolution.associatedPanelId).toBeNull(); + }); + it("has no Source for files that are not text", () => { expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]); }); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts index 6ff5cd8d5a..f77162645e 100644 --- a/packages/app/src/file-panels/catalog.ts +++ b/packages/app/src/file-panels/catalog.ts @@ -21,7 +21,10 @@ const renderingPanels = [ }, ] as const satisfies readonly FilePanelCandidate[]; -/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */ +/** + * Paseo's editor, the floor for text files: every text file no other built-in claims has it, so a + * text file always opens with some panel. A built-in that claims a file owns its source view. + */ export const sourceFilePanel = { id: "builtin:source", builtin: true, @@ -34,7 +37,7 @@ 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. + * panels, then Source. Source is last so a plugin panel is the default over it. */ export function filePanelCatalog( pluginPanels: readonly Plugin[], diff --git a/packages/app/src/file-panels/choice/hook.ts b/packages/app/src/file-panels/choice/hook.ts index 0e51a69c4b..2c2fafe388 100644 --- a/packages/app/src/file-panels/choice/hook.ts +++ b/packages/app/src/file-panels/choice/hook.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from import { usePaneContext } from "@/panels/pane-context"; import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; import { useFilePanelAssociations } from "../associations"; -import { sourceFilePanel } from "../catalog"; import { useFilePanels, type FilePanel } from "../panels"; import { resolveFilePanel, type FilePanelFileKind } from "../resolve"; import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model"; @@ -29,9 +28,7 @@ export function useFilePanelChoice(input: { const { serverId, workspaceId, tabId } = usePaneContext(); const candidates = useFilePanels(input.serverId); const { associations, remember } = useFilePanelAssociations(); - const [choice] = useState( - () => new FilePanelChoiceModel({ remember, linePanelId: sourceFilePanel.id }), - ); + const [choice] = useState(() => new FilePanelChoiceModel({ remember })); // From the subscribed snapshot: React Compiler caches reads off the stable model object. const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey); const resolution = useMemo( diff --git a/packages/app/src/file-panels/choice/model.test.ts b/packages/app/src/file-panels/choice/model.test.ts index e9e5e83042..73e98e91a1 100644 --- a/packages/app/src/file-panels/choice/model.test.ts +++ b/packages/app/src/file-panels/choice/model.test.ts @@ -8,6 +8,11 @@ const rich: FilePanelCandidate = { builtin: false, files: { extensions: ["md", "markdown"] }, }; +const richTypeScript: FilePanelCandidate = { + id: "plugin:ts/outline", + builtin: false, + files: { extensions: ["ts"] }, +}; const markdown = "builtin:markdown"; const source = sourceFilePanel.id; @@ -22,7 +27,6 @@ function openTab( if (panelId === null) delete associations[selectorKey]; else associations[selectorKey] = panelId; }, - linePanelId: source, }); let targetKey = `/repo:${options.path ?? "notes.md"}`; let presentable = options.presentable ?? true; @@ -70,11 +74,7 @@ describe("FilePanelChoiceModel", () => { selectedPanelId: markdown, remember: false, }); - expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([ - markdown, - rich.id, - source, - ]); + expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([markdown, rich.id]); tab.model.dismissPicker(); expect(tab.picker()).toBeNull(); expect(tab.panel()).toBe(markdown); @@ -84,17 +84,13 @@ describe("FilePanelChoiceModel", () => { expect(openTab(associations).picker()).not.toBeNull(); }); - it("never asks between built-in panels, and switches between them", () => { + it("opens a file a built-in claims with that built-in alone and nothing to choose", () => { 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, - ]); + expect(tab.model.getSnapshot().openWith).toBeNull(); tab.model.openWith(source); - expect(tab.panel()).toBe(source); - expect(tab.picker()).toBeNull(); + expect(tab.panel()).toBe(markdown); }); it("opens a file only Source claims with Source and nothing to choose", () => { @@ -133,7 +129,7 @@ describe("FilePanelChoiceModel", () => { 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); + expect(openTab({ "ext:md": source }).panel()).toBe(markdown); const background = openTab({}, undefined, { presentable: false }); expect(background.picker()).toBeNull(); @@ -141,21 +137,29 @@ describe("FilePanelChoiceModel", () => { 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); + it("opens the built-in panel for a line target without asking, and again on each new one", () => { + const tab = openTab({ "ext:md": rich.id }, undefined, { lineNavigation: 1 }); + expect(tab.panel()).toBe(markdown); 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); + expect(tab.navigateToLine(2)).toBe(markdown); }); - it("opens Source for the same line target in a retargeted tab", () => { + it("opens Source for a line target in a file only Source and a plugin claim", () => { + const tab = openTab({ "ext:ts": richTypeScript.id }, [...filePanelCatalog([richTypeScript])], { + path: "index.ts", + }); + expect(tab.panel()).toBe(richTypeScript.id); + expect(tab.navigateToLine(1)).toBe(source); + }); + + it("opens the built-in panel 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); + tab.model.openWith(rich.id); + expect(tab.retarget("other.md")).toBe(markdown); }); it("starts a new open when the tab is retargeted", () => { diff --git a/packages/app/src/file-panels/choice/model.ts b/packages/app/src/file-panels/choice/model.ts index 52cc4072ce..c28daeca6e 100644 --- a/packages/app/src/file-panels/choice/model.ts +++ b/packages/app/src/file-panels/choice/model.ts @@ -30,15 +30,13 @@ export interface FilePanelChoiceSnapshot { /** 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. */ + /** Present only when the file has more than one candidate; drives "Open with…". */ 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. */ @@ -61,7 +59,8 @@ const EMPTY: FilePanelChoiceSnapshot = { * * 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. + * Built-in panels alone never ask. A line target opens the file's built-in panel, which shows + * lines, 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. */ @@ -70,6 +69,8 @@ export class FilePanelChoiceModel { private snapshot: FilePanelChoiceSnapshot = EMPTY; private asked = false; private openedLineNavigation: number | null = null; + /** A line target arrived before the file resolved; it opens the built-in once it does. */ + private lineTargetPending = false; constructor(private readonly deps: FilePanelChoiceDeps) {} @@ -85,11 +86,17 @@ export class FilePanelChoiceModel { if (source.targetKey !== current.targetKey) { this.asked = false; this.openedLineNavigation = null; + this.lineTargetPending = false; 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 }; + this.lineTargetPending = true; + } + if (this.lineTargetPending && source.resolution) { + this.lineTargetPending = false; + const builtin = source.resolution.candidates.find((candidate) => candidate.builtin); + if (builtin) current = { ...current, chosenPanelId: builtin.id, picker: null }; } const openWith = stableOpenWith(current.openWith, openWithState(source.resolution)); let picker = openWith ? current.picker : null; diff --git a/packages/app/src/file-panels/choice/open-with.tsx b/packages/app/src/file-panels/choice/open-with.tsx index b793b4f445..33a10aa254 100644 --- a/packages/app/src/file-panels/choice/open-with.tsx +++ b/packages/app/src/file-panels/choice/open-with.tsx @@ -1,17 +1,12 @@ 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"; @@ -22,7 +17,6 @@ import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./ 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 }); @@ -43,41 +37,6 @@ export function useFilePanelOpenWithMenu( 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, diff --git a/packages/app/src/file-panels/choice/presentation.ts b/packages/app/src/file-panels/choice/presentation.ts index ffe319b4fe..34ecc7265b 100644 --- a/packages/app/src/file-panels/choice/presentation.ts +++ b/packages/app/src/file-panels/choice/presentation.ts @@ -14,7 +14,7 @@ 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. */ +/** "Markdown", "Rich Markdown": the panel's title. */ export function filePanelTitle(t: TFunction, panel: FilePanel): string { return panel.builtin ? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`) diff --git a/packages/app/src/file-panels/choice/switcher-layout.test.ts b/packages/app/src/file-panels/choice/switcher-layout.test.ts deleted file mode 100644 index 196f03bf17..0000000000 --- a/packages/app/src/file-panels/choice/switcher-layout.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 16afae0fe9..0000000000 --- a/packages/app/src/file-panels/choice/switcher-layout.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 75aef62727..0000000000 --- a/packages/app/src/file-panels/choice/switcher.tsx +++ /dev/null @@ -1,138 +0,0 @@ -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/index.ts b/packages/app/src/file-panels/index.ts index 5219632fcb..2e2b156ebb 100644 --- a/packages/app/src/file-panels/index.ts +++ b/packages/app/src/file-panels/index.ts @@ -6,12 +6,7 @@ export { type FilePanelChoice, } from "./choice/hook"; export { FilePanelPicker } from "./choice/picker"; -export { FilePanelSwitcher } from "./choice/switcher"; -export { - FilePanelBarMenu, - useFilePanelOpenWithMenu, - type FilePanelOpenWithMenu, -} from "./choice/open-with"; +export { useFilePanelOpenWithMenu, type FilePanelOpenWithMenu } from "./choice/open-with"; export { DocumentFrame } from "./document-frame"; export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation"; export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook"; diff --git a/packages/app/src/file-panels/resolve.test.ts b/packages/app/src/file-panels/resolve.test.ts index b0712ff7c1..3c7372b441 100644 --- a/packages/app/src/file-panels/resolve.test.ts +++ b/packages/app/src/file-panels/resolve.test.ts @@ -70,6 +70,17 @@ describe("resolveFilePanel", () => { }); expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull(); }); + it("offers a built-in panel claiming every file only when no other built-in claims the file", () => { + const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" }; + expect(resolve({ candidates: [markdown, alternative, every] }).candidates).toEqual([ + markdown, + alternative, + ]); + expect(resolve({ candidates: [alternative, every] }).candidates).toEqual([alternative, every]); + expect( + resolve({ file: { ...file, kind: "binary" }, candidates: [markdown, every] }).candidates, + ).toEqual([]); + }); it("passes over a built-in panel for another kind of file, even with an association", () => { expect( resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel, diff --git a/packages/app/src/file-panels/resolve.ts b/packages/app/src/file-panels/resolve.ts index 6b0f2ffbd1..1c9fefb78a 100644 --- a/packages/app/src/file-panels/resolve.ts +++ b/packages/app/src/file-panels/resolve.ts @@ -99,6 +99,14 @@ export function claimsFilePanelSelectorKey( : matchesName("", value, selector); } +/** + * A built-in panel claiming every file of its kind (Source) is the floor: a candidate only for + * files no other built-in panel claims, never an alternative beside one. + */ +function isBuiltinFloor(candidate: FilePanelCandidate): boolean { + return candidate.builtin && candidate.files === "all"; +} + /** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */ export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean { return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin); @@ -112,11 +120,17 @@ export function resolveFilePanel(input: { choice?: string | null; }): FilePanelResolution { const { file, associations } = input; - const candidates = input.candidates.filter( + const matching = input.candidates.filter( (candidate) => (candidate.fileKind === undefined || candidate.fileKind === file.kind) && matchesFiles(file, candidate.files), ); + const claimedByBuiltin = matching.some( + (candidate) => candidate.builtin && candidate.files !== "all", + ); + const candidates = claimedByBuiltin + ? matching.filter((candidate) => !isBuiltinFloor(candidate)) + : matching; const filename = filePanelBasename(file.path); const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename)); const selectorKey = matchedFilename diff --git a/packages/app/src/file-panels/settings/rows.test.ts b/packages/app/src/file-panels/settings/rows.test.ts index 170ffba04e..4cfc89ec75 100644 --- a/packages/app/src/file-panels/settings/rows.test.ts +++ b/packages/app/src/file-panels/settings/rows.test.ts @@ -39,12 +39,12 @@ describe("buildFilePanelSettingsRows", () => { expect(rows(catalog())).toEqual([]); }); - it("lists a key a plugin panel claims, built-in first and Source last", () => { + it("lists a key a plugin panel claims with the built-in first and no Source", () => { const plugin = richMarkdown("host-a"); expect(rows(catalog(plugin))).toEqual([ { selectorKey: "ext:md", - candidates: [builtinMarkdown, plugin, source], + candidates: [builtinMarkdown, plugin], associatedPanelId: null, }, ]); @@ -77,19 +77,25 @@ describe("buildFilePanelSettingsRows", () => { it("offers a panel installed on several hosts once, as the first host has it", () => { const first = richMarkdown("host-a"); const [row] = rows(catalog(first, richMarkdown("host-b"))); - expect(row?.candidates).toEqual([builtinMarkdown, first, source]); + expect(row?.candidates).toEqual([builtinMarkdown, first]); }); it("keeps a remembered key that only built-in panels claim", () => { expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([ { selectorKey: "ext:markdown", - candidates: [builtinMarkdown, source], + candidates: [builtinMarkdown], associatedPanelId: source.id, }, ]); }); + it("offers only Source on a text key no other panel claims", () => { + expect(rows(catalog(), { "ext:ts": source.id })).toEqual([ + { selectorKey: "ext:ts", candidates: [source], associatedPanelId: source.id }, + ]); + }); + it("keeps a remembered key whose panel is on no connected host", () => { expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([ { selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" }, @@ -104,7 +110,7 @@ describe("buildFilePanelSettingsRows", () => { }; const keys = rows(catalog(named)).map((row) => row.selectorKey); expect(keys).toEqual(["name:README.md"]); - expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]); + expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named]); }); it("orders extensions before filenames, each alphabetically", () => { diff --git a/packages/app/src/file-panels/settings/rows.ts b/packages/app/src/file-panels/settings/rows.ts index b2f8bc1971..339e2b6562 100644 --- a/packages/app/src/file-panels/settings/rows.ts +++ b/packages/app/src/file-panels/settings/rows.ts @@ -40,10 +40,9 @@ export function buildFilePanelSettingsRows } /** - * The panels offered under a key, by the resolver's matching rule. A panel claiming every file of - * a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a - * file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file - * when it opens is ignored there. + * The panels offered under a key, by the resolver's matching rule. The built-in floor (Source) is + * offered only when no built-in panel claims the key. A remembered panel that does not fit the + * file when it opens is ignored there. */ function offeredCandidates( candidates: readonly Candidate[], @@ -55,7 +54,7 @@ function offeredCandidates( ); return candidates.filter((candidate) => candidate.files === "all" - ? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind) + ? !claiming.some((claimant) => claimant.builtin) : claiming.includes(candidate), ); } diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx index eb6179e08e..7339cc50e7 100644 --- a/packages/app/src/file-panels/source/panel.tsx +++ b/packages/app/src/file-panels/source/panel.tsx @@ -1,5 +1,13 @@ -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { useFileText } from "@getpaseo/plugin/client"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ComponentProps, + type ComponentType, +} from "react"; +import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -11,12 +19,12 @@ import { formatFileSize } from "@/utils/format-file-size"; import type { Theme } from "@/styles/theme"; import { FilePanelLoading } from "../loading"; import type { BuiltinFilePanelProps } from "../panels"; -import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; +import type { FileConflictAlertState } from "./conflict-alert"; import { FileEditorModel, getFileConflictCallout } from "./editor/model"; import { FileEditorView } from "./editor/view"; import { selectSourcePresentation } from "./readonly/presentation"; import { FileSourceView } from "./readonly/view"; -import { SourceStatusRow } from "./status"; +import { TextFileToolbar, type TextFileView } from "./toolbar"; // CodeMirror and native source rendering consume appearance as props. Subscribe at the // rendering leaf so theme updates reconfigure it without replacing the editor/model. @@ -40,19 +48,49 @@ const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance); /** The daemon refuses to write larger files. */ const MAX_EDITABLE_BYTES = 1024 * 1024; -/** - * Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file - * on native read-only, and refuses files above the display budget before reading them. - */ -export function SourcePanel(props: BuiltinFilePanelProps) { - const size = props.file.size; - const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" }); - if (presentation === "unsupported") return ; - if (isWeb && size <= MAX_EDITABLE_BYTES) return ; - return ; +/** A rendered view of the file's text: the panel's Preview, shown instead of its source. */ +export interface TextFilePreviewProps { + text: string; + target: BuiltinFilePanelProps["target"]; } -function TooLargeSource({ size }: { size: number }) { +type TextFilePanelProps = BuiltinFilePanelProps & { + /** The panel's Preview; with one, the toolbar switches between Preview and Source. */ + preview?: ComponentType; +}; + +/** + * What the Markdown, HTML and Source panels share: a toolbar over the file's Source view and, when + * the panel passes one, its Preview. Both views show the one read of the file and, while editing, the one editor buffer, so switching + * between them neither re-reads nor loses edits. Edits files up to 1 MiB on web; shows larger files + * and every file on native read-only, and refuses files above the display budget before reading. + */ +export function TextFilePanel(props: TextFilePanelProps) { + const size = props.file.size; + const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" }); + if (presentation === "unsupported") return ; + if (isWeb && size <= MAX_EDITABLE_BYTES) return ; + return ; +} + +/** + * The view a panel with a preview shows. It opens on Preview, and on Source for a line target; + * each later line target switches to Source again. + */ +function useTextFileView( + location: PluginFileLocation, +): [TextFileView, (view: TextFileView) => void] { + const lineTarget = location.lineStart ? location.revision : null; + const [view, setView] = useState(lineTarget === null ? "preview" : "source"); + const [shownLineTarget, setShownLineTarget] = useState(lineTarget); + if (lineTarget !== null && lineTarget !== shownLineTarget) { + setShownLineTarget(lineTarget); + setView("source"); + } + return [view, setView]; +} + +function TooLargeTextFile({ size }: { size: number }) { const { t } = useTranslation(); return ( @@ -62,14 +100,16 @@ function TooLargeSource({ size }: { size: number }) { ); } -function ReadonlySource({ +function ReadonlyTextFile({ file, location, target, + preview: Preview, presentation, -}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) { +}: TextFilePanelProps & { presentation: "highlighted" | "plain" }) { const text = useFileText(file); const { t } = useTranslation(); + const [view, setView] = useTextFileView(location); const sourceLocation = useMemo( () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), [location.lineEnd, location.lineStart, target.path], @@ -82,25 +122,35 @@ function ReadonlySource({ ); } + const showPreview = Preview !== undefined && view === "preview"; return ( - - + {showPreview ? ( + + ) : ( + + )} ); } -function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) { +function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) { const { settings } = useAppSettings(); const { t } = useTranslation(); const [model] = useState(() => new FileEditorModel({ file })); - const [editorReady, setEditorReady] = useState(false); + const [view, setView] = useTextFileView(location); const [cursor, setCursor] = useState({ line: 1, column: 1 }); const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); @@ -130,7 +180,6 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) }, [model, snapshot.modified, t]); const handleOverwrite = useCallback(() => void model.overwrite(), [model]); const handleRetry = useCallback(() => void model.reload(), [model]); - const handleReady = useCallback(() => setEditorReady(true), []); const editorLocation = useMemo( () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), [location.lineEnd, location.lineStart, target.path], @@ -159,11 +208,23 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) onOverwrite: handleOverwrite, }; } + const showPreview = Preview !== undefined && view === "preview"; return ( - {conflict ? : null} - - + {showPreview ? ( + + ) : ( + - {editorReady ? null : } - - + )} + + ); +} + +/** The editor over the panel's buffer. Each mount shows Loading until the editor shows the text. */ +function SourceEditor(props: Omit, "onReady">) { + const [ready, setReady] = useState(false); + const handleReady = useCallback(() => setReady(true), []); + return ( + + + {ready ? null : } ); } diff --git a/packages/app/src/file-panels/source/status.tsx b/packages/app/src/file-panels/source/status.tsx deleted file mode 100644 index a3582f3c5d..0000000000 --- a/packages/app/src/file-panels/source/status.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import type { Theme } from "@/styles/theme"; -import { formatFileSize } from "@/utils/format-file-size"; -import type { FileEditorStatus } from "./editor/model"; - -const ThemedSpinner = withUnistyles(LoadingSpinner); -const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */ -export function SourceStatusRow({ - size, - lineCount, - editorStatus, - vimMode, - cursor, -}: { - size: number; - lineCount: number; - editorStatus?: FileEditorStatus; - vimMode?: string | null; - cursor?: { line: number; column: number }; -}) { - const { t } = useTranslation(); - return ( - - - - {formatFileSize(size)} - - - {t("panels.file.editor.lines", { count: lineCount })} - - - - {editorStatus === "dirty" ? ( - - ) : null} - {editorStatus === "saving" ? ( - <> - - {t("panels.file.editor.saving")} - - ) : null} - {editorStatus === "error" ? ( - {t("panels.file.editor.saveFailed")} - ) : null} - {vimMode ? ( - - {vimMode} - - ) : null} - {cursor ? ( - - Ln {cursor.line}, Col {cursor.column} - - ) : null} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - row: { - flexShrink: 0, - height: 28, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - metadata: { - flex: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - status: { - flexShrink: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, - whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm }, - error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm }, - dirtyDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.foregroundExtraMuted, - }, - vim: { - color: theme.colors.foregroundMuted, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.sm, - }, -})); diff --git a/packages/app/src/file-panels/source/toolbar.tsx b/packages/app/src/file-panels/source/toolbar.tsx new file mode 100644 index 0000000000..43cb6f7291 --- /dev/null +++ b/packages/app/src/file-panels/source/toolbar.tsx @@ -0,0 +1,163 @@ +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 type { Theme } from "@/styles/theme"; +import { formatFileSize } from "@/utils/format-file-size"; +import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; +import type { FileEditorStatus } from "./editor/model"; + +const ThemedSpinner = withUnistyles(LoadingSpinner); +const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export type TextFileView = "preview" | "source"; + +/** + * The text file panel's toolbar: the file's size and lines, the save state, the vim mode and the + * cursor, the Preview|Source control when the panel has a preview, and a conflict under it. + */ +export function TextFileToolbar({ + size, + lineCount, + view, + onViewChange, + editorStatus, + cursor, + vimMode, + conflict, +}: { + size: number; + lineCount: number; + view?: TextFileView; + onViewChange?(view: TextFileView): void; + editorStatus?: FileEditorStatus; + cursor?: { line: number; column: number }; + vimMode?: string | null; + conflict?: FileConflictAlertState | null; +}) { + const { t } = useTranslation(); + const views = [ + { + 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 ( + + + + + + {formatFileSize(size)} + + + {t("panels.file.editor.lines", { count: lineCount })} + + + + {editorStatus === "dirty" ? ( + + ) : null} + {editorStatus === "saving" ? ( + <> + + {t("panels.file.editor.saving")} + + ) : null} + {editorStatus === "error" ? ( + {t("panels.file.editor.saveFailed")} + ) : null} + {vimMode ? ( + + {vimMode} + + ) : null} + {cursor ? ( + + Ln {cursor.line}, Col {cursor.column} + + ) : null} + + {view && onViewChange ? ( + + ) : null} + + + {conflict ? : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + chrome: { + flexShrink: 0, + }, + row: { + height: "100%", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + }, + metadata: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + status: { + flexShrink: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm }, + error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm }, + dirtyDot: { + width: 6, + height: 6, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.foregroundExtraMuted, + }, + vim: { + color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 388f9be16f..643bd44691 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1928,7 +1928,6 @@ export const ar: TranslationResources = { alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}", alwaysUseFilename: "استخدام دائمًا لـ {{filename}}", option: "{{title}} — {{owner}}", - actions: "إجراءات الملف", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1947,6 +1946,8 @@ export const ar: TranslationResources = { notFound: "الملف غير موجود", failedToLoadPreview: "فشل تحميل معاينة الملف", editor: { + preview: "معاينة", + source: "المصدر", fileSize: "حجم الملف {{size}}", lines: "{{count}} سطر", editorStatus: "حالة المحرر {{status}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d64c19871d..6224b848f1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1953,7 +1953,6 @@ export const en = { alwaysUseExtension: "Always use for .{{extension}}", alwaysUseFilename: "Always use for {{filename}}", option: "{{title}} — {{owner}}", - actions: "File actions", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1972,6 +1971,8 @@ export const en = { notFound: "File not found", failedToLoadPreview: "Failed to load file preview", editor: { + preview: "Preview", + source: "Source", fileSize: "File size {{size}}", lines: "{{count}} lines", editorStatus: "Editor status {{status}}", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 27c4ab8404..b2718cd5a4 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1976,7 +1976,6 @@ export const es: TranslationResources = { alwaysUseExtension: "Usar siempre para .{{extension}}", alwaysUseFilename: "Usar siempre para {{filename}}", option: "{{title}} — {{owner}}", - actions: "Acciones del archivo", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1995,6 +1994,8 @@ export const es: TranslationResources = { notFound: "No se encontró el archivo", failedToLoadPreview: "No se pudo cargar la vista previa del archivo", editor: { + preview: "Vista previa", + source: "Código fuente", fileSize: "Tamaño {{size}}", lines: "{{count}} líneas", editorStatus: "Estado del editor: {{status}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 29464ca9e2..c750712bdd 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1974,7 +1974,6 @@ export const fr: TranslationResources = { alwaysUseExtension: "Toujours utiliser pour .{{extension}}", alwaysUseFilename: "Toujours utiliser pour {{filename}}", option: "{{title}} — {{owner}}", - actions: "Actions du fichier", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1993,6 +1992,8 @@ export const fr: TranslationResources = { notFound: "Fichier introuvable", failedToLoadPreview: "Impossible de charger l’aperçu du fichier", editor: { + preview: "Aperçu", + source: "Source", fileSize: "Taille du fichier : {{size}}", lines: "{{count}} lignes", editorStatus: "État de l’éditeur : {{status}}", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e3dba618ee..89ae1c5151 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1947,7 +1947,6 @@ export const ja: TranslationResources = { alwaysUseExtension: ".{{extension}} に常に使用", alwaysUseFilename: "{{filename}} に常に使用", option: "{{title}} — {{owner}}", - actions: "ファイルの操作", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1966,6 +1965,8 @@ export const ja: TranslationResources = { notFound: "ファイルが見つかりません", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", editor: { + preview: "プレビュー", + source: "ソース", fileSize: "ファイルサイズ {{size}}", lines: "{{count}} 行", editorStatus: "エディターの状態 {{status}}", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0c82504aaa..6e96723260 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1938,7 +1938,6 @@ export const ko: TranslationResources = { alwaysUseExtension: ".{{extension}}에 항상 사용", alwaysUseFilename: "{{filename}}에 항상 사용", option: "{{title}} — {{owner}}", - actions: "파일 작업", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1957,6 +1956,8 @@ export const ko: TranslationResources = { notFound: "파일을 찾을 수 없습니다", failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다", editor: { + preview: "미리보기", + source: "소스", fileSize: "파일 크기 {{size}}", lines: "{{count}} 라인", editorStatus: "편집기 상태 {{status}}", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 842dea8f5e..0e09fd91dc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1961,7 +1961,6 @@ export const ptBR: TranslationResources = { alwaysUseExtension: "Sempre usar para .{{extension}}", alwaysUseFilename: "Sempre usar para {{filename}}", option: "{{title}} — {{owner}}", - actions: "Ações do arquivo", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1980,6 +1979,8 @@ export const ptBR: TranslationResources = { notFound: "Arquivo não encontrado", failedToLoadPreview: "Falha ao carregar prévia do arquivo", editor: { + preview: "Prévia", + source: "Fonte", fileSize: "Tamanho {{size}}", lines: "{{count}} linhas", editorStatus: "Status do editor: {{status}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7a5881c1b7..95f9718313 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1960,7 +1960,6 @@ export const ru: TranslationResources = { alwaysUseExtension: "Всегда использовать для .{{extension}}", alwaysUseFilename: "Всегда использовать для {{filename}}", option: "{{title}} — {{owner}}", - actions: "Действия с файлом", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1979,6 +1978,8 @@ export const ru: TranslationResources = { notFound: "Файл не найден", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", editor: { + preview: "Предпросмотр", + source: "Исходный текст", fileSize: "Размер файла {{size}}", lines: "Строк: {{count}}", editorStatus: "Состояние редактора: {{status}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6cd2be6e81..aa7f64b2ef 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1906,7 +1906,6 @@ export const zhCN: TranslationResources = { alwaysUseExtension: "始终用于 .{{extension}}", alwaysUseFilename: "始终用于 {{filename}}", option: "{{title}} — {{owner}}", - actions: "文件操作", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1925,6 +1924,8 @@ export const zhCN: TranslationResources = { notFound: "找不到文件", failedToLoadPreview: "加载文件预览失败", editor: { + preview: "预览", + source: "源代码", fileSize: "文件大小 {{size}}", lines: "{{count}} 行", editorStatus: "编辑器状态 {{status}}", diff --git a/plugin-examples/markdown-panel/README.md b/plugin-examples/markdown-panel/README.md index ab5e2b9941..512f5f9be3 100644 --- a/plugin-examples/markdown-panel/README.md +++ b/plugin-examples/markdown-panel/README.md @@ -15,8 +15,9 @@ The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo, wrapping. It reads the file once with `readText`, saves 500 ms after the last edit with `writeText`, and reports unsaved edits with `tab.setModified`. When the file changes on disk with nothing unsaved, it reads again; with unsaved edits, or when a write rejects with -`PluginFileConflictError`, it offers **Reload** and **Overwrite**. The bar's switcher still offers -Paseo's Markdown and Source panels. Native shows selectable, read-only text through `useFileText`. +`PluginFileConflictError`, it offers **Reload** and **Overwrite**. **Open with…** in the tab menu +switches the tab back to Paseo's Markdown panel. Native shows selectable, read-only text through +`useFileText`. Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native path uses React Native primitives. DOM integration lives in `client/web.ts`. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 3729fa4e0e..273c32bc17 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1885,8 +1885,8 @@ Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC Register a component that renders a file as the body of its file tab. Your component receives a handle to the file on the selected host, including remote hosts. It reads the file, shows its own -loading state, and writes the file when the user edits it. The tab's bar shows the panel -switcher, the overflow menu, and a dot while your panel reports unsaved changes. +loading state, and writes the file when the user edits it. The tab shows a dot while your panel +reports unsaved changes. ```tsx import type { PluginClientContext } from "@getpaseo/plugin/client"; @@ -1906,13 +1906,13 @@ export default function contribute(client: PluginClientContext) { `addFilePanel` takes a `PluginFilePanelContribution` and returns an idempotent cleanup function. Paseo also removes the registration when the client entry is unloaded. -| Field | Required | Meaning | -| ----------- | -------- | -------------------------------------------------------------------------- | -| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. | -| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. | -| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | -| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. | -| `Component` | Yes | Component receiving `PluginFilePanelProps`. | +| Field | Required | Meaning | +| ----------- | -------- | ------------------------------------------------------------------------ | +| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. | +| `title` | Yes | Non-empty label in the picker, the Open with menu, and Settings > Files. | +| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | +| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. | +| `Component` | Yes | Component receiving `PluginFilePanelProps`. | Extensions have no leading dot and match without case sensitivity. Filenames match the exact basename, including case. Selectors contain no paths, globs, or wildcards. Use `extensions: []` @@ -2002,8 +2002,8 @@ A panel that holds unsaved edits reads with `readText()` once, then handles `wat 5. On `PluginFileConflictError`, show the same conflict. 6. On `{ type: "deleted" }` or `PluginFileDeletedError`, tell the user the file is gone. -Write pending edits before the component unmounts: switching panels or closing the tab unmounts -it. Paseo asks before closing a tab whose panel reports unsaved changes. The +Write pending edits before the component unmounts: opening the tab with another panel or closing +the tab unmounts it. Paseo asks before closing a tab whose panel reports unsaved changes. The [Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel) example implements these steps. @@ -2011,28 +2011,30 @@ example implements these steps. A plugin panel is a candidate for every file its selector matches, text or binary. The built-in panels require the host's file kind: Markdown, HTML, and **Source** open text files; Image opens -images. Candidates are ordered: the built-in Markdown, HTML, and Image panels, then plugin panels -in host catalog order, then **Source**, which every text file has. Source opens a file by default -only when nothing else claims it. +images. The Markdown and HTML panels each have a Preview and a Source view of the file, switched +in their toolbar. **Source**, Paseo's editor, is the built-in panel for text files that no other +built-in panel claims; it is never offered beside another built-in panel. Candidates are ordered: +the built-in panel that claims the file, then plugin panels in host catalog order, then Source +when no other built-in claims the file. A remembered association selects its panel when that panel is available on the current host. Otherwise Paseo opens the first candidate. When a plugin panel is among several candidates and no -association applies, Paseo asks once per tab open, over the panel it already opened. Built-in -panels alone never ask: a Markdown file opens with Markdown and offers Source in the switcher. With -a Markdown plugin installed, the picker offers **Markdown — Paseo**, **Rich Markdown — -markdown-panel**, and **Source — Paseo**. A plugin that claims a text extension competes with -Source, so its first open asks; a plugin that alone claims a binary file opens without asking. +association applies, Paseo asks once per tab open, over the panel it already opened. A file with +one candidate never asks. With a Markdown plugin installed, the picker offers **Markdown — Paseo** +and **Rich Markdown — markdown-panel**. A plugin that claims a text extension no built-in panel +claims competes with Source, so its first open asks; a plugin that alone claims a binary file +opens without asking. **Don't ask me again** remembers the choice on this device for the extension. When a candidate matches an exact filename, the choice applies to that basename instead. An unavailable association is ignored on that host and retained for hosts where its panel is installed. -Users switch the current tab with the bar's panel switcher, which shows up to three panels side by -side (two on a phone) and a menu beyond that, or with **Open with…** in the tab menu. That choice -wins over the remembered association for this tab until it is retargeted. **Always use**, in the -bar's overflow menu and in Open with, remembers the shown panel. Opening a file at a line, from a -link or a search result, switches the tab to Source. Files with no candidate show **Binary preview -unavailable**; binary files have no Source panel. +Users choose a panel three ways: the picker on first open, **Open with…** in the tab menu, and +**Settings > Files**. Open with switches the current tab and wins over the remembered association +for this tab until it is retargeted; its **Always use** remembers the shown panel. Opening a file +at a line, from a link or a search result, switches the tab to its built-in panel, which shows the +line in its Source view. Files with no candidate show **Binary preview unavailable**; binary files +have no Source panel. ### DocumentFrame diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index fb5d1c9f70..8865eb4310 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1637,7 +1637,7 @@ Agent 快照字段: ## 文件面板 {#file-panels} -注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。标签页栏会显示面板切换器、溢出菜单,以及在面板报告有未保存更改时显示一个圆点。 +注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。面板报告有未保存更改时,标签页会显示一个圆点。 ```tsx import type { PluginClientContext } from "@getpaseo/plugin/client"; @@ -1659,7 +1659,7 @@ export default function contribute(client: PluginClientContext) { | 字段 | 必填 | 含义 | | ----------- | ---- | -------------------------------------------------------------------- | | `id` | 是 | 插件内唯一的 ID,匹配 `^[a-z][a-z0-9-]*$`。 | -| `title` | 是 | 显示在面板切换器、选择器和 **打开方式…** 菜单中的非空标签。 | +| `title` | 是 | 显示在选择器、**打开方式…** 菜单和 **设置 > 文件** 中的非空标签。 | | `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 | | `files` | 是 | `{ extensions: readonly string[]; filenames?: readonly string[] }`。 | | `Component` | 是 | 接收 `PluginFilePanelProps` 的组件。 | @@ -1732,17 +1732,17 @@ hook 的重新读取会采用磁盘上的版本,因此下一次写入会覆盖 5. 遇到 `PluginFileConflictError` 时,显示同样的冲突。 6. 收到 `{ type: "deleted" }` 或遇到 `PluginFileDeletedError` 时,告诉用户文件已不存在。 -请在组件卸载之前写入待保存的编辑:切换面板或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。[Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)示例实现了这些步骤。 +请在组件卸载之前写入待保存的编辑:用其他面板打开标签页或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。[Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)示例实现了这些步骤。 ### 选择与打开方式 {#selection-and-open-with} -插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 **源代码** 打开文本文件,**图片** 打开图片。候选面板的顺序是:内置的 Markdown、HTML 和图片面板,然后按主机目录顺序排列的插件面板,最后是每个文本文件都有的 **源代码**。只有没有其他面板认领文件时,源代码才会默认打开它。 +插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 **源代码** 打开文本文件,**图片** 打开图片。Markdown 和 HTML 面板各自有文件的预览视图和源代码视图,在面板的工具栏中切换。**源代码** 是 Paseo 的编辑器,是没有其他内置面板认领的文本文件的内置面板;它从不与其他内置面板一起提供。候选面板的顺序是:认领该文件的内置面板,然后按主机目录顺序排列的插件面板,最后是没有其他内置面板认领该文件时的源代码。 -记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有内置面板时从不询问:Markdown 文件用 Markdown 打开,并在切换器中提供源代码。安装了一个 Markdown 插件后,选择器会提供 **Markdown — Paseo**、**Rich Markdown — markdown-panel** 和 **源代码 — Paseo**。认领文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。 +记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有一个候选面板的文件从不询问。安装了一个 Markdown 插件后,选择器会提供 **Markdown — Paseo** 和 **Rich Markdown — markdown-panel**。认领某个没有内置面板认领的文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。 **不再询问** 会在此设备上为该扩展名记住这个选择。当某个候选面板匹配了确切的文件名时,该选择改为应用于那个 basename。不可用的关联在该主机上会被忽略,并为安装了其面板的主机保留。 -用户可以用栏中的面板切换器切换当前标签页:它并排显示最多三个面板(手机上为两个),更多的面板放在菜单中;也可以用标签页菜单中的 **打开方式…**。对于这个标签页,该选择优先于记住的关联,直到标签页被重新定向。栏的溢出菜单和打开方式中的 **始终用于** 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到源代码。没有候选面板的文件显示 **二进制预览不可用**;二进制文件没有源代码面板。 +用户有三种方式选择面板:第一次打开时的选择器、标签页菜单中的 **打开方式…**,以及 **设置 > 文件**。打开方式会切换当前标签页,并且对于这个标签页优先于记住的关联,直到标签页被重新定向;其中的 **始终用于** 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到它的内置面板,并在源代码视图中显示该行。没有候选面板的文件显示 **二进制预览不可用**;二进制文件没有源代码面板。 ### DocumentFrame {#documentframe}