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.
This commit is contained in:
Mohamed Boudra
2026-10-22 13:04:59 +07:00
committed by GitHub
parent bdae614e95
commit 76b32544c6
42 changed files with 569 additions and 645 deletions
+5 -3
View File
@@ -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.
@@ -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();
+52 -17
View File
@@ -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<void> {
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<void> {
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
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<void> {
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<ReturnType<typeof installDaemonWebSocketGate>>,
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<void> {
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();
});
@@ -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);
}
@@ -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");
@@ -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");
@@ -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();
@@ -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");
@@ -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";
-58
View File
@@ -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 (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.spacer} />
{modified ? (
<View
style={styles.modifiedDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
testID="file-panel-modified"
/>
) : null}
<FilePanelSwitcher choice={choice} />
<FilePanelBarMenu choice={choice} />
</View>
</PaneContentToolbar>
</View>
);
}
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,
},
}));
-2
View File
@@ -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 (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelPicker choice={choice} />
<FilePanelBar choice={choice} modified={modified} />
<MountedFilePanel
key={`${panel.id}:${targetKey}`}
panel={panel}
+23 -13
View File
@@ -1,4 +1,4 @@
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { useFileUrl } from "@getpaseo/plugin/client";
import { useCallback, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -8,23 +8,35 @@ import { FileHtmlPreview } from "./html-preview";
import { FilePanelLoading } from "./loading";
import { FileMarkdownPreview } from "./markdown-preview";
import type { BuiltinFilePanelProps } from "./panels";
import { SourcePanel } from "./source/panel";
import { TextFilePanel, type TextFilePreviewProps } from "./source/panel";
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
const text = useFileText(file);
/** Source: the text file panel with no Preview, for text files no other built-in panel claims. */
function SourcePanel(props: BuiltinFilePanelProps) {
return <TextFilePanel {...props} />;
}
/** Markdown: the text file panel with the rendered document as its Preview. */
function MarkdownPanel(props: BuiltinFilePanelProps) {
return <TextFilePanel {...props} preview={MarkdownPreview} />;
}
/** HTML: the text file panel with the sandboxed page as its Preview. */
function HtmlPanel(props: BuiltinFilePanelProps) {
return <TextFilePanel {...props} preview={HtmlPreview} />;
}
/** 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 <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
return (
<View style={styles.container}>
<ScrollView style={styles.container} showsVerticalScrollIndicator>
<View onLayout={handleLayout}>
<FileMarkdownPreview
source={text.text}
source={text}
filePath={target.path}
workspaceRoot={target.workspaceRoot}
client={client}
@@ -37,13 +49,11 @@ function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
);
}
function HtmlPanel({ file }: BuiltinFilePanelProps) {
const text = useFileText(file);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
/** The page owns its scrolling, so there is no ScrollView around it. */
function HtmlPreview({ text }: TextFilePreviewProps) {
return (
<View style={styles.container}>
<FileHtmlPreview html={text.text} testID="file-html-preview" />
<FileHtmlPreview html={text} testID="file-html-preview" />
</View>
);
}
+18 -4
View File
@@ -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([]);
});
+5 -2
View File
@@ -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<Plugin extends FilePanelCandidate>(
pluginPanels: readonly Plugin[],
+1 -4
View File
@@ -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<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
);
const [choice] = useState(() => new FilePanelChoiceModel<FilePanel>({ remember }));
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
@@ -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", () => {
+12 -5
View File
@@ -30,15 +30,13 @@ export interface FilePanelChoiceSnapshot<Candidate> {
/** The panel picked for this tab open. Resolution prefers it over the association. */
chosenPanelId: string | null;
picker: FilePanelPickerState<Candidate> | 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<Candidate> | 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<never> = {
*
* 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<Candidate extends FilePanelCandidate> {
private snapshot: FilePanelChoiceSnapshot<Candidate> = 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<Candidate extends FilePanelCandidate> {
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;
@@ -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 (
<DropdownMenu compactMode="sheet">
<ToolbarButton kind="menu" label={label} compact={compact} testID="file-panel-bar-menu">
<ThemedEllipsis
size={paneContentToolbarIconSize(compact)}
uniProps={extraMutedIconColorMapping}
/>
</ToolbarButton>
<DropdownMenuContent
align="end"
width={240}
sheetTitle={label}
testID="file-panel-bar-menu-surface"
>
<AlwaysUseItem
choice={choice}
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
selected={openWith.remembered}
testID="file-panel-bar-menu-always-use"
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function useOpenWithMenu(
choice: FilePanelChoice | null,
testIDBase: string,
@@ -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}`)
@@ -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");
});
});
@@ -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<Candidate> {
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<Candidate>(
openWith: FilePanelOpenWithState<Candidate> | null,
{ compact }: { compact: boolean },
): FilePanelSwitcherState<Candidate> | 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,
};
}
@@ -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" ? (
<SegmentedSwitcher switcher={switcher} onSelect={select} />
) : (
<MenuSwitcher switcher={switcher} onSelect={select} />
);
}
interface SwitcherProps {
switcher: FilePanelSwitcherState<FilePanel>;
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 (
<SegmentedControl
size="xs"
value={switcher.activePanelId}
onValueChange={onSelect}
options={options}
testID="file-panel-switcher"
/>
);
}
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 (
<DropdownMenu>
<DropdownMenuTrigger
style={toolbarLabelTriggerStyle}
accessibilityRole="button"
accessibilityLabel={t("panels.file.filePanels.openWith")}
testID="file-panel-switcher"
>
{(state) => (
<>
<Text
style={toolbarLabelTriggerTextStyle(isToolbarLabelTriggerHighlighted(state))}
numberOfLines={1}
>
{activeTitle}
</Text>
<ToolbarLabelTriggerIcon>
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
</ToolbarLabelTriggerIcon>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="file-panel-switcher-menu">
{switcher.candidates.map((panel) => (
<SwitcherMenuItem
key={panel.id}
panel={panel}
selected={panel.id === switcher.activePanelId}
onSelect={onSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
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(
() => <ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />,
[panel],
);
return (
<MenuItem
selected={selected}
leading={leading}
onSelect={handleSelect}
testID={`file-panel-switcher-${panel.id}`}
>
{filePanelTitle(t, panel)}
</MenuItem>
);
}
+1 -6
View File
@@ -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";
@@ -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,
+15 -1
View File
@@ -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<Candidate extends FilePanelCandidate>(input: {
choice?: string | null;
}): FilePanelResolution<Candidate> {
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
@@ -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", () => {
@@ -40,10 +40,9 @@ export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>
}
/**
* 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<Candidate extends FilePanelCandidate>(
candidates: readonly Candidate[],
@@ -55,7 +54,7 @@ function offeredCandidates<Candidate extends FilePanelCandidate>(
);
return candidates.filter((candidate) =>
candidate.files === "all"
? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind)
? !claiming.some((claimant) => claimant.builtin)
: claiming.includes(candidate),
);
}
+104 -40
View File
@@ -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 <TooLargeSource size={size} />;
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableSource {...props} />;
return <ReadonlySource {...props} presentation={presentation} />;
/** 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<TextFilePreviewProps>;
};
/**
* 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 <TooLargeTextFile size={size} />;
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableTextFile {...props} />;
return <ReadonlyTextFile {...props} presentation={presentation} />;
}
/**
* 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<TextFileView>(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 (
<View style={styles.centerState} testID="file-source-too-large">
@@ -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({
</View>
);
}
const showPreview = Preview !== undefined && view === "preview";
return (
<View style={styles.container}>
<ThemedFileSourceView
content={text.text}
filename={target.path}
location={sourceLocation}
navigationRevision={location.revision}
presentation={presentation}
<TextFileToolbar
size={file.size}
lineCount={text.text.split("\n").length}
view={Preview ? view : undefined}
onViewChange={Preview ? setView : undefined}
/>
<SourceStatusRow size={file.size} lineCount={text.text.split("\n").length} />
{showPreview ? (
<Preview text={text.text} target={target} />
) : (
<ThemedFileSourceView
content={text.text}
filename={target.path}
location={sourceLocation}
navigationRevision={location.revision}
presentation={presentation}
/>
)}
</View>
);
}
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<string | null>(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 (
<View style={styles.container}>
{conflict ? <FileConflictAlert state={conflict} /> : null}
<View style={styles.editor}>
<ThemedFileEditorView
<TextFileToolbar
size={file.size}
lineCount={snapshot.content.split("\n").length}
view={Preview ? view : undefined}
onViewChange={Preview ? setView : undefined}
editorStatus={snapshot.status}
cursor={showPreview ? undefined : cursor}
vimMode={showPreview ? null : vimMode}
conflict={conflict}
/>
{showPreview ? (
<Preview text={snapshot.content} target={target} />
) : (
<SourceEditor
model={model}
filename={target.path}
location={editorLocation}
@@ -171,17 +232,20 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
vimEnabled={settings.vimKeybindings}
onCursorChange={setCursor}
onVimModeChange={setVimMode}
onReady={handleReady}
/>
{editorReady ? null : <FilePanelLoading />}
</View>
<SourceStatusRow
size={file.size}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
vimMode={vimMode}
cursor={cursor}
/>
)}
</View>
);
}
/** The editor over the panel's buffer. Each mount shows Loading until the editor shows the text. */
function SourceEditor(props: Omit<ComponentProps<typeof ThemedFileEditorView>, "onReady">) {
const [ready, setReady] = useState(false);
const handleReady = useCallback(() => setReady(true), []);
return (
<View style={styles.editor}>
<ThemedFileEditorView {...props} onReady={handleReady} />
{ready ? null : <FilePanelLoading />}
</View>
);
}
@@ -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 (
<View style={styles.row} testID="file-source-status">
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus ? t("panels.file.editor.editorStatus", { status: editorStatus }) : undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
</View>
);
}
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,
},
}));
@@ -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 (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus
? t("panels.file.editor.editorStatus", { status: editorStatus })
: undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
{view && onViewChange ? (
<SegmentedControl
size="xs"
value={view}
onValueChange={onViewChange}
testID="file-preview-mode"
options={views}
/>
) : null}
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
</View>
);
}
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,
},
}));
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+2 -1
View File
@@ -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}}",
+3 -2
View File
@@ -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`.
+27 -25
View File
@@ -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
+6 -6
View File
@@ -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}