mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
docs(plugins): file panel examples and author reference
Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin reference, and an e2e spec that installs both examples, opens a PDF in the browser viewer, and edits Markdown through Paseo's document across the switcher, Source, autosave and conflicts.
This commit is contained in:
@@ -556,6 +556,17 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab, rendering host-owned text or
|
||||
bytes. The tab's bar owns the document's status (size, lines, save state, conflicts) and switches
|
||||
between the file's panels; Source, Paseo's editor, is a built-in panel for every text file and
|
||||
always the last choice. Keep editing in the host document so autosave and conflicts have one
|
||||
owner. See the
|
||||
[public file panel reference](../public-docs/plugins/reference.md#file-panels) for selectors,
|
||||
selection, document props, `DocumentFrame`, and the version requirement. The examples live in
|
||||
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Frame, TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { copyPluginExample } 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 { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
|
||||
// Full Chromium includes the PDF viewer; the default headless shell does not.
|
||||
test.use({ channel: "chromium" });
|
||||
|
||||
function samplePdf() {
|
||||
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
|
||||
const offsets = objects.map((object, index) => {
|
||||
const offset = Buffer.byteLength(pdf);
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
return offset;
|
||||
});
|
||||
const xref = Buffer.byteLength(pdf);
|
||||
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
|
||||
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return pdf;
|
||||
}
|
||||
|
||||
async function openFile(page: Page, name: string) {
|
||||
await openFileFromExplorer(page, name);
|
||||
await expectFileTabOpen(page, name);
|
||||
}
|
||||
|
||||
const WIDE = { width: 1280, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
|
||||
async function captureWideAndCompact(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
ready: () => Promise<void>,
|
||||
) {
|
||||
await capture(page, testInfo, `${name}-wide`);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await ready();
|
||||
await capture(page, testInfo, `${name}-compact`);
|
||||
await page.setViewportSize(WIDE);
|
||||
await ready();
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
/** Turns on vim keybindings without discarding settings the test writes later. */
|
||||
async function enableVimKeybindings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseRichMarkdown(page: Page) {
|
||||
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
|
||||
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Use", exact: true }).click();
|
||||
}
|
||||
|
||||
async function replaceMarkdown(page: Page, text: string) {
|
||||
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
|
||||
}
|
||||
|
||||
async function selectPanel(page: Page, title: string) {
|
||||
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPanels(page: Page, titles: string[]) {
|
||||
for (const title of titles) {
|
||||
await expect(
|
||||
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
|
||||
async function pdfViewer(page: Page): Promise<Frame> {
|
||||
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
|
||||
const find = () =>
|
||||
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
|
||||
await expect.poll(() => find() !== null).toBe(true);
|
||||
return find()!;
|
||||
}
|
||||
|
||||
async function expectPdfViewerReady(page: Page) {
|
||||
// A narrow viewer collapses its toolbar to Print and More actions.
|
||||
const viewer = await pdfViewer(page);
|
||||
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
async function expectMarkdownEditor(page: Page, text: string) {
|
||||
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toContainText(text);
|
||||
await expect(editor).toBeInViewport({ ratio: 1 });
|
||||
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
|
||||
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
|
||||
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("directory examples mount the PDF frame and edit Markdown through Paseo's document", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const pdf = await copyPluginExample("pdf-panel");
|
||||
const markdown = await copyPluginExample("markdown-panel");
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: markdown.directory, stdio: "pipe" },
|
||||
);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-examples-",
|
||||
repo: {
|
||||
files: [
|
||||
{
|
||||
path: "notes.md",
|
||||
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
|
||||
},
|
||||
{ path: "second.md", content: "# Remembered panel\n" },
|
||||
{ path: "report.pdf", content: samplePdf() },
|
||||
],
|
||||
},
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(pdf.directory);
|
||||
await client.installDirectoryPlugin(markdown.directory);
|
||||
await enableVimKeybindings(page);
|
||||
await page.setViewportSize(WIDE);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFile(page, "report.pdf");
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
|
||||
"src",
|
||||
/^blob:/,
|
||||
);
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
|
||||
"sandbox",
|
||||
);
|
||||
const documentUrl = await page
|
||||
.getByTitle("Document preview", { exact: true })
|
||||
.getAttribute("src");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
|
||||
documentUrl,
|
||||
),
|
||||
).toBe("application/pdf");
|
||||
await expectPdfViewerReady(page);
|
||||
await expect(
|
||||
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
|
||||
).toHaveValue("1");
|
||||
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
|
||||
await expectPdfViewerReady(page);
|
||||
});
|
||||
|
||||
await openFile(page, "notes.md");
|
||||
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
});
|
||||
await chooseRichMarkdown(page);
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
|
||||
"Markdown panel example",
|
||||
);
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-dirty-wide");
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
|
||||
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
});
|
||||
|
||||
// Source is Paseo's editor; its cursor and vim mode join the bar's status.
|
||||
await selectPanel(page, "Source");
|
||||
const source = page.getByTestId("file-source-editor").filter({ visible: true });
|
||||
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-source-wide");
|
||||
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
await filePanelSwitcher(page).click();
|
||||
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Source");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
|
||||
await capture(page, testInfo, "markdown-example-source-compact");
|
||||
await page.setViewportSize(WIDE);
|
||||
await selectPanel(page, "Rich Markdown");
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
|
||||
"# External update",
|
||||
);
|
||||
await replaceMarkdown(page, "# Local edit\n");
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-conflict-wide");
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Local edit\n");
|
||||
|
||||
await openFile(page, "second.md");
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
|
||||
"Remembered panel",
|
||||
);
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectMarkdownEditor(page, "Remembered panel");
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
} finally {
|
||||
await client.removePlugin("pdf-panel");
|
||||
await client.removePlugin("markdown-panel");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await pdf.cleanup();
|
||||
await markdown.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,22 @@
|
||||
# Markdown file panel example
|
||||
|
||||
Requires Paseo 0.13.0 or later. Install the editor dependencies, then install this directory:
|
||||
|
||||
```sh
|
||||
cd plugin-examples/markdown-panel
|
||||
npm install
|
||||
paseo plugin install "$PWD"
|
||||
```
|
||||
|
||||
Open a `.md` or `.markdown` file in Files. Choose **Rich Markdown — markdown-panel** in the
|
||||
picker. Check **Don't ask me again** to remember the choice on this device.
|
||||
|
||||
The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo, redo, and line
|
||||
wrapping. Edits call the host document's `setText`; Paseo owns autosave, the dirty dot, and
|
||||
external-change conflicts. The bar's switcher still offers Paseo's Markdown and Source panels. Files above 1 MiB or hosts
|
||||
without editing support render read-only. Native shows selectable, read-only text.
|
||||
|
||||
Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native
|
||||
path uses React Native primitives. DOM integration lives in `client/web.ts`.
|
||||
|
||||
See the [file panel reference](https://paseo.sh/docs/plugins/reference#file-panels).
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { mountEditor } from "./web";
|
||||
|
||||
interface TextPanelProps {
|
||||
document: PluginTextDocument;
|
||||
theme: PluginFilePanelProps["theme"];
|
||||
}
|
||||
|
||||
const editorStyle = { flex: 1, minHeight: 0 };
|
||||
|
||||
function MarkdownEditor({ document, theme }: TextPanelProps) {
|
||||
const parent = useRef<View>(null);
|
||||
const editor = useRef<ReturnType<typeof mountEditor>>(null);
|
||||
const currentDocument = useRef(document);
|
||||
currentDocument.current = document;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
editor.current = mountEditor(parent.current, currentDocument.current, theme, (text) => {
|
||||
currentDocument.current.setText(text);
|
||||
});
|
||||
return () => {
|
||||
editor.current?.destroy();
|
||||
editor.current = null;
|
||||
};
|
||||
}, [theme, document.editable]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
editor.current?.setText(document.text);
|
||||
}, [document.text]);
|
||||
|
||||
return <View ref={parent} style={editorStyle} />;
|
||||
}
|
||||
|
||||
export function RichMarkdownPanel({ document, theme, layout }: PluginFilePanelProps) {
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
||||
text: { color: theme.colors.foreground, padding: 16 },
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
if (document.kind !== "text") return null;
|
||||
if (layout.platform === "web") return <MarkdownEditor document={document} theme={theme} />;
|
||||
return (
|
||||
<ScrollView style={styles.screen}>
|
||||
<Text selectable style={styles.text}>
|
||||
{document.text}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
|
||||
import type { EditorView, EditorViewConfig } from "@codemirror/view";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
/** DOM code stays here; native never evaluates the editor dependencies. */
|
||||
export function mountEditor(
|
||||
parent: unknown,
|
||||
document: PluginTextDocument,
|
||||
theme: PluginFilePanelProps["theme"],
|
||||
onChange: (text: string) => void,
|
||||
) {
|
||||
if (Platform.OS !== "web" || !parent) return null;
|
||||
const { EditorState } = require("@codemirror/state") as typeof import("@codemirror/state");
|
||||
const { EditorView, keymap, lineNumbers } =
|
||||
require("@codemirror/view") as typeof import("@codemirror/view");
|
||||
const { defaultKeymap, history, historyKeymap } =
|
||||
require("@codemirror/commands") as typeof import("@codemirror/commands");
|
||||
const { markdown } =
|
||||
require("@codemirror/lang-markdown") as typeof import("@codemirror/lang-markdown");
|
||||
const { syntaxHighlighting, defaultHighlightStyle } =
|
||||
require("@codemirror/language") as typeof import("@codemirror/language");
|
||||
let applyingHostText = false;
|
||||
const view: EditorView = new EditorView({
|
||||
parent: parent as EditorViewConfig["parent"],
|
||||
state: EditorState.create({
|
||||
doc: document.text,
|
||||
extensions: [
|
||||
markdown(),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
lineNumbers(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(!document.editable),
|
||||
EditorView.editable.of(document.editable),
|
||||
EditorView.contentAttributes.of({ "aria-label": "Rich Markdown editor" }),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": { padding: "16px", caretColor: theme.colors.foreground },
|
||||
".cm-gutters": {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
border: "none",
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !applyingHostText) onChange(update.state.doc.toString());
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return {
|
||||
setText(text: string) {
|
||||
if (view.state.doc.toString() === text) return;
|
||||
applyingHostText = true;
|
||||
try {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
|
||||
} finally {
|
||||
applyingHostText = false;
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
view.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { RichMarkdownPanel } from "./client/rich-markdown";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addFilePanel({
|
||||
id: "rich-markdown",
|
||||
title: "Rich Markdown",
|
||||
icon: "FileType",
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
document: "text",
|
||||
Component: RichMarkdownPanel,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function contribute() {
|
||||
return () => {};
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin-example-markdown-panel",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@getpaseo/plugin-example-markdown-panel",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.0",
|
||||
"@codemirror/lang-markdown": "^6.3.0",
|
||||
"@codemirror/language": "^6.12.0",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.43.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
|
||||
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
|
||||
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
|
||||
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/streamparser": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/streamparser": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
|
||||
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
|
||||
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
|
||||
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
|
||||
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.0.tgz",
|
||||
"integrity": "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin-example-markdown-panel",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.0",
|
||||
"@codemirror/lang-markdown": "^6.3.0",
|
||||
"@codemirror/language": "^6.12.0",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.43.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "markdown-panel",
|
||||
"requirements": {
|
||||
"paseo": ">=0.13.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# PDF file panel example
|
||||
|
||||
Requires Paseo 0.13.0 or later. Install this directory with
|
||||
`paseo plugin install /absolute/path/to/plugin-examples/pdf-panel`, then open a `.pdf` in Files.
|
||||
|
||||
The panel passes Paseo's local document URL to `DocumentFrame` on web and iOS.
|
||||
Files up to 50 MiB match. Android shows the filename, size, and a message that inline PDF rendering is unavailable. The plugin does not read files or construct daemon URLs.
|
||||
|
||||
See the [file panel reference](https://paseo.sh/docs/plugins/reference#file-panels).
|
||||
|
||||
Inline PDF rendering needs a daemon from Paseo 0.13.0 or later; see the
|
||||
[DocumentFrame reference](https://paseo.sh/docs/plugins/reference#documentframe).
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
|
||||
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
const fallbackStyle = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
} as const;
|
||||
|
||||
export function PdfPanel({ document, file, theme, layout }: PluginFilePanelProps) {
|
||||
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
|
||||
if (document.kind !== "bytes") return null;
|
||||
if (layout.platform === "android") {
|
||||
return (
|
||||
<View style={fallbackStyle}>
|
||||
<Text style={textStyle}>
|
||||
{file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <DocumentFrame url={document.url} testID="pdf-panel" />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { PdfPanel } from "./client/pdf-panel";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addFilePanel({
|
||||
id: "pdf",
|
||||
title: "PDF",
|
||||
icon: "FileText",
|
||||
files: { extensions: ["pdf"] },
|
||||
document: "bytes",
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
Component: PdfPanel,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function contribute() {
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "pdf-panel",
|
||||
"requirements": {
|
||||
"paseo": ">=0.13.0"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ category: Plugins
|
||||
A plugin is a TypeScript project installed into one Paseo daemon. It can add
|
||||
[screens and sidebar items](/docs/plugins/reference#screens-and-sidebar-items),
|
||||
[workspace panels](/docs/plugins/reference#workspace-panels),
|
||||
[file panels](/docs/plugins/reference#file-panels),
|
||||
[Command Center items](/docs/plugins/reference#command-center-items),
|
||||
[slash commands](/docs/plugins/reference#slash-commands),
|
||||
[composer pills](/docs/plugins/reference#composer-pills),
|
||||
|
||||
@@ -73,6 +73,7 @@ Use this table as the complete registration checklist.
|
||||
| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` |
|
||||
| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) |
|
||||
| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` |
|
||||
| New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` |
|
||||
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
|
||||
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
|
||||
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
|
||||
|
||||
@@ -16,6 +16,7 @@ Local plugins are directory sources installed into one Paseo daemon. A plugin ca
|
||||
|
||||
- React Native screens and sidebar header and footer items to Paseo clients;
|
||||
- workspace and agent panels opened as workspace tabs;
|
||||
- [file panels](#file-panels) for viewing or editing matching files;
|
||||
- global, workspace, and agent actions in the Command Center;
|
||||
- slash commands in the message composer;
|
||||
- transformed and daemon-pushed agent timeline rows;
|
||||
@@ -1755,6 +1756,156 @@ Agent snapshot fields:
|
||||
|
||||
Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC providers, and the render error boundary. A restored tab whose plugin, panel, context, workspace, or agent is unavailable stays open with an unavailable message instead of crashing the workspace.
|
||||
|
||||
## File panels
|
||||
|
||||
Register a component that renders a file as the body of its file tab. Paseo loads the document
|
||||
from the selected host, including remote hosts. Your plugin receives its contents and edits
|
||||
through the host document. The tab's bar keeps the file's size, line count, save state, and
|
||||
conflict alerts, and switches between every panel that can open the file.
|
||||
|
||||
```tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { RichMarkdownPanel } from "./client/rich-markdown";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
return client.addFilePanel({
|
||||
id: "rich-markdown",
|
||||
title: "Rich Markdown",
|
||||
icon: "FileType",
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
document: "text",
|
||||
Component: RichMarkdownPanel,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`addFilePanel` takes a `PluginFilePanelContribution` and returns an idempotent cleanup function.
|
||||
Paseo also removes the registration when the client entry is unloaded.
|
||||
|
||||
| Field | Required | Meaning |
|
||||
| ----------- | -------- | -------------------------------------------------------------------------------------- |
|
||||
| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. |
|
||||
| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. |
|
||||
| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). |
|
||||
| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. |
|
||||
| `document` | Yes | `"text"` or `"bytes"`; selects the document passed to the component. |
|
||||
| `maxBytes` | No | Positive finite size limit in bytes. Defaults to 10 MiB. Larger files skip this panel. |
|
||||
| `Component` | Yes | Component receiving `PluginFilePanelProps`. |
|
||||
|
||||
Extensions have no leading dot and match without case sensitivity. Filenames match the exact
|
||||
basename, including case. Selectors contain no paths, globs, or wildcards. Use `extensions: []`
|
||||
for a filename-only selector. At least one extension or filename is required.
|
||||
|
||||
### File panel props
|
||||
|
||||
`PluginFilePanelProps` includes the common `theme`, `host`, `layout`, and optional `navigation`
|
||||
props described under [Theme and layout](#theme-and-layout), plus:
|
||||
|
||||
| Prop | Type | Meaning |
|
||||
| -------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Workspace containing the file tab. |
|
||||
| `file.path` | `string` | Absolute path on the selected host. |
|
||||
| `file.name` | `string` | Basename. |
|
||||
| `file.extension` | `string` | Lowercase extension without the dot, or `""`. |
|
||||
| `file.mimeType` | `string` | Host-reported MIME type; binary files can report `application/octet-stream`. |
|
||||
| `file.size` | `number` | Size in bytes. |
|
||||
| `file.modifiedAt` | `string` | Modification timestamp. |
|
||||
| `file.lineStart`, `file.lineEnd` | `number` (optional) | Requested line range. |
|
||||
| `navigationRevision` | `number` | Increments when the same tab receives another line navigation. |
|
||||
| `document` | `PluginTextDocument` or `PluginBytesDocument` | Matches the registration's `document` field. |
|
||||
|
||||
A `PluginTextDocument` keeps the text owned by Paseo:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| ---------- | ------------------------ | -------------------------------------------------------------------------------------- |
|
||||
| `kind` | `"text"` | Document discriminator. |
|
||||
| `text` | `string` | Current editor text, including external updates. |
|
||||
| `editable` | `boolean` | False on native, daemons without file editing, or files above 1 MiB. Render read-only. |
|
||||
| `setText` | `(next: string) => void` | Replace editor text. Paseo owns autosave, dirty state, and conflict handling. |
|
||||
|
||||
Call `document.setText(next)` from your editor's change callback when `editable` is true.
|
||||
Update your editor when `document.text` changes; do not send that update back as a user edit.
|
||||
Do not write the file through RPCs or maintain a second save workflow. The **Source** panel in
|
||||
the switcher opens Paseo's text editor on the same document.
|
||||
|
||||
A `PluginBytesDocument` is read-only:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| ------- | ------------ | ------------------------------------------------------------------------------------- |
|
||||
| `kind` | `"bytes"` | Document discriminator. |
|
||||
| `bytes` | `Uint8Array` | Loaded file bytes. |
|
||||
| `url` | `string` | Host-owned local preview URL: `blob:` on web, `file:` on native. Valid while mounted. |
|
||||
|
||||
Use `bytes` for your own renderer or pass `url` to `DocumentFrame`. Do not reconstruct a daemon
|
||||
URL from `file.path`; local document URLs also work when the daemon is remote.
|
||||
|
||||
### Selection and Open with
|
||||
|
||||
Text panels require a text file. Bytes panels accept any file with loaded bytes. Paseo filters
|
||||
matching panels by document kind and size before selecting one. Candidates are ordered: the
|
||||
built-in Markdown, HTML, and Image panels, then plugin panels in host catalog order, then the
|
||||
built-in **Source** panel, which every text file has. Source opens a file by default only when
|
||||
nothing else claims it.
|
||||
|
||||
A remembered association selects its panel when that panel is available on the current host.
|
||||
Otherwise Paseo opens the first candidate. When a plugin panel is among several candidates and no
|
||||
association applies, Paseo asks once per tab open, over the panel it already opened. Built-in
|
||||
panels alone never ask: a Markdown file opens with Markdown and offers Source in the switcher. With
|
||||
a Markdown plugin installed, the picker offers **Markdown — Paseo**, **Rich Markdown —
|
||||
markdown-panel**, and **Source — Paseo**. A plugin that claims a text extension competes with
|
||||
Source, so its first open asks; a bytes plugin that alone claims a binary file opens without
|
||||
asking.
|
||||
|
||||
**Don't ask me again** remembers the choice on this device for the extension. When a candidate
|
||||
matches an exact filename, the choice applies to that basename instead. An unavailable association
|
||||
is ignored on that host and retained for hosts where its panel is installed.
|
||||
|
||||
Users switch the current tab with the bar's panel switcher, which shows up to three panels side by
|
||||
side (two on a phone) and a menu beyond that, or with **Open with…** in the tab menu. That choice wins over the
|
||||
remembered association for this tab until it is retargeted. **Always use**, in the bar's overflow
|
||||
menu and in Open with, remembers the shown panel. Opening a file at a line, from a link or a
|
||||
search result, switches the tab to Source. Files with no candidate show **Binary preview
|
||||
unavailable**; binary files have no Source panel.
|
||||
|
||||
### DocumentFrame
|
||||
|
||||
Import `DocumentFrame` from `@getpaseo/plugin/client/ui` to display a local document URL:
|
||||
|
||||
```tsx
|
||||
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
|
||||
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
export function PdfPanel({ document }: PluginFilePanelProps) {
|
||||
if (document.kind !== "bytes") return null;
|
||||
return <DocumentFrame url={document.url} testID="pdf-panel" />;
|
||||
}
|
||||
```
|
||||
|
||||
| Prop | Required | Meaning |
|
||||
| -------- | -------- | ----------------------------------------------------------------------------------- |
|
||||
| `url` | Yes | Host document URL; `blob:` on web or `file:` on native. Other schemes are rejected. |
|
||||
| `testID` | No | Test identifier for the frame. |
|
||||
|
||||
Web inspects the local document URL’s media type before mounting the iframe. PDF uses the
|
||||
browser’s PDF viewer without a sandbox; every other type, including HTML and unknown types,
|
||||
keeps a full sandbox with no scripts or same-origin access. Native uses a WebView with
|
||||
JavaScript, storage, and further navigation disabled. iOS supports inline PDF rendering; Android's
|
||||
WebView does not. Provide an Android fallback or bundle your own renderer.
|
||||
|
||||
Inline PDF rendering needs a daemon from Paseo 0.13.0 or later; older daemons report PDFs as
|
||||
`application/octet-stream`.
|
||||
|
||||
### Version requirement and examples
|
||||
|
||||
File panels and `DocumentFrame` require Paseo 0.13.0 or later. Declare
|
||||
`"requirements": { "paseo": ">=0.13.0" }` in `paseo-plugin.json`. Both daemon and app check the
|
||||
[version requirement](#requirements); older apps skip the client bundle before evaluation.
|
||||
|
||||
Complete directory examples:
|
||||
|
||||
- [PDF panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/pdf-panel): bytes with `DocumentFrame` and an Android fallback.
|
||||
- [Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel): a bundled CodeMirror editor that calls the host document's `setText`, with read-only native text.
|
||||
|
||||
## Command Center items
|
||||
|
||||
Open the Command Center with **⌘K** on macOS or **Ctrl+K** on Windows and Linux, then search for the item title.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: paseo-plugin
|
||||
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a screen, sidebar header or footer item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs.
|
||||
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a screen, sidebar header or footer item, workspace panel, or file panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs.
|
||||
---
|
||||
|
||||
# Paseo plugins
|
||||
@@ -31,6 +31,7 @@ Pick the contribution that matches the request. Each row names the registration,
|
||||
| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
|
||||
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
|
||||
| File panel | `addFilePanel` | View or edit a matching file inside the file tab, using the host document | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
|
||||
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
|
||||
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
|
||||
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
|
||||
|
||||
Reference in New Issue
Block a user