mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
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:
+5
-3
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user