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:
Mohamed Boudra
2026-10-19 13:40:04 +02:00
parent 9d2533183b
commit 970b41cb24
20 changed files with 930 additions and 1 deletions
@@ -0,0 +1 @@
node_modules/
+22
View File
@@ -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
View File
@@ -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"
}
}
+12
View File
@@ -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"
}
}