feat(plugins): file panels

Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the
file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor,
which claims every text file and is always the last candidate. The panel is the tab body;
the bar shows the document's status and a switcher between the file's panels (side by side
up to three, two on a phone, a menu beyond that).

Paseo asks which panel opens a file only when a plugin panel is among several candidates,
once per open, and remembers the answer per extension or exact filename when told to. Open
with in the tab menu and the switcher change the tab's panel; Always use remembers it.
Opening a file at a line switches the tab to Source.

Panels read and write the host's document, so dirty state, autosave and conflicts stay
Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer.

Replaces the file tab's Preview/Source mode toggle.
This commit is contained in:
Mohamed Boudra
2026-10-19 13:40:04 +02:00
parent dd18bc9001
commit 18d6d00d66
74 changed files with 3348 additions and 343 deletions
+16 -14
View File
@@ -55,6 +55,10 @@ 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"),
@@ -62,11 +66,9 @@ function htmlPreview(page: Page) {
};
}
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
exact: true,
});
/** Switches the file tab to one of its panels from the bar's switcher. */
async function selectFilePanel(page: Page, title: string): Promise<void> {
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
@@ -484,9 +486,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = page.getByTestId("file-preview-mode");
const modeControl = filePanelSwitcher(page);
await expect(modeControl).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -548,7 +550,7 @@ test.describe("CodeMirror workspace file editing", () => {
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
@@ -680,7 +682,7 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
test("refreshes Markdown and images while switching between Markdown and Source", async ({
page,
withWorkspace,
}) => {
@@ -695,15 +697,15 @@ test.describe("CodeMirror workspace file editing", () => {
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFileView(page, "Preview");
await selectFilePanel(page, "Markdown");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
@@ -786,10 +788,10 @@ test.describe("CodeMirror workspace file editing", () => {
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFileView(page, "Source");
await selectFilePanel(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFileView(page, "Preview");
await selectFilePanel(page, "HTML");
await expect(preview.host).toBeVisible();
});
@@ -14,8 +14,9 @@ async function openMarkdownPreview(page: Page) {
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Markdown", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
@@ -0,0 +1,156 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
expectBuiltinMarkdown,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
async function choosePanel(page: Page, option: string, options: { remember: boolean }) {
await page.getByRole("radio", { name: option, exact: true }).click();
if (options.remember) {
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
}
await page.getByRole("button", { name: "Use", exact: true }).click();
await expect(filePanelPicker(page)).toBeHidden();
}
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function toggleAlwaysUseFromFileBar(page: Page) {
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
async function expectSwitcherPanels(page: Page, titles: string[]) {
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "file-panel-builtins-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
await expectSwitcherPanels(page, ["Markdown", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-wide");
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
} finally {
await workspace.cleanup();
}
});
test("asks once which panel opens Markdown and remembers it when told to", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-picker-",
repo: {
files: ["first", "second", "third", "fourth"].map((name) => ({
path: `${name}.md`,
content: `# ${name} heading\n`,
})),
},
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
// First open: the built-in renders under the picker.
await openMarkdown(page, "first.md");
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
// Don't ask me again: the plugin renders and later opens use it silently.
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true });
await expectPluginMarkdown(page, "first.md");
expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID });
await openMarkdown(page, "second.md");
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
// Forgetting from the file bar keeps the tab on the plugin.
await toggleAlwaysUseFromFileBar(page);
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
// An unremembered choice applies to this tab only; the next open asks again.
await openMarkdown(page, "third.md");
await expect(filePanelPicker(page)).toBeVisible();
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false });
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
// "Open with…" switches the open tab back to the built-in in place.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
await expect(filePanelPicker(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(filePanelPicker(page)).toBeHidden();
await expectBuiltinMarkdown(page, "fourth heading");
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
await capture(page, testInfo, "file-panel-picker-compact");
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
@@ -0,0 +1,185 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
const PLUGIN_ID = "file-panel-e2e";
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
const SOURCE = `import React, { useEffect, useState } from "react";
import { Text, View, Pressable } from "react-native";
import { usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import { useQueryClient } from "@tanstack/react-query";
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
function Pdf({ document, file, workspaceId }) {
usePaseo();
useQueryClient();
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
const [urlBytes, setUrlBytes] = useState(-1);
useEffect(() => {
fetch(document.url).then(response => response.arrayBuffer()).then(bytes => setUrlBytes(bytes.byteLength));
}, [document.url]);
return <View style={{ flex: 1 }}>
<Text>PDF plugin: {file.name}</Text>
<Text>Path {file.path}</Text>
<Text>Bytes {document.bytes.length}; URL bytes {urlBytes}</Text>
<Text>Workspace {workspace?.id}</Text>
<DocumentFrame url={document.url} testID="plugin-pdf-frame" />
</View>;
}
function Markdown({ document }) {
return <View>
<Text>Plugin Markdown: {document.text}</Text>
<Text>{document.editable ? "Plugin document editable" : "Plugin document readonly"}</Text>
<Pressable accessibilityRole="button" onPress={() => document.setText("# Saved by plugin\\n")}>
<Text>Edit Markdown through plugin</Text>
</Pressable>
</View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
files: { extensions: ["pdf", "txt"] }, document: "bytes", Component: Pdf });
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
files: { extensions: ["md"] }, document: "text", Component: Markdown });
return () => {};
}`;
async function openWorkspaceFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
async function associateMarkdownWithPlugin(page: Page) {
await page.evaluate(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" };
localStorage.setItem(key, JSON.stringify(settings));
});
await page.reload();
}
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
async function selectSource(page: Page) {
await page
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
}
test("directory plugins render PDF bytes and edit associated Markdown through the host document", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-"));
const workspace = await seedWorkspace({
repoPrefix: "file-panels-",
repo: {
files: [
{ path: "notes.md", content: "# Built-in Markdown\n" },
{ path: "bytes.txt", content: "Bytes panel text document" },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openWorkspaceFile(page, "report.pdf");
await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible();
await expect(
page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }),
).toBeVisible();
await expect(
page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(
page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }),
).toBeVisible();
const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png");
await page.screenshot({ path: pdfScreenshot });
await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" });
// A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin.
await openWorkspaceFile(page, "bytes.txt");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden();
await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"sandbox",
"",
);
await expect(
page
.frameLocator('[title="Document preview"]')
.getByText("Bytes panel text document", { exact: true }),
).toBeVisible();
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await expect(
page.getByRole("heading", { name: "Built-in Markdown", exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
await associateMarkdownWithPlugin(page);
await expect(page.getByText("Plugin document editable", { exact: true })).toBeVisible();
gate.holdNextClientRequest("fs.file.write.request");
await editMarkdownThroughPlugin(page);
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
gate.releaseHeldClientRequest();
await expect(
page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }),
).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Saved by plugin\n");
await selectSource(page);
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Saved by plugin");
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
await page.screenshot({ path: markdownScreenshot });
await testInfo.attach("plugin-markdown-source-web", {
path: markdownScreenshot,
contentType: "image/png",
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await workspace.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,77 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { expectFileTabOpen, openFileFromExplorer } from "./file-explorer";
/** A directory plugin whose "Rich Markdown" file panel claims `.md`, next to the built-in. */
export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
export const SOURCE_OPTION = "Source — Paseo";
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
function RichMarkdown({ file }) {
return <View><Text>Rich Markdown plugin: {file.name}</Text></View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType",
files: { extensions: ["md"] }, document: "text", Component: RichMarkdown });
return () => {};
}`;
/** Enables plugins on the test daemon and installs the plugin; `uninstall` restores both. */
export async function installMarkdownPanelPlugin(): Promise<{ uninstall(): Promise<void> }> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-markdown-file-panel-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const uninstall = async () => {
await client.removePlugin(MARKDOWN_PANEL_PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await rm(directory, { recursive: true, force: true });
};
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MARKDOWN_PANEL_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
} catch (error) {
await uninstall();
throw error;
}
return { uninstall };
}
export function filePanelPicker(page: Page) {
return page.getByText("Open .md files with", { exact: true });
}
export async function openMarkdown(page: Page, name: string): Promise<void> {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
export async function expectBuiltinMarkdown(page: Page, heading: string): Promise<void> {
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
}
export async function expectPluginMarkdown(page: Page, name: string): Promise<void> {
await expect(page.getByText(`Rich Markdown plugin: ${name}`, { exact: true })).toBeVisible();
}
export async function storedFilePanelAssociations(page: Page): Promise<unknown> {
return page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("@paseo:app-settings") ?? "{}");
return settings.filePanelAssociations ?? {};
});
}
+41 -42
View File
@@ -1,9 +1,10 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { FilePanelBarMenu, FilePanelSwitcher, type FilePanelChoice } from "@/file-panels";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
@@ -11,34 +12,26 @@ import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* The file tab's bar: the document's status, which the host owns whatever panel shows it, and the
* switcher between the file's panels. `trailing` is the active panel's own status.
*/
export function FilePanelBar({
size,
lineCount,
mode,
onModeChange,
editorStatus,
cursor,
vimMode,
conflict,
choice,
trailing,
}: {
size: number;
lineCount?: number;
mode?: "preview" | "source";
onModeChange?(mode: "preview" | "source"): void;
editorStatus?: FileEditorStatus;
cursor?: { line: number; column: number };
vimMode?: string | null;
conflict?: FileConflictAlertState;
choice: FilePanelChoice;
trailing?: ReactNode;
}) {
const { t } = useTranslation();
const previewModes = [
{
value: "preview" as const,
label: t("panels.file.editor.preview"),
testID: "file-mode-preview",
},
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
];
return (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
@@ -46,6 +39,7 @@ export function FilePanelBar({
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
@@ -53,6 +47,7 @@ export function FilePanelBar({
{lineCount !== undefined ? (
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
@@ -82,32 +77,10 @@ export function FilePanelBar({
{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}
{trailing}
</View>
{mode && onModeChange ? (
<SegmentedControl
size="xs"
value={mode}
onValueChange={onModeChange}
testID="file-preview-mode"
options={previewModes}
/>
) : null}
<FilePanelSwitcher choice={choice} />
<FilePanelBarMenu choice={choice} />
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
@@ -115,6 +88,32 @@ export function FilePanelBar({
);
}
/** Source's status in the bar: the vim mode and the cursor position. */
export function FileSourceStatus({
cursor,
vimMode,
}: {
cursor: { line: number; column: number };
vimMode: string | null;
}) {
const { t } = useTranslation();
return (
<>
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
Ln {cursor.line}, Col {cursor.column}
</Text>
</>
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
@@ -4,7 +4,6 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/file-panels";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
@@ -25,8 +24,12 @@ const wrappingCompartment = new Compartment();
const themeCompartment = new Compartment();
const vimCompartment = new Compartment();
/** Markdown is prose, so its paragraphs wrap; code keeps its lines. */
const WRAPPED_EXTENSIONS = new Set(["md", "markdown"]);
function wrappingForFile(filename: string) {
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
const extension = filename.split(".").pop()?.toLowerCase() ?? "";
return WRAPPED_EXTENSIONS.has(extension) ? EditorView.lineWrapping : [];
}
export function FileEditorView({
+123 -108
View File
@@ -1,13 +1,19 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } from "@/file-panels";
import {
FilePanelPicker,
FilePanelView,
isSourceFilePanel,
useFilePanelChoice,
type FilePanel,
type FilePanelChoice,
} from "@/file-panels";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
@@ -20,13 +26,13 @@ import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FilePanelBar, FileSourceStatus } from "./bar";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import type { LiveFileModel, LiveFileSnapshot } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
@@ -36,15 +42,15 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface FilePreviewBodyProps {
interface FilePanelBodyProps {
bytes: Uint8Array | null;
setText?: (text: string) => void;
panel: FilePanel | null;
preview: ExplorerFile | null;
mode?: "preview" | "source";
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
documentPreviewUri: string | null;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
@@ -121,19 +127,20 @@ function TooLargeSource({ size }: { size?: number }) {
);
}
function FilePreviewBody({
/** The tab body: the file's panel, Source read-only when the tab cannot edit. */
function FilePanelBody({
bytes,
setText,
panel,
preview,
mode,
isLoading,
isMobile: _isMobile,
location,
navigationRevision,
imagePreviewUri,
documentPreviewUri,
workspaceRoot,
client,
serverId,
}: FilePreviewBodyProps) {
}: FilePanelBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
@@ -154,21 +161,15 @@ function FilePreviewBody({
);
}
const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source");
if (panel && !showSource) {
if (!panel) {
return (
<FilePanelPreview
panel={panel}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
imagePreviewUri={imagePreviewUri}
/>
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
if (preview.kind === "text") {
if (isSourceFilePanel(panel)) {
return (
<ReadonlySource
preview={preview}
@@ -178,12 +179,21 @@ function FilePreviewBody({
/>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
<FilePanelView
bytes={bytes}
panel={panel}
preview={preview}
location={location}
navigationRevision={navigationRevision}
setText={setText}
content={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
documentPreviewUri={documentPreviewUri}
/>
);
}
@@ -199,9 +209,6 @@ export function FilePane({
navigationRevision: number;
}) {
const { t } = useTranslation();
const { settings } = useAppSettings();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
@@ -240,26 +247,31 @@ export function FilePane({
});
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const { panel: resolvedPanel, choice } = useFilePanelChoice({
serverId,
targetKey,
path: location.path,
file: readyFile(liveFile.snapshot, targetKey),
presentable: isActive,
lineNavigation: location.lineStart ? navigationRevision : null,
});
const previewLifecycle = useFilePreview({
panel: resolvedPanel,
targetKey,
liveFileSnapshot: liveFile.snapshot,
});
useEffect(() => setPreviewMode("preview"), [targetKey]);
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const panel = resolveFilePreviewPanel({
preview,
path: location.path,
associations: settings.filePanelAssociations,
});
const isRenderable = preview?.kind === "text" && panel !== null;
const {
file: preview,
panel,
bytes,
documentAttachment,
} = resolveFilePreviewLifecycle(previewLifecycle);
const documentPreviewUri = useAttachmentPreviewUrl(documentAttachment);
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const canTogglePreviewMode = isRenderable && !location.lineStart;
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
@@ -269,33 +281,43 @@ export function FilePane({
previewLifecycle.status === "preparing";
return (
<FilePanePresentation
panel={panel}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
onRetryRead={liveFile.refresh}
retryingRead={liveFile.isRetrying}
retryLabel={t("common.actions.retry")}
filename={getFileNameFromPath(location.path) ?? location.path}
previewMode={canTogglePreviewMode ? previewMode : undefined}
onPreviewModeChange={canTogglePreviewMode ? setPreviewMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
<>
<FilePanelPicker choice={choice} />
<FilePanePresentation
bytes={bytes}
panel={panel}
choice={choice}
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
onRetryRead={liveFile.refresh}
retryingRead={liveFile.isRetrying}
retryLabel={t("common.actions.retry")}
filename={getFileNameFromPath(location.path) ?? location.path}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={isLoading}
location={location}
navigationRevision={navigationRevision}
documentPreviewUri={documentPreviewUri}
/>
</>
);
}
/** The read for `targetKey`; right after a retarget the snapshot still holds the previous file. */
function readyFile(snapshot: LiveFileSnapshot, targetKey: string | null): FileReadResult | null {
const observation = snapshot.observation;
if (observation?.status !== "ready") return null;
const { cwd, path } = observation.version;
return targetKey === `${cwd}:${path}` ? observation.file : null;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
@@ -309,7 +331,9 @@ function isEditableTextFile(input: {
}
function FilePanePresentation({
bytes,
panel,
choice,
serverId,
workspaceRoot,
client,
@@ -320,19 +344,18 @@ function FilePanePresentation({
retryingRead,
retryLabel,
filename,
previewMode,
onPreviewModeChange,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
documentPreviewUri,
}: {
bytes: Uint8Array | null;
panel: FilePanel | null;
choice: FilePanelChoice;
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
@@ -343,17 +366,14 @@ function FilePanePresentation({
retryingRead: boolean;
retryLabel: string;
filename: string;
previewMode?: "preview" | "source";
onPreviewModeChange?: (mode: "preview" | "source") => void;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
documentPreviewUri: string | null;
}) {
if (!client && readTarget) {
return (
@@ -368,7 +388,10 @@ function FilePanePresentation({
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
bytes={bytes}
panel={panel}
choice={choice}
documentPreviewUri={documentPreviewUri}
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
@@ -380,10 +403,7 @@ function FilePanePresentation({
onRetryRead={onRetryRead}
retryingRead={retryingRead}
filename={filename}
mode={previewMode}
onModeChange={onPreviewModeChange}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
/>
@@ -412,23 +432,15 @@ function FilePanePresentation({
return (
<View style={styles.container} testID="workspace-file-pane">
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={previewMode}
onModeChange={onPreviewModeChange}
/>
) : null}
<FilePreviewBody
{preview ? <FilePanelBar size={preview.size} lineCount={lineCount} choice={choice} /> : null}
<FilePanelBody
bytes={bytes}
panel={panel}
preview={preview}
mode={previewMode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
documentPreviewUri={documentPreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
@@ -438,7 +450,10 @@ function FilePanePresentation({
}
function EditableFilePane({
bytes,
documentPreviewUri,
panel,
choice,
serverId,
workspaceRoot,
client,
@@ -449,14 +464,14 @@ function EditableFilePane({
onRetryRead,
retryingRead,
filename,
mode,
onModeChange,
isLoading,
isMobile,
location,
navigationRevision,
}: {
bytes: Uint8Array | null;
documentPreviewUri: string | null;
panel: FilePanel | null;
choice: FilePanelChoice;
serverId: string;
workspaceRoot: string;
client: DaemonClient;
@@ -467,10 +482,7 @@ function EditableFilePane({
onRetryRead: () => void;
retryingRead: boolean;
filename: string;
mode?: "preview" | "source";
onModeChange?: (mode: "preview" | "source") => void;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
@@ -564,6 +576,7 @@ function EditableFilePane({
onRetry: onRetryRead,
retrying: retryingRead,
});
const setText = useCallback((text: string) => model.edit(text), [model]);
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
const renderedPreview = useMemo<ExplorerFile>(
() => ({
@@ -575,7 +588,11 @@ function EditableFilePane({
}),
[preview, snapshot.content, snapshot.version],
);
const showSource = mode !== "preview";
const sourceActive = panel !== null && isSourceFilePanel(panel);
const sourceStatus = useMemo(
() => (sourceActive ? <FileSourceStatus cursor={cursor} vimMode={vimMode} /> : null),
[cursor, sourceActive, vimMode],
);
return (
<View style={styles.container} testID="workspace-file-pane">
@@ -585,13 +602,11 @@ function EditableFilePane({
}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
cursor={showSource ? cursor : undefined}
vimMode={showSource ? vimMode : null}
conflict={conflict}
mode={mode}
onModeChange={onModeChange}
choice={choice}
trailing={sourceStatus}
/>
{showSource ? (
{sourceActive ? (
<FileEditorView
model={model}
filename={filename}
@@ -603,15 +618,15 @@ function EditableFilePane({
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
<FilePanelBody
bytes={bytes}
panel={panel}
setText={setText}
preview={renderedPreview}
mode={mode}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
documentPreviewUri={documentPreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
@@ -1,4 +1,5 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import type { FilePanel } from "@/file-panels";
import type { LiveFileSnapshot } from "../live-file/model";
import {
createFilePanePreview,
@@ -10,14 +11,15 @@ import {
export function useFilePreview(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
panel: FilePanel | null;
}): FilePreviewLifecycleSnapshot {
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const { targetKey, liveFileSnapshot } = input;
const { targetKey, liveFileSnapshot, panel } = input;
useEffect(() => {
model.setSource({ targetKey, liveFileSnapshot });
}, [liveFileSnapshot, model, targetKey]);
model.setSource({ targetKey, liveFileSnapshot, panel });
}, [liveFileSnapshot, model, targetKey, panel]);
return snapshot;
}
@@ -2,6 +2,7 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { LiveFileSnapshot } from "../live-file/model";
import { describe, expect, it } from "vitest";
import {
createFilePanePreview,
FilePreviewLifecycleModel,
type FilePanePreview,
type FilePreviewLifecycleSnapshot,
@@ -75,8 +76,9 @@ function source(
): {
targetKey: string;
liveFileSnapshot: LiveFileSnapshot;
panel: null;
} {
return { targetKey, liveFileSnapshot: snapshot };
return { targetKey, liveFileSnapshot: snapshot, panel: null };
}
describe("FilePreviewLifecycleModel", () => {
@@ -88,7 +90,8 @@ describe("FilePreviewLifecycleModel", () => {
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
const preview: FilePanePreview = {
file: previewFile("preview"),
imageAttachment: null,
panel: null,
bytesPreview: null,
};
model.setSource(source("/workspace:file.ts", pending()));
@@ -134,12 +137,13 @@ describe("FilePreviewLifecycleModel", () => {
);
const nextPreview: FilePanePreview = {
file: previewFile("two"),
imageAttachment: null,
panel: null,
bytesPreview: null,
};
model.setSource(source("/workspace:file.ts", completed(file("one"))));
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
first.resolve({ file: previewFile("one"), imageAttachment: null });
first.resolve({ file: previewFile("one"), panel: null, bytesPreview: null });
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "preparing" });
@@ -155,3 +159,41 @@ async function expectSnapshot(
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(model.getSnapshot()).toEqual(expected);
}
it("does not persist attachments when a text file has no selected panel", async () => {
let writes = 0;
await createFilePanePreview(file(), null, async () => {
writes++;
return {
id: "attachment",
mimeType: "text/plain",
storageType: "web-indexeddb",
storageKey: "attachment",
createdAt: 0,
};
});
expect(writes).toBe(0);
});
it("reprepares the same file when its selected panel changes", async () => {
const preparations: Array<string | null> = [];
const model = new FilePreviewLifecycleModel(async (raw, panel) => {
preparations.push(panel?.id ?? null);
return { file: previewFile("preview"), panel, bytesPreview: null };
});
const initial = source("/workspace:file.ts", completed(file()));
model.setSource(initial);
await Promise.resolve();
const textPanel = {
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
} as const;
model.setSource({ ...initial, panel: textPanel });
await Promise.resolve();
model.setSource({ ...initial, panel: textPanel });
expect(preparations).toEqual([null, "builtin:markdown"]);
expect(model.getSnapshot()).toMatchObject({ status: "ready", preview: { panel: textPanel } });
});
@@ -3,12 +3,14 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { FilePanel } from "@/file-panels";
import type { ExplorerFile } from "@/stores/session-store";
import type { LiveFileSnapshot } from "../live-file/model";
export interface FilePanePreview {
file: ExplorerFile;
imageAttachment: AttachmentMetadata | null;
panel: FilePanel | null;
bytesPreview: { bytes: Uint8Array; attachment: AttachmentMetadata } | null;
}
export type FilePreviewLifecycleSnapshot =
@@ -22,13 +24,17 @@ export type FilePreviewLifecycleSnapshot =
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
/** Converts a completed raw read into the preview resources consumed by FilePane. */
export async function createFilePanePreview(file: FileReadResult): Promise<FilePanePreview | null> {
export async function createFilePanePreview(
file: FileReadResult,
panel: FilePanel | null,
persist: typeof persistAttachmentFromBytes = persistAttachmentFromBytes,
): Promise<FilePanePreview | null> {
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
if (panel?.document !== "bytes") {
return { file: explorerFile, panel, bytesPreview: null };
}
const imageAttachment = await persistAttachmentFromBytes({
const documentAttachment = await persist({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path,
@@ -41,7 +47,11 @@ export async function createFilePanePreview(file: FileReadResult): Promise<FileP
fileName: getFileNameFromPath(file.path),
});
return { file: explorerFile, imageAttachment };
return {
file: explorerFile,
panel,
bytesPreview: { bytes: file.bytes, attachment: documentAttachment },
};
}
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
@@ -51,9 +61,13 @@ export class FilePreviewLifecycleModel {
private generation = 0;
private preparedSourceKey: string | null = null;
private targetKey: string | null = null;
private preparedPanel: FilePanel | null = null;
constructor(
private readonly prepare: (file: FileReadResult) => Promise<FilePanePreview | null>,
private readonly prepare: (
file: FileReadResult,
panel: FilePanel | null,
) => Promise<FilePanePreview | null>,
) {}
subscribe = (listener: () => void): (() => void) => {
@@ -63,7 +77,11 @@ export class FilePreviewLifecycleModel {
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void {
setSource(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
panel: FilePanel | null;
}): void {
const targetChanged = input.targetKey !== this.targetKey;
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
if (targetChanged) {
@@ -74,16 +92,22 @@ export class FilePreviewLifecycleModel {
const source = getReadySource(input);
if (source) {
if (source.key === this.preparedSourceKey) {
if (source.key === this.preparedSourceKey && input.panel === this.preparedPanel) {
return;
}
this.preparedSourceKey = source.key;
this.preparedPanel = input.panel;
const generation = ++this.generation;
this.setSnapshot({
status: "preparing",
...(retainedPreview ? { preview: retainedPreview } : {}),
});
void this.prepareSource({ file: source.file, generation, retainedPreview });
void this.prepareSource({
file: source.file,
panel: input.panel,
generation,
retainedPreview,
});
return;
}
@@ -109,11 +133,12 @@ export class FilePreviewLifecycleModel {
private async prepareSource(input: {
file: FileReadResult;
panel: FilePanel | null;
generation: number;
retainedPreview?: FilePanePreview;
}): Promise<void> {
try {
const preview = await this.prepare(input.file);
const preview = await this.prepare(input.file, input.panel);
if (input.generation !== this.generation) return;
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
} catch (error) {
@@ -135,10 +160,18 @@ export function filePreviewFromLifecycle(
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
panel: FilePanel | null;
bytes: Uint8Array | null;
documentAttachment: AttachmentMetadata | null;
} {
const preview = filePreviewFromLifecycle(snapshot);
return preview ?? { file: null, imageAttachment: null };
if (!preview) return { file: null, panel: null, bytes: null, documentAttachment: null };
return {
file: preview.file,
panel: preview.panel,
bytes: preview.bytesPreview ? preview.bytesPreview.bytes : null,
documentAttachment: preview.bytesPreview ? preview.bytesPreview.attachment : null,
};
}
function getReadySource(input: {
@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { useAppSettings } from "@/hooks/use-settings";
export interface FilePanelAssociations {
/** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */
associations: Readonly<Record<string, string>>;
/** Writes the association for a selector key, or forgets it with null. */
remember(selectorKey: string, panelId: string | null): void;
}
/** The one reader and writer of the device's file panel associations. */
export function useFilePanelAssociations(): FilePanelAssociations {
const { settings, updateSettings } = useAppSettings();
const remember = useCallback(
(selectorKey: string, panelId: string | null) => {
void updateSettings((current) => ({
filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId),
}));
},
[updateSettings],
);
return { associations: settings.filePanelAssociations, remember };
}
function withAssociation(
associations: Readonly<Record<string, string>>,
selectorKey: string,
panelId: string | null,
): Record<string, string> {
const next = { ...associations };
if (panelId === null) delete next[selectorKey];
else next[selectorKey] = panelId;
return next;
}
+11 -11
View File
@@ -7,18 +7,18 @@ import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { Theme } from "@/styles/theme";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
import type { builtinFilePanels } from "./catalog";
import type { RenderingFilePanel } from "./catalog";
interface FilePanelPreviewProps {
interface BuiltinFilePanelProps {
content: string;
filePath: string;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
imagePreviewUri: string | null;
documentPreviewUri: string | null;
}
function MarkdownPanel(props: FilePanelPreviewProps) {
function MarkdownPanel(props: BuiltinFilePanelProps) {
return (
<View style={styles.previewScrollContainer}>
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
@@ -34,7 +34,7 @@ function MarkdownPanel(props: FilePanelPreviewProps) {
);
}
function HtmlPanel({ content }: FilePanelPreviewProps) {
function HtmlPanel({ content }: BuiltinFilePanelProps) {
return (
<View style={styles.previewScrollContainer}>
<FileHtmlPreview html={content} testID="file-html-preview" />
@@ -45,9 +45,9 @@ function HtmlPanel({ content }: FilePanelPreviewProps) {
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) {
function ImagePanel({ documentPreviewUri }: BuiltinFilePanelProps) {
const { t } = useTranslation();
if (!imagePreviewUri) {
if (!documentPreviewUri) {
return (
<View style={styles.centerState}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
@@ -55,7 +55,7 @@ function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) {
</View>
);
}
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
return <ZoomableImage uri={documentPreviewUri} testID="image-file-preview" />;
}
const components = {
@@ -64,11 +64,11 @@ const components = {
"builtin:image": ImagePanel,
};
export function FilePanelPreview({
export function BuiltinFilePanelView({
panel,
...props
}: FilePanelPreviewProps & {
panel: (typeof builtinFilePanels)[number];
}: BuiltinFilePanelProps & {
panel: RenderingFilePanel;
}) {
const Component = components[panel.id];
return <Component {...props} />;
+59 -55
View File
@@ -1,55 +1,62 @@
import { describe, expect, it } from "vitest";
import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
import { resolveFilePanel } from "./resolve";
import { filePanelCatalog } from "./catalog";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
function filePreviewRenderKind(path: string) {
const { panel } = resolveFilePanel({
file: { path, kind: "text", size: 100, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
});
return panel ? panel.id.slice("builtin:".length) : null;
const builtins = filePanelCatalog([]);
const richMarkdown: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md"] },
document: "text",
maxBytes: Infinity,
};
function textFile(path: string): FilePanelFile {
return { path, kind: "text", size: 100, hasBytes: true };
}
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
expect(isRenderedMarkdownFile("README.md")).toBe(true);
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
});
function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).candidates.map(
(candidate) => candidate.id,
);
}
it("detects .markdown files", () => {
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
});
it("does not treat .mdx files as rendered markdown", () => {
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
});
it("does not treat other text files as rendered markdown", () => {
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
expect(isRenderedMarkdownFile("plan.html")).toBe(false);
});
});
describe("filePreviewRenderKind", () => {
it("maps each renderable extension to its kind", () => {
expect(filePreviewRenderKind("README.md")).toBe("markdown");
expect(filePreviewRenderKind("notes.markdown")).toBe("markdown");
expect(filePreviewRenderKind("plan.html")).toBe("html");
expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html");
expect(filePreviewRenderKind("plan.htm")).toBe("html");
});
it("returns null for files without a rendered preview", () => {
expect(filePreviewRenderKind("src/index.ts")).toBe(null);
expect(filePreviewRenderKind("page.mdx")).toBe(null);
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
});
});
function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null;
}
describe("built-in file panels", () => {
it("opens each rendered extension with its panel and Source after it", () => {
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
expect(panelId(textFile("plan.htm"))).toBe("builtin:html");
});
it("opens every other text file with Source, whatever its name", () => {
for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) {
expect(candidateIds(textFile(path))).toEqual(["builtin:source"]);
}
});
it("puts plugin panels after the rendering built-ins and before Source", () => {
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
"builtin:markdown",
richMarkdown.id,
"builtin:source",
]);
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
});
it("has no Source for files that are not text", () => {
expect(
candidateIds({ path: "archive.zip", kind: "binary", size: 512, hasBytes: true }),
).toEqual([]);
});
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
"registers the image extension %s",
(extension) => {
@@ -60,27 +67,24 @@ describe("built-in file panels", () => {
size: 512,
hasBytes: true,
},
candidates: builtinFilePanels,
candidates: builtins,
associations: { [`ext:${extension}`]: "builtin:markdown" },
});
expect(resolution.panel?.id).toBe("builtin:image");
expect(
resolveFilePanel({
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
candidates: builtinFilePanels,
candidates: builtins,
associations: {},
}).panel,
).toBeNull();
},
);
it("does not impose a new preview size limit on built-in text panels", () => {
expect(
resolveFilePanel({
file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false },
candidates: builtinFilePanels,
associations: {},
}).panel?.id,
).toBe("builtin:markdown");
it("does not impose a size limit on built-in text panels", () => {
expect(candidateIds({ ...textFile("notes.md"), size: 11 * 1024 * 1024 })).toEqual([
"builtin:markdown",
"builtin:source",
]);
});
});
+38 -12
View File
@@ -1,28 +1,54 @@
import { filePanelExtension, type FilePanelCandidate } from "./resolve";
import type { FilePanelCandidate } from "./resolve";
const markdownPanel = {
id: "builtin:markdown",
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
} as const satisfies FilePanelCandidate;
export const builtinFilePanels = [
markdownPanel,
const renderingPanels = [
{
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
},
{
id: "builtin:html",
builtin: true,
files: { extensions: ["html", "htm"] },
document: "text",
maxBytes: Infinity,
},
{
id: "builtin:image",
builtin: true,
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
document: "bytes",
maxBytes: Infinity,
},
] as const satisfies readonly FilePanelCandidate[];
export function isRenderedMarkdownFile(path: string): boolean {
return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path));
/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
export const sourceFilePanel = {
id: "builtin:source",
builtin: true,
files: "all",
document: "text",
maxBytes: Infinity,
} as const satisfies FilePanelCandidate;
export type RenderingFilePanel = (typeof renderingPanels)[number];
export type SourceFilePanel = typeof sourceFilePanel;
/**
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
* panels, then Source. Source is last so it is the default only when nothing else claims the file.
*/
export function filePanelCatalog<Plugin extends FilePanelCandidate>(
pluginPanels: readonly Plugin[],
): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] {
return [...renderingPanels, ...pluginPanels, sourceFilePanel];
}
/** Source is Paseo's own editor, so the file tab renders it rather than the panel host. */
export function isSourceFilePanel<Panel extends { id: string }>(
panel: Panel | SourceFilePanel,
): panel is SourceFilePanel {
return panel.id === sourceFilePanel.id;
}
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { useFilePanelAssociations } from "../associations";
import { sourceFilePanel } from "../catalog";
import { useFilePanels, type FilePanel } from "../panels";
import { resolveFileReadPanels } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry";
export type FilePanelChoice = FilePanelChoiceModel<FilePanel>;
/**
* The file tab's panel: resolves it from the catalog, the device associations and this tab's
* choice, and owns the picker and "Open with…" for it.
*/
export function useFilePanelChoice(input: {
serverId: string;
targetKey: string | null;
path: string;
file: FileReadResult | null;
/** The tab is on screen. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}): { panel: FilePanel | null; choice: FilePanelChoice } {
const { targetKey, path, file, presentable, lineNavigation } = input;
const { serverId, workspaceId, tabId } = usePaneContext();
const candidates = useFilePanels(input.serverId);
const { associations, remember } = useFilePanelAssociations();
const [choice] = useState(
() => new FilePanelChoiceModel<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
);
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
() => resolveFileReadPanels({ file, path, candidates, associations, choice: chosenPanelId }),
[associations, candidates, chosenPanelId, file, path],
);
useEffect(() => {
choice.observe({ targetKey, resolution, presentable, lineNavigation });
}, [choice, lineNavigation, presentable, resolution, targetKey]);
useEffect(
() => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice),
[choice, serverId, tabId, workspaceId],
);
return { panel: resolution?.panel ?? null, choice };
}
const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot<FilePanel> = {
targetKey: null,
chosenPanelId: null,
picker: null,
openWith: null,
};
const subscribeNothing = () => () => {};
const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT;
/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */
export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null {
const { serverId, workspaceId, tabId } = identity;
const getSnapshot = useCallback(
() => getFilePanelChoice({ serverId, workspaceId, tabId }),
[serverId, tabId, workspaceId],
);
return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot);
}
export function useFilePanelChoiceSnapshot(
choice: FilePanelChoice | null,
): FilePanelChoiceSnapshot<FilePanel> {
return useSyncExternalStore(
choice?.subscribe ?? subscribeNothing,
choice?.getSnapshot ?? getNoChoiceSnapshot,
choice?.getSnapshot ?? getNoChoiceSnapshot,
);
}
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog, sourceFilePanel } from "../catalog";
import { resolveFilePanel, type FilePanelCandidate } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel } from "./model";
const rich: FilePanelCandidate = {
id: "plugin:notes/rich",
builtin: false,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
};
const markdown = "builtin:markdown";
const source = sourceFilePanel.id;
/** A file tab: resolves against the shared associations and feeds the result to its model. */
function openTab(
associations: Record<string, string>,
candidates: FilePanelCandidate[] = [...filePanelCatalog([rich])],
options: { presentable?: boolean; path?: string; lineNavigation?: number | null } = {},
) {
const model = new FilePanelChoiceModel<FilePanelCandidate>({
remember(selectorKey, panelId) {
if (panelId === null) delete associations[selectorKey];
else associations[selectorKey] = panelId;
},
linePanelId: source,
});
let targetKey = `/repo:${options.path ?? "notes.md"}`;
let presentable = options.presentable ?? true;
let lineNavigation = options.lineNavigation ?? null;
const resolve = () =>
resolveFilePanel({
file: { path: targetKey.split(":")[1]!, kind: "text", size: 10, hasBytes: true },
candidates,
associations,
choice: chosenPanelFor(model.getSnapshot(), targetKey),
});
// Like the hook: observe, then render what the model's choice resolves to.
const render = () => {
model.observe({ targetKey, resolution: resolve(), presentable, lineNavigation });
return resolve().panel?.id ?? null;
};
render();
return {
model,
render,
panel: () => render(),
picker: () => model.getSnapshot().picker,
retarget(path: string) {
targetKey = `/repo:${path}`;
return render();
},
setPresentable(next: boolean) {
presentable = next;
return render();
},
navigateToLine(revision: number) {
lineNavigation = revision;
return render();
},
};
}
describe("FilePanelChoiceModel", () => {
it("asks once per tab open over the first candidate, and dismissing keeps it", () => {
const associations = {};
const tab = openTab(associations);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toMatchObject({
selectorKey: "ext:md",
selectedPanelId: markdown,
remember: false,
});
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
rich.id,
source,
]);
tab.model.dismissPicker();
expect(tab.picker()).toBeNull();
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
expect(associations).toEqual({});
expect(openTab(associations).picker()).not.toBeNull();
});
it("never asks between built-in panels, and switches between them", () => {
const tab = openTab({}, [...filePanelCatalog([])]);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
source,
]);
tab.model.openWith(source);
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
});
it("opens a file only Source claims with Source and nothing to choose", () => {
const tab = openTab({}, [...filePanelCatalog([rich])], { path: "index.ts" });
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
expect(tab.model.getSnapshot().openWith).toBeNull();
});
it("remembers the choice when Don't ask me again is checked", () => {
const associations: Record<string, string> = {};
const tab = openTab(associations);
tab.model.selectInPicker(rich.id);
tab.model.setRemember(true);
tab.model.confirmPicker();
expect(tab.panel()).toBe(rich.id);
expect(associations).toEqual({ "ext:md": rich.id });
const next = openTab(associations);
expect(next.panel()).toBe(rich.id);
expect(next.picker()).toBeNull();
});
it("uses an unremembered choice for this tab only and asks again on the next open", () => {
const associations = {};
const tab = openTab(associations);
tab.model.selectInPicker(rich.id);
tab.model.confirmPicker();
expect(tab.panel()).toBe(rich.id);
expect(associations).toEqual({});
const next = openTab(associations);
expect(next.panel()).toBe(markdown);
expect(next.picker()).not.toBeNull();
});
it("does not ask with an association or while the tab is in the background", () => {
expect(openTab({ "ext:md": rich.id }).picker()).toBeNull();
expect(openTab({ "ext:md": source }).panel()).toBe(source);
const background = openTab({}, undefined, { presentable: false });
expect(background.picker()).toBeNull();
background.setPresentable(true);
expect(background.picker()).not.toBeNull();
});
it("opens Source for a line target without asking, and again on each new one", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
expect(tab.navigateToLine(1)).toBe(rich.id);
expect(tab.navigateToLine(2)).toBe(source);
});
it("opens Source for the same line target in a retargeted tab", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
tab.model.openWith(markdown);
expect(tab.retarget("other.md")).toBe(source);
});
it("starts a new open when the tab is retargeted", () => {
const tab = openTab({});
tab.model.selectInPicker(rich.id);
tab.model.confirmPicker();
expect(tab.retarget("other.md")).toBe(markdown);
expect(tab.picker()).not.toBeNull();
});
it("switches with Open with and toggles Always use without moving the tab", () => {
const associations: Record<string, string> = {};
const tab = openTab(associations);
tab.model.dismissPicker();
expect(tab.model.getSnapshot().openWith).toMatchObject({
activePanelId: markdown,
remembered: false,
});
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
tab.model.toggleAlwaysUse();
expect(associations).toEqual({ "ext:md": rich.id });
tab.render();
expect(tab.model.getSnapshot().openWith?.remembered).toBe(true);
tab.model.openWith(markdown);
expect(tab.panel()).toBe(markdown);
expect(tab.model.getSnapshot().openWith?.remembered).toBe(false);
tab.model.openWith(rich.id);
tab.render();
tab.model.toggleAlwaysUse();
expect(associations).toEqual({});
expect(tab.panel()).toBe(rich.id);
});
it("asks when a plugin panel arrives after the file first rendered", () => {
const candidates: FilePanelCandidate[] = [...filePanelCatalog([])];
const tab = openTab({}, candidates);
expect(tab.picker()).toBeNull();
candidates.splice(1, 0, rich);
tab.render();
expect(tab.picker()).not.toBeNull();
});
});
@@ -0,0 +1,195 @@
import { asksForFilePanel, type FilePanelCandidate, type FilePanelResolution } from "../resolve";
/** What the file tab currently resolves; `resolution` is null until the file has been read. */
export interface FilePanelChoiceSource<Candidate extends FilePanelCandidate> {
targetKey: string | null;
resolution: FilePanelResolution<Candidate> | null;
/** The tab is on screen, so a picker shown now is seen over the panel it chooses for. */
presentable: boolean;
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
lineNavigation: number | null;
}
export interface FilePanelPickerState<Candidate> {
selectorKey: string;
candidates: readonly Candidate[];
selectedPanelId: string;
remember: boolean;
}
export interface FilePanelOpenWithState<Candidate> {
selectorKey: string;
candidates: readonly Candidate[];
activePanelId: string;
/** The active panel is the one remembered for `selectorKey`. */
remembered: boolean;
}
export interface FilePanelChoiceSnapshot<Candidate> {
targetKey: string | null;
/** 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. */
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. */
export function chosenPanelFor(
snapshot: FilePanelChoiceSnapshot<unknown>,
targetKey: string | null,
): string | null {
return snapshot.targetKey === targetKey ? snapshot.chosenPanelId : null;
}
const EMPTY: FilePanelChoiceSnapshot<never> = {
targetKey: null,
chosenPanelId: null,
picker: null,
openWith: null,
};
/**
* Which file panel one file tab shows, and whether to ask.
*
* A tab open is one target in one tab: the picker is offered at most once per open, the first
* time the tab is on screen with a plugin panel among several candidates and nothing remembered.
* Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does.
* Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me
* again" and "Always use" write the association every later open reads.
*/
export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
private readonly listeners = new Set<() => void>();
private snapshot: FilePanelChoiceSnapshot<Candidate> = EMPTY;
private asked = false;
private openedLineNavigation: number | null = null;
constructor(private readonly deps: FilePanelChoiceDeps) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FilePanelChoiceSnapshot<Candidate> => this.snapshot;
observe(source: FilePanelChoiceSource<Candidate>): void {
let current = this.snapshot;
if (source.targetKey !== current.targetKey) {
this.asked = false;
this.openedLineNavigation = null;
current = { ...EMPTY, targetKey: source.targetKey };
}
if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) {
this.openedLineNavigation = source.lineNavigation;
current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null };
}
const openWith = stableOpenWith(current.openWith, openWithState(source.resolution));
let picker = openWith ? current.picker : null;
// Decided once per open, the first time there is something to ask about on screen. An
// association forgotten while the tab stays open is asked about on the next open.
if (!this.asked && source.presentable && openWith && asksForFilePanel(openWith.candidates)) {
this.asked = true;
const remembered = Boolean(source.resolution?.associatedPanelId);
if (!remembered && current.chosenPanelId === null) {
picker = {
selectorKey: openWith.selectorKey,
candidates: openWith.candidates,
selectedPanelId: openWith.activePanelId,
remember: false,
};
}
}
this.publish({ ...current, openWith, picker });
}
selectInPicker(panelId: string): void {
const picker = this.snapshot.picker;
if (!picker) return;
this.publish({ ...this.snapshot, picker: { ...picker, selectedPanelId: panelId } });
}
setRemember(remember: boolean): void {
const picker = this.snapshot.picker;
if (!picker) return;
this.publish({ ...this.snapshot, picker: { ...picker, remember } });
}
confirmPicker(): void {
const picker = this.snapshot.picker;
if (!picker) return;
if (picker.remember) this.deps.remember(picker.selectorKey, picker.selectedPanelId);
this.publish({ ...this.snapshot, chosenPanelId: picker.selectedPanelId, picker: null });
}
/** Keeps whatever the tab shows now; the next open asks again. */
dismissPicker(): void {
if (!this.snapshot.picker) return;
this.publish({ ...this.snapshot, picker: null });
}
openWith(panelId: string): void {
this.publish({ ...this.snapshot, chosenPanelId: panelId, picker: null });
}
/** Remembers the active panel, or forgets it when it already is the remembered one. */
toggleAlwaysUse(): void {
const openWith = this.snapshot.openWith;
if (!openWith) return;
if (!openWith.remembered) {
this.deps.remember(openWith.selectorKey, openWith.activePanelId);
return;
}
// Forgetting must not move the tab off the panel it is showing.
this.publish({ ...this.snapshot, chosenPanelId: openWith.activePanelId });
this.deps.remember(openWith.selectorKey, null);
}
private publish(next: FilePanelChoiceSnapshot<Candidate>): void {
const current = this.snapshot;
if (
current.targetKey === next.targetKey &&
current.chosenPanelId === next.chosenPanelId &&
current.picker === next.picker &&
current.openWith === next.openWith
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) listener();
}
}
function openWithState<Candidate extends FilePanelCandidate>(
resolution: FilePanelResolution<Candidate> | null,
): FilePanelOpenWithState<Candidate> | null {
if (!resolution?.panel || resolution.candidates.length < 2) return null;
return {
selectorKey: resolution.selectorKey,
candidates: resolution.candidates,
activePanelId: resolution.panel.id,
remembered: resolution.associatedPanelId === resolution.panel.id,
};
}
/** Keeps the published object when nothing in it changed, so subscribers do not re-render. */
function stableOpenWith<Candidate>(
current: FilePanelOpenWithState<Candidate> | null,
next: FilePanelOpenWithState<Candidate> | null,
): FilePanelOpenWithState<Candidate> | null {
if (!current || !next) return next;
const same =
current.selectorKey === next.selectorKey &&
current.activePanelId === next.activePanelId &&
current.remembered === next.remembered &&
current.candidates.length === next.candidates.length &&
current.candidates.every((candidate, index) => candidate === next.candidates[index]);
return same ? current : next;
}
@@ -0,0 +1,175 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { Ellipsis } from "lucide-react-native";
import {
MenuItem,
MenuSeparator,
MenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import { useFilePanelChoiceSnapshot, useTabFilePanelChoice, type FilePanelChoice } from "./hook";
import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./presentation";
const OPEN_WITH_PAGE_ID = "filePanelOpenWith";
const MENU_ICON_SIZE = 16;
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const NO_PAGES: readonly MenuPageDefinition[] = [];
export interface FilePanelOpenWithMenu {
/** The "Open with…" row, or null when the file has one panel or none. */
trigger: ReactElement | null;
/** The page the row opens; pass to the menu surface. */
pages: readonly MenuPageDefinition[];
}
/** "Open with…" for a file tab's context menu, found by the tab's identity. */
export function useFilePanelOpenWithMenu(
identity: PanelInstanceIdentity,
testIDBase: string,
): FilePanelOpenWithMenu {
return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase);
}
/**
* The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless
* the file has more than one panel.
*/
export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) {
const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
const label = t("panels.file.filePanels.actions");
return (
<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,
): FilePanelOpenWithMenu {
const { t } = useTranslation();
const { openWith } = useFilePanelChoiceSnapshot(choice);
return useMemo(() => {
if (!choice || !openWith) return { trigger: null, pages: NO_PAGES };
const title = t("panels.file.filePanels.openWith");
return {
trigger: (
<MenuSubTrigger id={OPEN_WITH_PAGE_ID} testID={`${testIDBase}-open-with`}>
{title}
</MenuSubTrigger>
),
pages: [
{
id: OPEN_WITH_PAGE_ID,
title,
content: (
<>
{openWith.candidates.map((panel) => (
<OpenWithItem
key={panel.id}
choice={choice}
panel={panel}
label={filePanelOptionLabel(t, panel)}
selected={panel.id === openWith.activePanelId}
testID={`${testIDBase}-open-with-${panel.id}`}
/>
))}
<MenuSeparator />
<AlwaysUseItem
choice={choice}
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
selected={openWith.remembered}
testID={`${testIDBase}-always-use`}
/>
</>
),
},
],
};
}, [choice, openWith, t, testIDBase]);
}
function OpenWithItem({
choice,
panel,
label,
selected,
testID,
}: {
choice: FilePanelChoice;
panel: FilePanel;
label: string;
selected: boolean;
testID: string;
}) {
const handleSelect = useCallback(() => choice.openWith(panel.id), [choice, panel.id]);
const leading = useMemo(
() => (
<ThemedPanelIcon
icon={filePanelIcon(panel)}
size={MENU_ICON_SIZE}
uniProps={mutedIconMapping}
/>
),
[panel],
);
return (
<MenuItem selected={selected} leading={leading} onSelect={handleSelect} testID={testID}>
{label}
</MenuItem>
);
}
function AlwaysUseItem({
choice,
label,
selected,
testID,
}: {
choice: FilePanelChoice;
label: string;
selected: boolean;
testID: string;
}) {
const handleSelect = useCallback(() => choice.toggleAlwaysUse(), [choice]);
return (
<MenuItem selected={selected} onSelect={handleSelect} testID={testID}>
{label}
</MenuItem>
);
}
@@ -0,0 +1,233 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PressHighlight } from "@/components/ui/press-highlight";
import { PluginIconView } from "@/plugins/icons";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import type { FilePanelPickerState } from "./model";
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
import { filePanelIcon, filePanelOptionLabel, filePanelPickerTitle } from "./presentation";
const ThemedCheck = withUnistyles(Check);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The first-open question for a file with several panels, over the panel already showing. */
export function FilePanelPicker({ choice }: { choice: FilePanelChoice }) {
const { t } = useTranslation();
const { picker } = useFilePanelChoiceSnapshot(choice);
// The sheet animates out after the model has closed the picker; keep drawing what it asked.
const [shown, setShown] = useState(picker);
if (picker && picker !== shown) setShown(picker);
const content = picker ?? shown;
const dismiss = useCallback(() => choice.dismissPicker(), [choice]);
const confirm = useCallback(() => choice.confirmPicker(), [choice]);
const toggleRemember = useCallback(
() => choice.setRemember(!choice.getSnapshot().picker?.remember),
[choice],
);
const header = useMemo<SheetHeader>(
() => ({ title: content ? filePanelPickerTitle(t, content.selectorKey) : "" }),
[content, t],
);
const footer = useMemo(
() => (
<Button
style={styles.footerButton}
variant="default"
onPress={confirm}
testID="file-panel-picker-use"
>
{t("panels.file.filePanels.use")}
</Button>
),
[confirm, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={picker !== null}
onClose={dismiss}
footer={footer}
snapPoints={PICKER_SNAP_POINTS}
desktopMaxWidth={420}
testID="file-panel-picker"
>
{content ? (
<PickerBody choice={choice} picker={content} onToggleRemember={toggleRemember} />
) : null}
</AdaptiveModalSheet>
);
}
const PICKER_SNAP_POINTS = ["60%", "90%"];
function PickerBody({
choice,
picker,
onToggleRemember,
}: {
choice: FilePanelChoice;
picker: FilePanelPickerState<FilePanel>;
onToggleRemember: () => void;
}) {
const { t } = useTranslation();
const select = useCallback((panelId: string) => choice.selectInPicker(panelId), [choice]);
return (
<>
<View style={settingsStyles.card} accessibilityRole="radiogroup">
{picker.candidates.map((panel, index) => (
<PanelRadioRow
key={panel.id}
panel={panel}
label={filePanelOptionLabel(t, panel)}
selected={panel.id === picker.selectedPanelId}
withBorder={index > 0}
onSelect={select}
/>
))}
</View>
<CheckboxRow
label={t("panels.file.filePanels.dontAskAgain")}
checked={picker.remember}
onToggle={onToggleRemember}
/>
</>
);
}
function PanelRadioRow({
panel,
label,
selected,
withBorder,
onSelect,
}: {
panel: FilePanel;
label: string;
selected: boolean;
withBorder: boolean;
onSelect: (panelId: string) => void;
}) {
const handlePress = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
const accessibilityState = useMemo(() => ({ checked: selected }), [selected]);
const rowStyle = useMemo(
() => [settingsStyles.row, withBorder ? settingsStyles.rowBorder : null, styles.optionRow],
[withBorder],
);
return (
<PressHighlight
style={rowStyle}
onPress={handlePress}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={selected}
>
<View style={selected ? styles.radioSelected : styles.radio}>
{selected ? <View style={styles.radioDot} /> : null}
</View>
<ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{label}
</Text>
</PressHighlight>
);
}
function CheckboxRow({
label,
checked,
onToggle,
}: {
label: string;
checked: boolean;
onToggle: () => void;
}) {
const accessibilityState = useMemo(() => ({ checked }), [checked]);
return (
<PressHighlight
style={styles.checkboxRow}
onPress={onToggle}
accessibilityRole="checkbox"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-checked={checked}
testID="file-panel-picker-remember"
>
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
</View>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
footerButton: {
flex: 1,
},
optionRow: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
radio: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
radioSelected: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
radioDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
checkbox: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
width: 18,
height: 18,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.accent,
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
}));
@@ -0,0 +1,52 @@
import type { TFunction } from "i18next";
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { FilePanel } from "../panels";
import { parseFilePanelSelectorKey } from "../resolve";
const BUILTIN_PRESENTATION = {
"builtin:markdown": { title: "markdown", icon: "FileText" },
"builtin:html": { title: "html", icon: "FileCode" },
"builtin:image": { title: "image", icon: "Image" },
"builtin:source": { title: "source", icon: "Code" },
} as const;
export function filePanelIcon(panel: FilePanel): PluginIcon {
return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon;
}
/** "Markdown", "Rich Markdown": the panel switcher's label. */
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
return panel.builtin
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
: panel.contribution.title;
}
/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */
export function filePanelOptionLabel(t: TFunction, panel: FilePanel): string {
return t("panels.file.filePanels.option", {
title: filePanelTitle(t, panel),
owner: panel.builtin ? t("panels.file.filePanels.builtin.owner") : panel.plugin.id,
});
}
/** ".md" for an extension key, "Dockerfile" for a filename key. */
export function filePanelSelectorLabel(selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name" ? selector.value : `.${selector.value}`;
}
/** "Open .md files with" for an extension key, "Open Dockerfile with" for a filename key. */
export function filePanelPickerTitle(t: TFunction, selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name"
? t("panels.file.filePanels.pickerTitleFilename", { filename: selector.value })
: t("panels.file.filePanels.pickerTitleExtension", { extension: selector.value });
}
/** "Always use for .md", "Always use for Dockerfile". */
export function filePanelAlwaysUseLabel(t: TFunction, selectorKey: string): string {
const selector = parseFilePanelSelectorKey(selectorKey);
return selector.kind === "name"
? t("panels.file.filePanels.alwaysUseFilename", { filename: selector.value })
: t("panels.file.filePanels.alwaysUseExtension", { extension: selector.value });
}
@@ -0,0 +1,42 @@
import {
buildPanelInstanceKey,
type PanelInstanceIdentity,
} from "@/panels/panel-instance-attributes";
import type { FilePanel } from "../panels";
import type { FilePanelChoiceModel } from "./model";
/**
* The open file tabs' choice models, so tab chrome outside the pane (the tab's context menu)
* reads and drives the same choice the pane renders.
*/
const models = new Map<string, FilePanelChoiceModel<FilePanel>>();
const listeners = new Set<() => void>();
export function registerFilePanelChoice(
identity: PanelInstanceIdentity,
model: FilePanelChoiceModel<FilePanel>,
): () => void {
const key = buildPanelInstanceKey(identity);
models.set(key, model);
notify();
return () => {
if (models.get(key) !== model) return;
models.delete(key);
notify();
};
}
export function getFilePanelChoice(
identity: PanelInstanceIdentity,
): FilePanelChoiceModel<FilePanel> | null {
return models.get(buildPanelInstanceKey(identity)) ?? null;
}
export function subscribeFilePanelChoices(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notify(): void {
for (const listener of listeners) listener();
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import type { FilePanelOpenWithState } from "./model";
import { filePanelSwitcherState } from "./switcher-layout";
function openWith(count: number): FilePanelOpenWithState<{ id: string }> {
const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` }));
return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false };
}
describe("filePanelSwitcherState", () => {
it("shows nothing when the file has one panel or none", () => {
expect(filePanelSwitcherState(null, { compact: false })).toBeNull();
});
it("sets up to three panels side by side and more behind a menu", () => {
const wide = { compact: false };
expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({
layout: "menu",
activePanelId: "panel-1",
candidates: openWith(4).candidates,
});
});
it("sets two panels side by side on a compact screen and more behind a menu", () => {
const compact = { compact: true };
expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu");
});
});
@@ -0,0 +1,24 @@
import type { FilePanelOpenWithState } from "./model";
/** Up to this many panels sit side by side in the bar; more go behind a menu. */
const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 };
export interface FilePanelSwitcherState<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,
};
}
@@ -0,0 +1,138 @@
import { useCallback, useMemo } from "react";
import { Text } from "react-native";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { MenuItem } from "@/components/ui/menu";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
ToolbarLabelTriggerIcon,
isToolbarLabelTriggerHighlighted,
toolbarLabelTriggerStyle,
toolbarLabelTriggerTextStyle,
} from "@/components/ui/toolbar-label-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
import { filePanelIcon, filePanelTitle } from "./presentation";
import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */
export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) {
const { openWith } = useFilePanelChoiceSnapshot(choice);
const compact = useIsCompactFormFactor();
const switcher = filePanelSwitcherState(openWith, { compact });
const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]);
if (!switcher) return null;
return switcher.layout === "segmented" ? (
<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>
);
}
@@ -0,0 +1,39 @@
import { useCallback, useMemo, useRef } from "react";
import { WebView } from "react-native-webview";
const frameStyle = { flex: 1, backgroundColor: "white" };
const originWhitelist = ["*"];
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
const source = useMemo(() => ({ uri: url }), [url]);
const loaded = useRef<string | null>(null);
const allowDocument = useCallback(
(request: { url: string }) => {
if (!url.startsWith("file:") || request.url !== url || loaded.current === url) return false;
loaded.current = url;
return true;
},
[url],
);
if (!url.startsWith("file:")) return null;
return (
<WebView
testID={testID}
style={frameStyle}
source={source}
originWhitelist={originWhitelist}
onShouldStartLoadWithRequest={allowDocument}
javaScriptEnabled={false}
setSupportMultipleWindows={false}
javaScriptCanOpenWindowsAutomatically={false}
domStorageEnabled={false}
thirdPartyCookiesEnabled={false}
cacheEnabled={false}
incognito
allowFileAccess
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
allowingReadAccessToURL={url}
/>
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { documentFrameSandbox } from "./sandbox";
const frameStyle = { flex: 1, minHeight: 0, border: "none", backgroundColor: "white" } as const;
interface ResolvedDocument {
url: string;
sandbox: string | null;
}
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
const [document, setDocument] = useState<ResolvedDocument | null>(null);
useEffect(() => {
if (!url.startsWith("blob:")) return;
const lifetime = new AbortController();
void fetch(url, { signal: lifetime.signal })
.then(async (response) => {
const sandbox = documentFrameSandbox(response.headers.get("content-type"));
await response.body?.cancel();
if (!lifetime.signal.aborted) setDocument({ url, sandbox });
return undefined;
})
.catch(() => {
if (!lifetime.signal.aborted) setDocument({ url, sandbox: "" });
});
return () => lifetime.abort();
}, [url]);
if (!url.startsWith("blob:") || document?.url !== url) return null;
return (
<iframe
title="Document preview"
data-testid={testID}
src={url}
sandbox={document.sandbox ?? undefined}
referrerPolicy="no-referrer"
style={frameStyle}
/>
);
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { documentFrameSandbox } from "./sandbox";
describe("document frame sandbox", () => {
it("lets the browser's PDF viewer render PDF documents", () => {
expect(documentFrameSandbox("application/pdf")).toBeNull();
expect(documentFrameSandbox("Application/PDF; charset=binary")).toBeNull();
});
it.each(["text/html", "application/octet-stream", "unknown", "", null, undefined])(
"keeps %s fully sandboxed",
(mediaType) => {
expect(documentFrameSandbox(mediaType)).toBe("");
},
);
});
@@ -0,0 +1,9 @@
/**
* Chromium refuses to run its PDF viewer in a sandboxed frame, regardless of tokens.
* The viewer is a browser component, not page script. The host sets the blob's type from
* the daemon's MIME type, so HTML bytes labeled PDF fail to parse in the viewer rather
* than executing. HTML and every other media type retain the full sandbox.
*/
export function documentFrameSandbox(mediaType: string | null | undefined): string | null {
return mediaType?.split(";")[0].trim().toLowerCase() === "application/pdf" ? null : "";
}
@@ -0,0 +1,73 @@
import { expect, it } from "vitest";
import { FileEditorModel, type FileEditorClock } from "@/file-pane/editor/model";
import { textDocument } from "./document";
class DocumentClock implements FileEditorClock {
callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout() {
this.callback = null;
}
fire() {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
it("plugin text edits mark the host document dirty and autosave with its revision", async () => {
const clock = new DocumentClock();
const writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> =
[];
const model = new FileEditorModel({
file: {
content: "original",
hasBom: false,
version: {
status: "ready",
cwd: "/workspace",
path: "notes.md",
size: 8,
modifiedAt: "2026-10-19T00:00:00Z",
revision: "first",
},
},
clock,
session: {
async write(input) {
writes.push(input);
return {
status: "written",
modifiedAt: "2026-10-19T00:00:01Z",
size: input.content.length,
revision: "second",
};
},
},
});
const document = textDocument(model.getSnapshot().content, (next) => model.edit(next));
expect(document.editable).toBe(true);
document.setText("edited");
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true, content: "edited" });
expect(writes).toEqual([]);
clock.fire();
await Promise.resolve();
expect(writes).toEqual([
{ content: "edited", expectedModifiedAt: "2026-10-19T00:00:00Z", expectedRevision: "first" },
]);
expect(model.getSnapshot()).toMatchObject({
status: "clean",
modified: false,
content: "edited",
});
model.dispose();
});
it("readonly text documents leave the host text unchanged", () => {
const document = textDocument("readonly");
document.setText("ignored");
expect(document).toMatchObject({ kind: "text", text: "readonly", editable: false });
});
+16
View File
@@ -0,0 +1,16 @@
import type { PluginBytesDocument, PluginTextDocument } from "@getpaseo/plugin/client";
export function textDocument(text: string, setText?: (text: string) => void): PluginTextDocument {
return {
kind: "text",
text,
editable: Boolean(setText),
setText(next) {
setText?.(next);
},
};
}
export function bytesDocument(bytes: Uint8Array, url: string): PluginBytesDocument {
return { kind: "bytes", bytes, url };
}
@@ -29,7 +29,7 @@ export function FileHtmlPreview({ html, testID }: { html: string; testID?: strin
return (
<iframe
data-testid={testID}
title={t("panels.file.editor.preview")}
title={t("panels.file.filePanels.builtin.html")}
srcDoc={document}
sandbox={SANDBOX}
referrerPolicy="no-referrer"
+15 -23
View File
@@ -1,23 +1,15 @@
import type { ExplorerFile } from "@/stores/session-store";
import { builtinFilePanels } from "./catalog";
import { resolveFilePanel } from "./resolve";
export { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
export { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
export { FilePanelPreview } from "./builtin";
export type FilePanel = (typeof builtinFilePanels)[number];
export function resolveFilePreviewPanel(input: {
preview: ExplorerFile | null;
path: string;
associations: Readonly<Record<string, string>>;
}) {
const { preview, path, associations } = input;
if (!preview) return null;
return resolveFilePanel({
file: { path, kind: preview.kind, size: preview.size, hasBytes: preview.kind === "image" },
candidates: builtinFilePanels,
associations,
}).panel;
}
export { isSourceFilePanel } from "./catalog";
export { FilePanelView } from "./view";
export type { FilePanel } from "./panels";
export { useFilePanelChoice, 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 { DocumentFrame } from "./document-frame";
export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation";
export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook";
export type { FilePanelSettingsRow } from "./settings/rows";
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from "react";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin, EvaluatedPluginFilePanelContribution } from "@/plugins/types";
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
export interface PluginFilePanel {
id: string;
builtin: false;
files: EvaluatedPluginFilePanelContribution["files"];
document: "text" | "bytes";
maxBytes: number;
plugin: InstalledPlugin;
contribution: EvaluatedPluginFilePanelContribution;
}
export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel;
export function useFilePanels(serverId: string): readonly FilePanel[] {
const plugins = useInstalledPlugins();
return useMemo(
() => hostFilePanels(plugins.filter((plugin) => plugin.serverId === serverId)),
[plugins, serverId],
);
}
/** Every host's panels; a plugin installed on several hosts contributes its panels once per host. */
export function useAllHostsFilePanels(): readonly FilePanel[] {
const plugins = useInstalledPlugins();
return useMemo(() => hostFilePanels(plugins), [plugins]);
}
function hostFilePanels(plugins: readonly InstalledPlugin[]): readonly FilePanel[] {
return filePanelCatalog<PluginFilePanel>(
plugins.flatMap((plugin) =>
plugin.filePanels.map((contribution) => ({
id: `plugin:${plugin.id}/${contribution.id}`,
builtin: false,
files: contribution.files,
document: contribution.document,
maxBytes: contribution.maxBytes,
plugin,
contribution,
})),
),
);
}
@@ -0,0 +1,94 @@
import { useMemo } from "react";
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type { PluginTheme } from "@getpaseo/plugin";
import { withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useHosts } from "@/runtime/host-runtime";
import { createPluginClientStateSource } from "@/plugins/client-state/source";
import { usePluginHostNavigation } from "@/plugins/host-navigation";
import { PluginInstallationProvider } from "@/plugins/installation-provider";
import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary";
import { resolvePluginPlatform } from "@/plugins/platform";
import { toPluginTheme } from "@/plugins/theme";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import type { Theme } from "@/styles/theme";
import type { ExplorerFile } from "@/stores/session-store";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { filePanelExtension } from "./resolve";
import type { PluginFilePanel } from "./panels";
interface Props {
workspaceRoot: string;
panel: PluginFilePanel;
preview: ExplorerFile;
location: WorkspaceFileLocation;
navigationRevision: number;
document: PluginFilePanelProps["document"];
}
function PluginHost({
workspaceRoot,
panel,
preview,
location,
navigationRevision,
document,
theme,
}: Props & { theme: PluginTheme }) {
const { workspaceId } = usePaneContext();
const { plugin, contribution } = panel;
const serverId = plugin.serverId;
const compact = useIsCompactFormFactor();
const hosts = useHosts();
const navigation = usePluginHostNavigation(serverId);
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const file = useMemo(
() => ({
path: buildAbsoluteExplorerPath({ workspaceRoot, entryPath: location.path }),
name: location.path.split(/[\\/]/).pop() ?? "",
extension: filePanelExtension(location.path),
mimeType: preview.mimeType ?? "application/octet-stream",
size: preview.size,
modifiedAt: preview.modifiedAt,
lineStart: location.lineStart,
lineEnd: location.lineEnd,
}),
[
workspaceRoot,
location.path,
location.lineStart,
location.lineEnd,
preview.mimeType,
preview.size,
preview.modifiedAt,
],
);
const Component = contribution.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Component} key={panel.id}>
<PluginInstallationProvider plugin={plugin}>
<PluginClientStateProvider source={stateSource}>
<Component
theme={theme}
host={host}
layout={layout}
navigation={navigation}
workspaceId={workspaceId}
navigationRevision={navigationRevision}
document={document}
file={file}
/>
</PluginClientStateProvider>
</PluginInstallationProvider>
</SurfaceErrorBoundary>
);
}
const ThemedHost = withUnistyles(PluginHost);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginFilePanelHost(props: Props) {
return <ThemedHost {...props} uniProps={themeMapping} />;
}
+126 -1
View File
@@ -1,13 +1,23 @@
import { createFilePanePreview } from "@/file-pane/preview-lifecycle/model";
import { filePanelCatalog } from "./catalog";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { resolveFileReadPanels } from "./resolve";
import { describe, expect, it } from "vitest";
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
const markdown: FilePanelCandidate = {
id: "builtin:markdown",
builtin: true,
files: { extensions: ["md", "markdown"] },
document: "text",
maxBytes: Infinity,
};
const alternative: FilePanelCandidate = { ...markdown, id: "plugin:notes/markdown" };
const alternative: FilePanelCandidate = {
...markdown,
id: "plugin:notes/markdown",
builtin: false,
};
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
function resolve(
@@ -27,6 +37,7 @@ describe("resolveFilePanel", () => {
panel: alternative,
candidates: [markdown, alternative],
selectorKey: "ext:md",
associatedPanelId: alternative.id,
});
});
it("ignores an absent association without deleting it", () => {
@@ -39,13 +50,32 @@ describe("resolveFilePanel", () => {
expect(resolve().panel).toBe(markdown);
expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative);
});
it("uses a tab choice before the association and ignores a choice that is not a candidate", () => {
const associations = { "ext:md": alternative.id };
expect(resolve({ associations, choice: markdown.id })).toMatchObject({
panel: markdown,
associatedPanelId: alternative.id,
});
expect(resolve({ associations, choice: "plugin:absent/markdown" }).panel).toBe(alternative);
});
it("returns the floor when no selector matches", () => {
expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({
panel: null,
candidates: [],
selectorKey: "ext:ts",
associatedPanelId: null,
});
});
it("offers a panel claiming every file wherever its document is available", () => {
const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
expect(resolve({ file: { ...file, path: "index.ts" }, candidates: [every] })).toEqual({
panel: every,
candidates: [every],
selectorKey: "ext:ts",
associatedPanelId: null,
});
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
});
it("filters text panels for binary files even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
@@ -75,6 +105,7 @@ describe("resolveFilePanel", () => {
panel: named,
candidates: [markdown, named],
selectorKey: "name:README.MD",
associatedPanelId: named.id,
});
expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull();
});
@@ -94,6 +125,7 @@ describe("resolveFilePanel", () => {
panel: named,
candidates: [named],
selectorKey: "name:Dockerfile",
associatedPanelId: null,
});
},
);
@@ -103,3 +135,96 @@ describe("resolveFilePanel", () => {
expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:");
});
});
it("opens a binary PDF with its plugin and filters oversized and unavailable documents", () => {
const pdf: FilePanelCandidate = {
id: "plugin:pdf/viewer",
builtin: false,
files: { extensions: ["pdf"] },
document: "bytes",
maxBytes: 1024,
};
const pdfFile: FilePanelFile = {
path: "/workspace/document.pdf",
kind: "binary",
size: 1024,
hasBytes: true,
};
expect(
resolveFilePanel({ file: pdfFile, candidates: [markdown, pdf], associations: {} }).panel,
).toBe(pdf);
expect(
resolveFilePanel({
file: { ...pdfFile, size: 1025 },
candidates: [pdf],
associations: { "ext:pdf": pdf.id },
}).panel,
).toBeNull();
});
describe("resolved panel resources", () => {
const attachment: AttachmentMetadata = {
id: "preview",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "preview",
createdAt: 0,
};
function read(path: string, kind: FileReadResult["kind"]): FileReadResult {
return {
path,
kind,
bytes: new TextEncoder().encode("contents"),
mime: "text/plain",
size: 8,
modifiedAt: "2026-10-19T00:00:00Z",
};
}
it.each([
["notes.md", "text"],
["source.ts", "text"],
["unknown.bin", "binary"],
] as const)(
"does not create an attachment for %s, including live refreshes",
async (path, kind) => {
const writes: Uint8Array[] = [];
const persist = async (input: { bytes: Uint8Array }) => {
writes.push(input.bytes);
return attachment;
};
for (const modifiedAt of ["2026-10-19T00:00:00Z", "2026-10-19T00:00:01Z"]) {
const raw = { ...read(path, kind), modifiedAt };
const panel =
resolveFileReadPanels({
file: raw,
path,
candidates: filePanelCatalog([]),
associations: {},
})?.panel ?? null;
const preview = await createFilePanePreview(raw, panel, persist);
expect(preview?.bytesPreview).toBeNull();
expect(preview?.file).not.toHaveProperty("bytes");
}
expect(writes).toEqual([]);
},
);
it("persists and retains bytes only for the selected image panel", async () => {
const raw = read("picture.png", "image");
const panel =
resolveFileReadPanels({
file: raw,
path: raw.path,
candidates: filePanelCatalog([]),
associations: {},
})?.panel ?? null;
const writes: Uint8Array[] = [];
const preview = await createFilePanePreview(raw, panel, async (input) => {
writes.push(input.bytes);
return attachment;
});
expect(panel?.id).toBe("builtin:image");
expect(writes).toEqual([raw.bytes]);
expect(preview?.bytesPreview).toEqual({ bytes: raw.bytes, attachment });
expect(preview?.file).not.toHaveProperty("bytes");
});
});
+99 -13
View File
@@ -1,3 +1,5 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
export interface FilePanelFile {
path: string;
kind: "text" | "image" | "binary";
@@ -10,9 +12,14 @@ export interface FilePanelSelector {
filenames?: readonly string[];
}
/** A selector, or `"all"`: every file whose document the panel can read. */
export type FilePanelFiles = FilePanelSelector | "all";
export interface FilePanelCandidate {
id: string;
files: FilePanelSelector;
/** Paseo's own panel. Paseo asks which panel opens a file only when a plugin panel claims it. */
builtin: boolean;
files: FilePanelFiles;
document: "text" | "bytes";
maxBytes: number;
}
@@ -21,6 +28,8 @@ export interface FilePanelResolution<Candidate> {
panel: Candidate | null;
candidates: readonly Candidate[];
selectorKey: string;
/** The remembered candidate for `selectorKey`, when this host has it. */
associatedPanelId: string | null;
}
export function filePanelExtension(path: string): string {
@@ -33,31 +42,108 @@ function filePanelBasename(path: string): string {
return path.trim().split(/[\\/]/).pop() ?? "";
}
function matchesSelector(file: FilePanelFile, selector: FilePanelSelector): boolean {
if (selector.filenames?.includes(filePanelBasename(file.path))) return true;
const extension = filePanelExtension(file.path);
/** Which files an association applies to: one extension, or one exact filename. */
export interface FilePanelSelectorKey {
kind: "ext" | "name";
value: string;
}
function filePanelSelectorKey(key: FilePanelSelectorKey): string {
return `${key.kind}:${key.value}`;
}
export function parseFilePanelSelectorKey(selectorKey: string): FilePanelSelectorKey {
const separator = selectorKey.indexOf(":");
const kind = selectorKey.slice(0, separator) === "name" ? "name" : "ext";
return { kind, value: selectorKey.slice(separator + 1) };
}
function matchesFiles(file: FilePanelFile, files: FilePanelFiles): boolean {
if (files === "all") return true;
return matchesName(filePanelBasename(file.path), filePanelExtension(file.path), files);
}
function claimsFilename(files: FilePanelFiles, filename: string): boolean {
return files !== "all" && Boolean(files.filenames?.includes(filename));
}
function matchesName(name: string, extension: string, selector: FilePanelSelector): boolean {
if (selector.filenames?.includes(name)) return true;
return selector.extensions.some((entry) => entry.toLowerCase() === extension);
}
/** The selector keys a panel claims by name: one per extension and one per filename. */
export function filePanelClaimedSelectorKeys(files: FilePanelFiles): string[] {
if (files === "all") return [];
const selector = files;
return [
...selector.extensions.map((extension) =>
filePanelSelectorKey({ kind: "ext", value: extension.toLowerCase() }),
),
...(selector.filenames ?? []).map((filename) =>
filePanelSelectorKey({ kind: "name", value: filename }),
),
];
}
/**
* Whether a panel is offered for files under `selectorKey`, by the resolver's matching rule. Size
* and document kind depend on the file, so they are not part of the answer.
*/
export function claimsFilePanelSelectorKey(
selector: FilePanelSelector,
selectorKey: string,
): boolean {
const { kind, value } = parseFilePanelSelectorKey(selectorKey);
return kind === "name"
? matchesName(value, filePanelExtension(value), selector)
: matchesName("", value, selector);
}
/** 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);
}
export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
file: FilePanelFile;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
/** A panel chosen for this tab open; wins over the association while it is a candidate. */
choice?: string | null;
}): FilePanelResolution<Candidate> {
const { file, associations } = input;
const candidates = input.candidates.filter((candidate) => {
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
return (
documentAvailable && file.size <= candidate.maxBytes && matchesSelector(file, candidate.files)
documentAvailable && file.size <= candidate.maxBytes && matchesFiles(file, candidate.files)
);
});
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) =>
candidate.files.filenames?.includes(filename),
);
const selectorKey = matchedFilename ? `name:${filename}` : `ext:${filePanelExtension(file.path)}`;
const association = associations[selectorKey];
const panel =
candidates.find((candidate) => candidate.id === association) ?? candidates[0] ?? null;
return { panel, candidates, selectorKey };
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
const selectorKey = matchedFilename
? filePanelSelectorKey({ kind: "name", value: filename })
: filePanelSelectorKey({ kind: "ext", value: filePanelExtension(file.path) });
const associated = candidates.find((candidate) => candidate.id === associations[selectorKey]);
const chosen = candidates.find((candidate) => candidate.id === input.choice);
const panel = chosen ?? associated ?? candidates[0] ?? null;
return { panel, candidates, selectorKey, associatedPanelId: associated?.id ?? null };
}
/** Raw reads always carry bytes, independently of whether a panel will retain them. */
export function resolveFileReadPanels<Candidate extends FilePanelCandidate>(input: {
file: FileReadResult | null;
path: string;
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
choice?: string | null;
}): FilePanelResolution<Candidate> | null {
const { file, path } = input;
if (!file) return null;
return resolveFilePanel({
file: { path, kind: file.kind, size: file.size, hasBytes: true },
candidates: input.candidates,
associations: input.associations,
choice: input.choice,
});
}
@@ -0,0 +1,24 @@
import { useCallback, useMemo } from "react";
import { useFilePanelAssociations } from "../associations";
import { useAllHostsFilePanels, type FilePanel } from "../panels";
import { buildFilePanelSettingsRows, type FilePanelSettingsRow } from "./rows";
export interface FilePanelSettings {
rows: readonly FilePanelSettingsRow<FilePanel>[];
/** Remembers `panelId` for the key; the next open of a matching file uses it. */
choose(selectorKey: string, panelId: string): void;
/** Forgets the key's panel, so the next open of a matching file asks. */
askAgain(selectorKey: string): void;
}
/** Settings > Files: the keys worth configuring across every host, and the commands on them. */
export function useFilePanelSettings(): FilePanelSettings {
const candidates = useAllHostsFilePanels();
const { associations, remember } = useFilePanelAssociations();
const rows = useMemo(
() => buildFilePanelSettingsRows({ candidates, associations }),
[associations, candidates],
);
const askAgain = useCallback((selectorKey: string) => remember(selectorKey, null), [remember]);
return { rows, choose: remember, askAgain };
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { filePanelCatalog, sourceFilePanel } from "../catalog";
import type { FilePanelCandidate } from "../resolve";
import { buildFilePanelSettingsRows } from "./rows";
type HostedCandidate = FilePanelCandidate & { serverId?: string };
const builtinMarkdown: HostedCandidate = filePanelCatalog([])[0];
const source: HostedCandidate = sourceFilePanel;
/** What Settings reads: the built-ins and every host's plugin panels, in catalog order. */
function catalog(...plugins: HostedCandidate[]): HostedCandidate[] {
return [...filePanelCatalog(plugins)];
}
function richMarkdown(serverId: string): HostedCandidate {
return {
id: "plugin:markdown-panel/rich-markdown",
builtin: false,
files: { extensions: ["MD"] },
document: "text",
maxBytes: 10 * 1024 * 1024,
serverId,
};
}
const dockerfile: HostedCandidate = {
id: "plugin:docker/dockerfile",
builtin: false,
files: { extensions: [], filenames: ["Dockerfile"] },
document: "text",
maxBytes: Infinity,
serverId: "host-a",
};
function rows(candidates: HostedCandidate[], associations: Record<string, string> = {}) {
return buildFilePanelSettingsRows({ candidates, associations });
}
describe("buildFilePanelSettingsRows", () => {
it("has nothing to configure when only built-in panels claim a key", () => {
expect(rows(catalog())).toEqual([]);
});
it("lists a key a plugin panel claims, built-in first and Source last", () => {
const plugin = richMarkdown("host-a");
expect(rows(catalog(plugin))).toEqual([
{
selectorKey: "ext:md",
candidates: [builtinMarkdown, plugin, source],
associatedPanelId: null,
},
]);
});
it("offers Source only on keys a text panel claims, and asks only between several", () => {
const pdf: HostedCandidate = {
id: "plugin:pdf-panel/pdf",
builtin: false,
files: { extensions: ["pdf"] },
document: "bytes",
maxBytes: Infinity,
};
expect(rows(catalog(pdf))).toEqual([]);
expect(rows(catalog(pdf), { "ext:pdf": pdf.id })).toEqual([
{ selectorKey: "ext:pdf", candidates: [pdf], associatedPanelId: pdf.id },
]);
});
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]);
});
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],
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: [], associatedPanelId: "plugin:pdf-panel/pdf" },
]);
});
it("offers extension panels on a filename key the way the resolver does", () => {
const named: HostedCandidate = {
...dockerfile,
id: "plugin:readme/readme",
files: { extensions: [], filenames: ["README.md"] },
};
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]);
});
it("orders extensions before filenames, each alphabetically", () => {
const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" };
const html: HostedCandidate = {
...richMarkdown("host-a"),
id: "plugin:web/html",
files: { extensions: ["html"] },
};
const keys = rows(catalog(dockerfile, other, html, richMarkdown("host-a"))).map(
(row) => row.selectorKey,
);
expect(keys).toEqual(["ext:html", "ext:md", "name:Dockerfile"]);
});
});
@@ -0,0 +1,78 @@
import {
asksForFilePanel,
claimsFilePanelSelectorKey,
filePanelClaimedSelectorKeys,
parseFilePanelSelectorKey,
type FilePanelCandidate,
} from "../resolve";
/** One selector key in Settings > Files. */
export interface FilePanelSettingsRow<Candidate> {
selectorKey: string;
/** Every known panel for the key, coalesced across hosts, in resolver order. */
candidates: readonly Candidate[];
/** The remembered panel, which may be on no connected host; null means Paseo asks on open. */
associatedPanelId: string | null;
}
/**
* The keys worth configuring: every key where Paseo would ask, and every key that has an
* association, so a remembered choice can always be changed or forgotten. A panel installed on
* several hosts is one candidate.
*/
export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>(input: {
candidates: readonly Candidate[];
associations: Readonly<Record<string, string>>;
}): FilePanelSettingsRow<Candidate>[] {
const candidates = coalesceById(input.candidates);
const keys = new Set([
...candidates.flatMap((candidate) => filePanelClaimedSelectorKeys(candidate.files)),
...Object.keys(input.associations),
]);
return [...keys]
.sort(compareSelectorKeys)
.map((selectorKey) => ({
selectorKey,
candidates: offeredCandidates(candidates, selectorKey),
associatedPanelId: input.associations[selectorKey] ?? null,
}))
.filter((row) => asksForFilePanel(row.candidates) || row.associatedPanelId !== null);
}
/**
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file
* (Source) is offered where a panel claiming the key reads the same document: that is the only
* knowledge of whether the key's files are text.
*/
function offeredCandidates<Candidate extends FilePanelCandidate>(
candidates: readonly Candidate[],
selectorKey: string,
): Candidate[] {
const claiming = candidates.filter(
(candidate) =>
candidate.files !== "all" && claimsFilePanelSelectorKey(candidate.files, selectorKey),
);
return candidates.filter((candidate) =>
candidate.files === "all"
? claiming.some((claimant) => claimant.document === candidate.document)
: claiming.includes(candidate),
);
}
function coalesceById<Candidate extends { id: string }>(
candidates: readonly Candidate[],
): Candidate[] {
const byId = new Map<string, Candidate>();
for (const candidate of candidates) {
if (!byId.has(candidate.id)) byId.set(candidate.id, candidate);
}
return [...byId.values()];
}
/** Extensions before filenames, each alphabetically. */
function compareSelectorKeys(left: string, right: string): number {
const a = parseFilePanelSelectorKey(left);
const b = parseFilePanelSelectorKey(right);
if (a.kind !== b.kind) return a.kind === "ext" ? -1 : 1;
return a.value.localeCompare(b.value);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ComponentProps } from "react";
import type { ExplorerFile } from "@/stores/session-store";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FilePanel } from "./panels";
import type { SourceFilePanel } from "./catalog";
import { BuiltinFilePanelView } from "./builtin";
import { PluginFilePanelHost } from "./plugin-host";
import { bytesDocument, textDocument } from "./document";
/** Renders a file panel as the file tab's body. Source is the tab's own editor, not hosted here. */
export function FilePanelView(
props: Omit<ComponentProps<typeof BuiltinFilePanelView>, "panel"> & {
panel: Exclude<FilePanel, SourceFilePanel>;
preview: ExplorerFile;
bytes: Uint8Array | null;
location: WorkspaceFileLocation;
navigationRevision: number;
setText?: (text: string) => void;
},
) {
const { panel, preview } = props;
if (panel.builtin) return <BuiltinFilePanelView {...props} panel={panel} />;
let document;
if (panel.document === "text") document = textDocument(props.content, props.setText);
else if (props.bytes && props.documentPreviewUri)
document = bytesDocument(props.bytes, props.documentPreviewUri);
if (!document) return null;
return (
<PluginFilePanelHost
workspaceRoot={props.workspaceRoot}
panel={panel}
preview={preview}
location={props.location}
navigationRevision={props.navigationRevision}
document={document}
/>
);
}
+27 -2
View File
@@ -1918,6 +1918,24 @@ export const ar: TranslationResources = {
creatingAgent: "وكيل الخلق",
},
file: {
filePanels: {
pickerTitleExtension: "فتح ملفات .{{extension}} باستخدام",
pickerTitleFilename: "فتح {{filename}} باستخدام",
dontAskAgain: "لا تسألني مرة أخرى",
use: "استخدام",
openWith: "فتح باستخدام…",
alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}",
alwaysUseFilename: "استخدام دائمًا لـ {{filename}}",
option: "{{title}} — {{owner}}",
actions: "إجراءات الملف",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "صورة",
source: "المصدر",
},
},
directoryMissing: "لم يتم العثور على دليل Workspace.",
loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة",
@@ -1935,8 +1953,6 @@ export const ar: TranslationResources = {
changedOnDisk: "تغيّر على القرص",
vimMode: "وضع Vim ‏{{mode}}",
cursor: "السطر {{line}}، العمود {{column}}",
preview: "معاينة",
source: "المصدر",
deletedTitle: "تم حذف الملف من القرص",
checkFailedTitle: "تعذّر التحقق من الملف على القرص",
preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.",
@@ -2074,6 +2090,7 @@ export const ar: TranslationResources = {
terminal: "الطرفية",
browser: "المتصفح",
editor: "المحرر",
files: "الملفات",
shortcuts: "الاختصارات",
integrations: "التكامل",
notifications: "الإشعارات",
@@ -2087,6 +2104,14 @@ export const ar: TranslationResources = {
vimKeybindings: "اختصارات Vim",
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
},
files: {
title: "الملفات",
askEveryTime: "اسأل في كل مرة",
askAgain: "اسأل مجددًا",
askAgainFor: "اسأل مجددًا عن {{label}}",
unavailable: "غير موجود على مضيف متصل",
empty: "تظهر لوحات الملفات هنا عندما تضيف إحدى الإضافات لوحة",
},
notifications: {
title: "الإشعارات",
permission: "إذن الإشعارات",
+27 -2
View File
@@ -1943,6 +1943,24 @@ export const en = {
creatingAgent: "Creating agent",
},
file: {
filePanels: {
pickerTitleExtension: "Open .{{extension}} files with",
pickerTitleFilename: "Open {{filename}} with",
dontAskAgain: "Don't ask me again",
use: "Use",
openWith: "Open with…",
alwaysUseExtension: "Always use for .{{extension}}",
alwaysUseFilename: "Always use for {{filename}}",
option: "{{title}} — {{owner}}",
actions: "File actions",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Image",
source: "Source",
},
},
directoryMissing: "Workspace directory not found.",
loading: "Loading file...",
noPreview: "No preview available",
@@ -1960,8 +1978,6 @@ export const en = {
changedOnDisk: "Changed on disk",
vimMode: "Vim mode {{mode}}",
cursor: "Line {{line}}, column {{column}}",
preview: "Preview",
source: "Source",
deletedTitle: "File deleted on disk",
checkFailedTitle: "Couldn't check file on disk",
preservedDescription: "The open copy is preserved.",
@@ -2097,6 +2113,7 @@ export const en = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
@@ -2150,6 +2167,14 @@ export const en = {
vimKeybindings: "Vim keybindings",
vimHint: "Applies to source files on web and desktop.",
},
files: {
title: "Files",
askEveryTime: "Ask every time",
askAgain: "Ask again",
askAgainFor: "Ask again for {{label}}",
unavailable: "Not on a connected host",
empty: "File panels appear here when a plugin adds one",
},
notifications: {
title: "Notifications",
permission: "Notification permission",
+27 -2
View File
@@ -1966,6 +1966,24 @@ export const es: TranslationResources = {
creatingAgent: "Agente creador",
},
file: {
filePanels: {
pickerTitleExtension: "Abrir archivos .{{extension}} con",
pickerTitleFilename: "Abrir {{filename}} con",
dontAskAgain: "No volver a preguntar",
use: "Usar",
openWith: "Abrir con…",
alwaysUseExtension: "Usar siempre para .{{extension}}",
alwaysUseFilename: "Usar siempre para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Acciones del archivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Imagen",
source: "Código fuente",
},
},
directoryMissing: "No se encontró el directorio de Workspace.",
loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible",
@@ -1983,8 +2001,6 @@ export const es: TranslationResources = {
changedOnDisk: "Cambiado en disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Línea {{line}}, columna {{column}}",
preview: "Vista previa",
source: "Código fuente",
deletedTitle: "Archivo eliminado del disco",
checkFailedTitle: "No se pudo comprobar el archivo en disco",
preservedDescription: "La copia abierta se ha conservado.",
@@ -2122,6 +2138,7 @@ export const es: TranslationResources = {
terminal: "Terminal",
browser: "Navegador",
editor: "Editor",
files: "Archivos",
shortcuts: "Atajos",
integrations: "Integraciones",
notifications: "Notificaciones",
@@ -2135,6 +2152,14 @@ export const es: TranslationResources = {
vimKeybindings: "Atajos de Vim",
vimHint: "Se aplica a archivos fuente en web y escritorio.",
},
files: {
title: "Archivos",
askEveryTime: "Preguntar siempre",
askAgain: "Volver a preguntar",
askAgainFor: "Volver a preguntar para {{label}}",
unavailable: "No está en un host conectado",
empty: "Los paneles de archivo aparecen aquí cuando un plugin añade uno",
},
notifications: {
title: "Notificaciones",
permission: "Permiso de notificaciones",
+27 -2
View File
@@ -1964,6 +1964,24 @@ export const fr: TranslationResources = {
creatingAgent: "Création de l’agent",
},
file: {
filePanels: {
pickerTitleExtension: "Ouvrir les fichiers .{{extension}} avec",
pickerTitleFilename: "Ouvrir {{filename}} avec",
dontAskAgain: "Ne plus demander",
use: "Utiliser",
openWith: "Ouvrir avec…",
alwaysUseExtension: "Toujours utiliser pour .{{extension}}",
alwaysUseFilename: "Toujours utiliser pour {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Actions du fichier",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Image",
source: "Source",
},
},
directoryMissing: "Répertoire de l’espace de travail introuvable.",
loading: "Chargement du fichier…",
noPreview: "Aucun aperçu disponible",
@@ -1981,8 +1999,6 @@ export const fr: TranslationResources = {
changedOnDisk: "Modifié sur le disque",
vimMode: "Mode Vim {{mode}}",
cursor: "Ligne {{line}}, colonne {{column}}",
preview: "Aperçu",
source: "Source",
deletedTitle: "Fichier supprimé du disque",
checkFailedTitle: "Impossible de vérifier le fichier sur le disque",
preservedDescription: "La copie ouverte est conservée.",
@@ -2122,6 +2138,7 @@ export const fr: TranslationResources = {
terminal: "Terminal",
browser: "Navigateur",
editor: "Éditeur",
files: "Fichiers",
shortcuts: "Raccourcis",
integrations: "Intégrations",
notifications: "Notifications",
@@ -2135,6 +2152,14 @@ export const fr: TranslationResources = {
vimKeybindings: "Raccourcis Vim",
vimHint: "S’applique aux fichiers source dans l’app web et l’app de bureau.",
},
files: {
title: "Fichiers",
askEveryTime: "Toujours demander",
askAgain: "Redemander",
askAgainFor: "Redemander pour {{label}}",
unavailable: "Absent des hôtes connectés",
empty: "Les panneaux de fichier apparaissent ici quand un plugin en ajoute un",
},
notifications: {
title: "Notifications",
permission: "Autorisation des notifications",
+27 -2
View File
@@ -1937,6 +1937,24 @@ export const ja: TranslationResources = {
creatingAgent: "エージェントを作成中",
},
file: {
filePanels: {
pickerTitleExtension: ".{{extension}} ファイルを開くアプリ",
pickerTitleFilename: "{{filename}} を開くアプリ",
dontAskAgain: "今後は確認しない",
use: "使用",
openWith: "このアプリで開く…",
alwaysUseExtension: ".{{extension}} に常に使用",
alwaysUseFilename: "{{filename}} に常に使用",
option: "{{title}} — {{owner}}",
actions: "ファイルの操作",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "画像",
source: "ソース",
},
},
directoryMissing: "ワークスペースディレクトリが見つかりません。",
loading: "ファイルを読み込み中...",
noPreview: "プレビューが利用できません",
@@ -1954,8 +1972,6 @@ export const ja: TranslationResources = {
changedOnDisk: "ディスク上で変更されました",
vimMode: "Vim モード {{mode}}",
cursor: "{{line}} 行、{{column}} 列",
preview: "プレビュー",
source: "ソース",
deletedTitle: "ファイルはディスク上から削除されました",
checkFailedTitle: "ディスク上のファイルを確認できませんでした",
preservedDescription: "開いているコピーは保持されています。",
@@ -2095,6 +2111,7 @@ export const ja: TranslationResources = {
terminal: "ターミナル",
browser: "ブラウザ",
editor: "エディター",
files: "ファイル",
shortcuts: "ショートカット",
integrations: "連携",
notifications: "通知",
@@ -2108,6 +2125,14 @@ export const ja: TranslationResources = {
vimKeybindings: "Vim キーバインド",
vimHint: "Web とデスクトップのソースファイルに適用されます。",
},
files: {
title: "ファイル",
askEveryTime: "毎回確認する",
askAgain: "もう一度確認する",
askAgainFor: "{{label}} をもう一度確認する",
unavailable: "接続中のホストにありません",
empty: "プラグインがファイルパネルを追加すると、ここに表示されます",
},
notifications: {
title: "通知",
permission: "通知の権限",
+27 -2
View File
@@ -1928,6 +1928,24 @@ export const ko: TranslationResources = {
creatingAgent: "에이전트 생성 중",
},
file: {
filePanels: {
pickerTitleExtension: ".{{extension}} 파일 열기 방식",
pickerTitleFilename: "{{filename}} 열기 방식",
dontAskAgain: "다시 묻지 않기",
use: "사용",
openWith: "다음으로 열기…",
alwaysUseExtension: ".{{extension}}에 항상 사용",
alwaysUseFilename: "{{filename}}에 항상 사용",
option: "{{title}} — {{owner}}",
actions: "파일 작업",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "이미지",
source: "소스",
},
},
directoryMissing: "워크스페이스 디렉터리를 찾을 수 없습니다.",
loading: "파일 불러오는 중...",
noPreview: "사용 가능한 미리보기가 없습니다",
@@ -1945,8 +1963,6 @@ export const ko: TranslationResources = {
changedOnDisk: "디스크에서 변경됨",
vimMode: "Vim 모드 {{mode}}",
cursor: "{{line}} 라인, {{column}} 열",
preview: "미리보기",
source: "소스",
deletedTitle: "디스크에서 파일이 삭제되었습니다.",
checkFailedTitle: "디스크의 파일을 확인할 수 없습니다.",
preservedDescription: "열린 복사본은 보존됩니다.",
@@ -2084,6 +2100,7 @@ export const ko: TranslationResources = {
terminal: "터미널",
browser: "브라우저",
editor: "편집기",
files: "파일",
shortcuts: "단축키",
integrations: "통합",
notifications: "알림",
@@ -2097,6 +2114,14 @@ export const ko: TranslationResources = {
vimKeybindings: "Vim 키 바인딩",
vimHint: "웹 및 데스크톱의 소스 파일에 적용됩니다.",
},
files: {
title: "파일",
askEveryTime: "매번 묻기",
askAgain: "다시 묻기",
askAgainFor: "{{label}} 다시 묻기",
unavailable: "연결된 호스트에 없음",
empty: "플러그인이 파일 패널을 추가하면 여기에 표시됩니다",
},
notifications: {
title: "알림",
permission: "알림 권한",
+27 -2
View File
@@ -1951,6 +1951,24 @@ export const ptBR: TranslationResources = {
creatingAgent: "Criando agente",
},
file: {
filePanels: {
pickerTitleExtension: "Abrir arquivos .{{extension}} com",
pickerTitleFilename: "Abrir {{filename}} com",
dontAskAgain: "Não perguntar novamente",
use: "Usar",
openWith: "Abrir com…",
alwaysUseExtension: "Sempre usar para .{{extension}}",
alwaysUseFilename: "Sempre usar para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Ações do arquivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Imagem",
source: "Fonte",
},
},
directoryMissing: "Diretório do workspace não encontrado.",
loading: "Carregando arquivo...",
noPreview: "Nenhuma prévia disponível",
@@ -1968,8 +1986,6 @@ export const ptBR: TranslationResources = {
changedOnDisk: "Alterado no disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Linha {{line}}, coluna {{column}}",
preview: "Prévia",
source: "Fonte",
deletedTitle: "Arquivo excluído do disco",
checkFailedTitle: "Não foi possível verificar o arquivo no disco",
preservedDescription: "A cópia aberta foi preservada.",
@@ -2107,6 +2123,7 @@ export const ptBR: TranslationResources = {
terminal: "Terminal",
browser: "Navegador",
editor: "Editor",
files: "Arquivos",
shortcuts: "Atalhos",
integrations: "Integrações",
notifications: "Notificações",
@@ -2120,6 +2137,14 @@ export const ptBR: TranslationResources = {
vimKeybindings: "Atalhos do Vim",
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
},
files: {
title: "Arquivos",
askEveryTime: "Perguntar sempre",
askAgain: "Perguntar de novo",
askAgainFor: "Perguntar de novo para {{label}}",
unavailable: "Não está em um host conectado",
empty: "Os painéis de arquivo aparecem aqui quando um plugin adiciona um",
},
notifications: {
title: "Notificações",
permission: "Permissão de notificações",
+27 -2
View File
@@ -1950,6 +1950,24 @@ export const ru: TranslationResources = {
creatingAgent: "Создание агента",
},
file: {
filePanels: {
pickerTitleExtension: "Открывать файлы .{{extension}} с помощью",
pickerTitleFilename: "Открывать {{filename}} с помощью",
dontAskAgain: "Больше не спрашивать",
use: "Использовать",
openWith: "Открыть с помощью…",
alwaysUseExtension: "Всегда использовать для .{{extension}}",
alwaysUseFilename: "Всегда использовать для {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Действия с файлом",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "Изображение",
source: "Исходный текст",
},
},
directoryMissing: "Каталог рабочего пространства не найден.",
loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен",
@@ -1967,8 +1985,6 @@ export const ru: TranslationResources = {
changedOnDisk: "Изменён на диске",
vimMode: "Режим Vim {{mode}}",
cursor: "Строка {{line}}, столбец {{column}}",
preview: "Предпросмотр",
source: "Исходный текст",
deletedTitle: "Файл удалён с диска",
checkFailedTitle: "Не удалось проверить файл на диске",
preservedDescription: "Открытая копия сохранена в редакторе.",
@@ -2107,6 +2123,7 @@ export const ru: TranslationResources = {
terminal: "Терминал",
browser: "Браузер",
editor: "Редактор",
files: "Файлы",
shortcuts: "Сочетания клавиш",
integrations: "Интеграции",
notifications: "Уведомления",
@@ -2120,6 +2137,14 @@ export const ru: TranslationResources = {
vimKeybindings: "Сочетания клавиш Vim",
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
},
files: {
title: "Файлы",
askEveryTime: "Спрашивать каждый раз",
askAgain: "Спросить снова",
askAgainFor: "Спросить снова для {{label}}",
unavailable: "Нет на подключённых хостах",
empty: "Панели файлов появятся здесь, когда плагин добавит их",
},
notifications: {
title: "Уведомления",
permission: "Разрешение на уведомления",
+27 -2
View File
@@ -1896,6 +1896,24 @@ export const zhCN: TranslationResources = {
creatingAgent: "正在创建 Agent",
},
file: {
filePanels: {
pickerTitleExtension: "打开 .{{extension}} 文件的方式",
pickerTitleFilename: "打开 {{filename}} 的方式",
dontAskAgain: "不再询问",
use: "使用",
openWith: "打开方式…",
alwaysUseExtension: "始终用于 .{{extension}}",
alwaysUseFilename: "始终用于 {{filename}}",
option: "{{title}} — {{owner}}",
actions: "文件操作",
builtin: {
owner: "Paseo",
markdown: "Markdown",
html: "HTML",
image: "图片",
source: "源代码",
},
},
directoryMissing: "未找到 workspace 目录。",
loading: "正在加载文件...",
noPreview: "没有可用预览",
@@ -1913,8 +1931,6 @@ export const zhCN: TranslationResources = {
changedOnDisk: "磁盘上的文件已更改",
vimMode: "Vim 模式 {{mode}}",
cursor: "第 {{line}} 行,第 {{column}} 列",
preview: "预览",
source: "源代码",
deletedTitle: "文件已从磁盘删除",
checkFailedTitle: "无法检查磁盘上的文件",
preservedDescription: "打开的副本已保留。",
@@ -2052,6 +2068,7 @@ export const zhCN: TranslationResources = {
terminal: "终端",
browser: "浏览器",
editor: "编辑器",
files: "文件",
shortcuts: "快捷键",
integrations: "集成",
notifications: "通知",
@@ -2065,6 +2082,14 @@ export const zhCN: TranslationResources = {
vimKeybindings: "Vim 键位",
vimHint: "适用于网页和桌面端的源文件。",
},
files: {
title: "文件",
askEveryTime: "每次询问",
askAgain: "重新询问",
askAgainFor: "重新询问 {{label}}",
unavailable: "不在已连接的主机上",
empty: "插件添加文件面板后会显示在这里",
},
notifications: {
title: "通知",
permission: "通知权限",
@@ -18,6 +18,7 @@ function installation(): InstalledPlugin {
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -78,6 +78,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
surfaces: [{ id: "main", title: "Main", Component: () => null }],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [
{
id: "details",
+56
View File
@@ -812,3 +812,59 @@ it("binds imported getters to each originating installation across delayed callb
await first.cleanup();
await second.cleanup();
});
describe("file panels", () => {
const panel =
'{ id: "pdf", title: "PDF", icon: "FileText", files: { extensions: ["pdf"] }, document: "bytes", Component() { return null; } }';
it("registers a panel with the default limit and removes it on cleanup", async () => {
const plugin = evaluatePluginClientBundle("files", bundle(`plugin.addFilePanel(${panel})`));
expect(
plugin.filePanels.map(({ id, files, document, maxBytes }) => ({
id,
files,
document,
maxBytes,
})),
).toEqual([
{ id: "pdf", files: { extensions: ["pdf"] }, document: "bytes", maxBytes: 10 * 1024 * 1024 },
]);
await plugin.cleanup();
expect(plugin.filePanels).toEqual([]);
});
it.each([
['files: { extensions: ["pdf"] }', 'files: { pattern: "*.pdf" }', "selector"],
['files: { extensions: ["pdf"] }', 'files: { extensions: [".pdf"] }', "selector"],
['files: { extensions: ["pdf"] }', 'files: { extensions: ["*"] }', "selector"],
['files: { extensions: ["pdf"] }', 'files: { extensions: ["foo/bar"] }', "selector"],
['files: { extensions: ["pdf"] }', "files: { extensions: [] }", "selector"],
['files: { extensions: ["pdf"] }', 'files: { extensions: [], filenames: ["a/b"] }', "selector"],
['document: "bytes"', 'document: "binary"', "document"],
['id: "pdf"', 'id: "Bad"', "file panel id"],
['title: "PDF"', 'title: " "', "title"],
['document: "bytes"', 'document: "bytes", maxBytes: 0', "maxBytes"],
['document: "bytes"', 'document: "bytes", maxBytes: Infinity', "maxBytes"],
["Component() { return null; }", "Component: {}", "component"],
['icon: "FileText"', 'icon: ""', "icon"],
])("rejects invalid registration %s → %s", (from, to, error) => {
expect(() =>
evaluatePluginClientBundle(
"files",
bundle(`plugin.addFilePanel(${panel.replace(from, to)})`),
),
).toThrow(error);
});
it("accepts filename selectors and rejects duplicate IDs", () => {
const source = panel.replace(
'extensions: ["pdf"]',
'extensions: [], filenames: ["Dockerfile"]',
);
const plugin = evaluatePluginClientBundle("files", bundle(`plugin.addFilePanel(${source})`));
expect(plugin.filePanels[0]?.files).toEqual({ extensions: [], filenames: ["Dockerfile"] });
expect(() =>
evaluatePluginClientBundle(
"files",
bundle(`plugin.addFilePanel(${source}); plugin.addFilePanel(${source})`),
),
).toThrow("Duplicate file panel");
});
});
+55
View File
@@ -113,6 +113,7 @@ export function runPluginClientBundle(
settingsScreens: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -127,6 +128,7 @@ export function runPluginClientBundle(
header: new Set(),
footer: new Set(),
};
const filePanelIds = new Set<string>();
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
const clientSlashCommandNames = new Set<string>();
@@ -254,6 +256,58 @@ export function runPluginClientBundle(
ids.add(normalizedId);
return register(collector.legacySidebarItems, legacy, () => ids.delete(normalizedId));
},
addFilePanel(contribution) {
const panelId = requireId(contribution.id, "file panel id");
if (filePanelIds.has(panelId)) throw new Error(`Duplicate file panel: ${panelId}`);
const title = contribution.title.trim();
if (!title) throw new Error(`File panel ${panelId} has no title`);
const icon = parsePluginIcon(`File panel ${panelId}`, contribution.icon);
const files = contribution.files;
const validEntries = (entries: unknown, extension: boolean): entries is string[] =>
Array.isArray(entries) &&
entries.every(
(entry) =>
typeof entry === "string" &&
entry.trim() === entry &&
entry.length > 0 &&
!/[\\/*?]/.test(entry) &&
(!extension || !entry.includes(".")),
);
if (
!files ||
Object.keys(files).some((key) => key !== "extensions" && key !== "filenames") ||
!validEntries(files.extensions, true) ||
(files.filenames !== undefined && !validEntries(files.filenames, false)) ||
(files.extensions.length === 0 && !files.filenames?.length)
) {
throw new Error(`File panel ${panelId} has invalid selector`);
}
if (contribution.document !== "text" && contribution.document !== "bytes")
throw new Error(`File panel ${panelId} has invalid document`);
if (
contribution.maxBytes !== undefined &&
(!Number.isFinite(contribution.maxBytes) || contribution.maxBytes <= 0)
)
throw new Error(`File panel ${panelId} has invalid maxBytes`);
if (typeof contribution.Component !== "function")
throw new Error(`File panel ${panelId} is not a component`);
filePanelIds.add(panelId);
return register(
collector.filePanels,
{
...contribution,
id: panelId,
title,
icon,
files: {
extensions: [...files.extensions],
...(files.filenames ? { filenames: [...files.filenames] } : {}),
},
maxBytes: contribution.maxBytes ?? 10 * 1024 * 1024,
},
() => filePanelIds.delete(panelId),
);
},
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
const normalizedId = requireId(contribution.id, "workspace panel id");
if (workspacePanelIds.has(normalizedId)) {
@@ -510,6 +564,7 @@ export function runPluginClientBundle(
settingsScreens: collector.settingsScreens,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
filePanels: collector.filePanels,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSlashCommands: collector.clientSlashCommands,
@@ -32,6 +32,7 @@ function installation(invoked: string[]): InstalledPlugin {
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -12,3 +12,5 @@ export {
export { ExternalLink } from "@/components/ui/external-link";
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
export { DocumentFrame } from "@/file-panels/document-frame";
@@ -99,3 +99,23 @@ it("unloads on a requirement-only edit and recovers after correction", () => {
expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]);
expect(result.getEvaluationError("host", "example")).toBeUndefined();
});
it("skips file-panel bundles on older apps and installs them on the required app", () => {
const entry = {
id: "files",
requirements: { paseo: ">=0.13.0" },
clientBundle: `(function() { return { default(client) {
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
files: { extensions: ["pdf"] }, document: "bytes", Component() { return null; } });
return () => {};
} }; })`,
};
const old = registry("0.12.0");
old.result.installCatalog("host", [entry], { client, audio });
expect(old.starts()).toBe(0);
expect(old.result.getSnapshot()).toEqual([]);
const current = registry("0.13.0");
current.result.installCatalog("host", [entry], { client, audio });
expect(current.starts()).toBe(1);
expect(current.result.getSnapshot()[0]?.filePanels.map((panel) => panel.id)).toEqual(["pdf"]);
});
+1
View File
@@ -93,6 +93,7 @@ export class PluginRegistry {
settingsScreens: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -30,6 +30,7 @@ function installed(
footer: [{ id: "status", title: "Status", Component: () => null }],
},
legacySidebarItems,
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -32,6 +32,7 @@ function installation(
title: item.id,
icon: "Blocks",
})),
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
+1
View File
@@ -57,6 +57,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -20,6 +20,7 @@ function plugin(input: {
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
+6
View File
@@ -7,6 +7,7 @@ import type {
PluginThemeContribution,
} from "@getpaseo/plugin";
import type {
PluginFilePanelContribution,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
PluginComposerPillContribution,
@@ -26,6 +27,10 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont
locations: readonly PluginPanelLocation[];
};
export type EvaluatedPluginFilePanelContribution = PluginFilePanelContribution & {
maxBytes: number;
};
export interface EvaluatedPlugin {
id: string;
cleanup: PluginCleanup;
@@ -35,6 +40,7 @@ export interface EvaluatedPlugin {
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
legacySidebarItems: PluginSidebarContribution[];
filePanels: EvaluatedPluginFilePanelContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSlashCommands: PluginClientSlashCommandContribution[];
@@ -17,6 +17,7 @@ function installed(): InstalledPlugin {
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [
{
id: "details",
@@ -11,6 +11,7 @@ import {
WorkspaceTabOptionRow,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import { useFilePanelOpenWithMenu } from "@/file-panels";
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
import {
buildWorkspaceTabMenuEntries,
@@ -235,15 +236,20 @@ function MobileWorkspaceTabOption({
[t],
);
const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels);
const openWith = useFilePanelOpenWithMenu(
{ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: tab.tabId },
menuTestIDBase,
);
const trailingAccessory = useMemo(
() => (
<MobileTabTrailingAccessory
menuTestIDBase={menuTestIDBase}
presentationLabel={fallbackLabel}
menuEntries={menuEntries}
openWith={openWith}
/>
),
[menuTestIDBase, fallbackLabel, menuEntries],
[menuTestIDBase, fallbackLabel, menuEntries, openWith],
);
const renderPresentation = useCallback(
@@ -1,5 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
@@ -10,6 +11,7 @@ import React, {
type SetStateAction,
} from "react";
import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
import { useFilePanelOpenWithMenu } from "@/file-panels";
import {
CopyX,
ArrowLeftToLine,
@@ -720,6 +722,7 @@ function TabHandleContent({
function TabChip({
serverId,
workspaceId,
tab,
isActive,
isDragging,
@@ -739,6 +742,7 @@ function TabChip({
dragHandleProps,
}: {
serverId: string;
workspaceId: string;
tab: WorkspaceTabDescriptor;
isActive: boolean;
isDragging: boolean;
@@ -759,6 +763,10 @@ function TabChip({
}) {
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const { t } = useTranslation();
const openWith = useFilePanelOpenWithMenu(
{ serverId, workspaceId, tabId: tab.tabId },
contextMenuTestId,
);
const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
);
@@ -937,14 +945,19 @@ function TabChip({
</View>
) : null}
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<ContextMenuSeparator key={entry.key} />
) : (
<TabContextMenuItem key={entry.key} entry={entry} />
),
)}
<ContextMenuContent
align="start"
width={DROPDOWN_WIDTH}
pages={openWith.pages}
testID={contextMenuTestId}
>
{menuEntries.map((entry) => {
if (entry.kind === "separator") return <ContextMenuSeparator key={entry.key} />;
if (entry.kind === "file-panel-open-with") {
return <Fragment key={entry.key}>{openWith.trigger}</Fragment>;
}
return <TabContextMenuItem key={entry.key} entry={entry} />;
})}
</ContextMenuContent>
</ContextMenu>
</View>
@@ -1276,6 +1289,7 @@ function ResolvedWorkspaceDesktopTabsRow({
<ResolvedDesktopTabChip
key={`${item.tab.key}:${item.tab.kind}`}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
item={item}
isFocused={isFocused}
isDragging={isActive}
@@ -1309,6 +1323,7 @@ function ResolvedWorkspaceDesktopTabsRow({
layout.closeButtonPolicy,
layout.items,
normalizedServerId,
normalizedWorkspaceId,
onCloseOtherTabs,
onCloseTab,
onCloseTabsToLeft,
@@ -1429,6 +1444,7 @@ function ResolvedWorkspaceDesktopTabsRow({
}
function ResolvedDesktopTabChip({
serverId,
workspaceId,
item,
isFocused,
isDragging,
@@ -1455,6 +1471,7 @@ function ResolvedDesktopTabChip({
showDropIndicatorAfter,
}: {
serverId: string;
workspaceId: string;
item: ResolvedWorkspaceDesktopTabRowItem;
isFocused: boolean;
isDragging: boolean;
@@ -1536,6 +1553,7 @@ function ResolvedDesktopTabChip({
) : null}
<TabChip
serverId={serverId}
workspaceId={workspaceId}
tab={item.tab}
isActive={item.isActive}
isDragging={isDragging}
@@ -272,6 +272,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
}
copyFilePathEntry.onSelect();
expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts");
expect(entries[1]).toEqual({ kind: "file-panel-open-with", key: "file-panel-open-with" });
});
it("uses a Changes close id for the working diff tab", () => {
@@ -60,6 +60,11 @@ export type WorkspaceTabMenuEntry =
| {
kind: "separator";
key: string;
}
| {
/** Where the file tab's "Open with…" row goes; it renders only for files with several panels. */
kind: "file-panel-open-with";
key: string;
};
interface BuildWorkspaceTabMenuEntriesInput {
@@ -241,6 +246,7 @@ export function buildWorkspaceTabMenuEntries(
void onCopyFilePath(filePath);
},
});
entries.push({ kind: "file-panel-open-with", key: "file-panel-open-with" });
}
if (tab.target.kind === "agent" || tab.target.kind === "terminal") {
@@ -1,4 +1,4 @@
import { useMemo, type ReactElement } from "react";
import { Fragment, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -19,6 +19,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { FilePanelOpenWithMenu } from "@/file-panels";
import type { WorkspaceTabMenuEntry } from "@/screens/workspace/workspace-tab-menu";
import type { Theme } from "@/styles/theme";
@@ -88,10 +89,13 @@ export function MobileTabTrailingAccessory({
menuTestIDBase,
presentationLabel,
menuEntries,
openWith,
}: {
menuTestIDBase: string;
presentationLabel: string;
menuEntries: WorkspaceTabMenuEntry[];
/** A file tab's "Open with…" row and its page. */
openWith?: FilePanelOpenWithMenu;
}): ReactElement {
const { t } = useTranslation();
return (
@@ -110,15 +114,16 @@ export function MobileTabTrailingAccessory({
align="end"
width={220}
sheetTitle={presentationLabel}
pages={openWith?.pages}
testID={menuTestIDBase}
>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<DropdownMenuSeparator key={entry.key} />
) : (
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
),
)}
{menuEntries.map((entry) => {
if (entry.kind === "separator") return <DropdownMenuSeparator key={entry.key} />;
if (entry.kind === "file-panel-open-with") {
return <Fragment key={entry.key}>{openWith?.trigger}</Fragment>;
}
return <MobileTabDropdownMenuItem key={entry.key} entry={entry} />;
})}
</DropdownMenuContent>
</DropdownMenu>
);
+3 -2
View File
@@ -62,8 +62,9 @@
"build": "tsc -p tsconfig.json --incremental false",
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit && tsgo -p tsconfig.examples.json --noEmit",
"test": "vitest run"
"typecheck": "tsgo --noEmit && npm run typecheck:examples",
"test": "vitest run",
"typecheck:examples": "npm --prefix ../../plugin-examples/markdown-panel install --ignore-scripts --no-audit --no-fund && tsgo -p tsconfig.examples.json --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
+2 -2
View File
@@ -89,8 +89,8 @@ describe("plugin SDK import boundaries", () => {
describe("plugin example import boundaries", () => {
const examples = path.resolve(sourceDirectory, "../../../plugin-examples");
const files = readdirSync(examples, { recursive: true }).filter((file) =>
/(?<!\.test)\.tsx?$/.test(file),
const files = readdirSync(examples, { recursive: true }).filter(
(file) => !file.split(path.sep).includes("node_modules") && /(?<!\.test)\.tsx?$/.test(file),
);
const owner = (file: string) => {
const [, directory] = path.relative(examples, file).split(path.sep);
+43
View File
@@ -120,6 +120,48 @@ export interface PluginAgentPanelProps extends PluginNavigableHostProps {
agentId: string;
}
export interface PluginTextDocument {
kind: "text";
text: string;
/** Paseo owns autosave, dirty state, and conflicts. False on native, old daemons, and files above 1 MiB. */
editable: boolean;
setText(next: string): void;
}
export interface PluginBytesDocument {
kind: "bytes";
bytes: Uint8Array;
/** Local URL owned by the host. */
url: string;
}
export interface PluginFilePanelProps extends PluginNavigableHostProps {
workspaceId: string;
file: {
path: string;
name: string;
extension: string;
mimeType: string;
size: number;
modifiedAt: string;
lineStart?: number;
lineEnd?: number;
};
navigationRevision: number;
document: PluginTextDocument | PluginBytesDocument;
}
export interface PluginFilePanelContribution {
id: string;
title: string;
icon: PluginIcon;
files: { extensions: readonly string[]; filenames?: readonly string[] };
document: "text" | "bytes";
/** Defaults to 10 MiB. */
maxBytes?: number;
Component: ComponentType<PluginFilePanelProps>;
}
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
@@ -140,6 +182,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
addScreen(contribution: PluginScreenContribution): PluginCleanup;
addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup;
addFilePanel(contribution: PluginFilePanelContribution): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
+4
View File
@@ -1,5 +1,9 @@
export type {
PluginHostProps,
PluginFilePanelContribution,
PluginFilePanelProps,
PluginTextDocument,
PluginBytesDocument,
PluginSurfaceProps,
PluginScreenProps,
PluginPopoverProps,
+3
View File
@@ -85,3 +85,6 @@ export interface SidebarRowProps {
export declare const SidebarRow: ComponentType<SidebarRowProps>;
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
export declare const SidebarSeparator: ComponentType;
/** Render a local document URL in an isolated frame. */
export declare const DocumentFrame: ComponentType<{ url: string; testID?: string }>;