feat(plugins): file panels (#7411)

* fix(files): report document MIME types for binary files

File reads now carry the document's MIME type for binary files, so a file panel can
hand the bytes to a viewer that needs the real type (application/pdf).

* 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.

* feat(file-panels): choose and forget file panels in Settings > Files

Settings > Files lists each extension or filename where a plugin panel competes with
another candidate, or that has a remembered panel. Each row chooses the panel that opens
it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for
text files. Panels no connected host offers can be forgotten but not chosen.

* 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.

* feat(file-panels): panels read and write through a file handle

The file tab stats the file and mounts its panel without reading it. Every
panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url,
version-checked writeText/writeBytes (PluginFileConflictError unless force),
and watch. useFileText, useFileBytes and useFileUrl read through it, and
Loading joins @getpaseo/plugin/client/ui. Panels own their loading state.

The host document pipeline, the explorer preview cache and the preview
lifecycle are deleted. The daemon classifies a file version from a bounded
sniff and writes base64 content as bytes behind fileByteWrites.

A deleted file keeps its panel mounted; a read confirms the deletion first,
so a remove-and-rewrite is not reported as one. Native Source decodes with
builtins and highlights in idle-time slices, the lines around the opening
line first, so a large file no longer blocks the JS thread.

* fix(file-panels): Settings offers Source where the picker does, and review follow-ups

- Settings > Files offers Source on every key except those a built-in panel of
  another kind (Image) claims, so a plugin that alone claims a text extension
  shows the same choices as the picker.
- Delete the unused isSourceFilePanel.
- The Markdown example warns with the file name when its unmount write fails,
  rather than dropping the edit silently.
- Document that PluginFile stat fields are read at render time and that watch
  or the read hooks are how a panel learns they changed.

* fix(plugins): file events are objects, deletion is a typed error, DocumentFrame owns its fallback

- watch listeners receive { type: "changed" } | { type: "deleted" }, so later
  versions can add fields without breaking them.
- PluginFileDeletedError joins PluginFileConflictError. The read hooks report a
  deleted file with it, and a write over a deleted file rejects with it; Source
  shows that as the deleted notice. PluginFileConflictError now means only
  "changed on disk".
- DocumentFrame on Android shows Paseo's "<TYPE> files can't be shown on this
  device" message for documents its WebView cannot display, so the PDF example
  is useFileUrl + Loading + DocumentFrame with no platform branch.

* test(file-panels): Settings offers Source for a remembered panel no host has

* test(e2e): hide the developer error toast instead of clicking it away

The locator handler that dismissed #error-toast could match a toast that closed before its
click, then wait on it until the test timed out (agent-timeline-resume.spec.ts:45 failed every
attempt on this branch). Hiding the toast with CSS keeps it off app actions with no race.

* test(e2e): desktop browser-tabs hides developer toasts on its attached page
This commit is contained in:
Mohamed Boudra
2026-10-21 10:49:04 +07:00
committed by GitHub
parent b36ff056ea
commit e464fd3bd3
149 changed files with 7364 additions and 3767 deletions
+1
View File
@@ -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),
+1
View File
@@ -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` |
+185
View File
@@ -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;
@@ -1764,6 +1765,190 @@ 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. Your component receives a
handle to the file on the selected host, including remote hosts. It reads the file, shows its own
loading state, and writes the file when the user edits it. The tab's bar shows the panel
switcher, the overflow menu, and a dot while your panel reports unsaved changes.
```tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { PdfPanel } from "./client/pdf-panel";
export default function contribute(client: PluginClientContext) {
return client.addFilePanel({
id: "pdf",
title: "PDF",
icon: "FileText",
files: { extensions: ["pdf"] },
Component: PdfPanel,
});
}
```
`addFilePanel` takes a `PluginFilePanelContribution` and returns an idempotent cleanup function.
Paseo also removes the registration when the client entry is unloaded.
| Field | Required | Meaning |
| ----------- | -------- | -------------------------------------------------------------------------- |
| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. |
| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. |
| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). |
| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. |
| `Component` | Yes | Component receiving `PluginFilePanelProps`. |
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` | `PluginFile` | Handle to the file. Stable while the tab shows this file. |
| `tab` | `PluginFileTab` | `setModified(modified)` shows the tab's unsaved dot and confirms before close. |
| `location` | `PluginFileLocation` | `lineStart?`, `lineEnd?`, and `revision`, which increments on every navigation. |
Paseo mounts your panel once it knows the file exists and its size, without reading it. The
`file` object is stable, and its stat fields hold the latest version Paseo knows. Read them while
rendering; changing them does not re-render your panel. A `watch` listener, or a re-render from
`useFileText`, `useFileBytes`, or `useFileUrl`, is how your panel learns they changed:
| Member | Meaning |
| -------------------------------- | ------------------------------------------------------------------------------------------- |
| `path`, `name`, `extension` | Absolute path on the host, basename, and lowercase extension without the dot (or `""`). |
| `size`, `modifiedAt`, `mimeType` | Size in bytes, modification timestamp, and host-reported MIME type. |
| `readText()` | Reads the file as UTF-8. A byte order mark is dropped and written back by `writeText`. |
| `readBytes()` | Reads the file's bytes. |
| `url()` | A local `blob:` (web) or `file:` (native) URL of the current bytes, for `DocumentFrame`. |
| `writeText(text, options?)` | Writes UTF-8 text if the file has not changed on disk since this handle last read or wrote. |
| `writeBytes(bytes, options?)` | The same for bytes. |
| `watch(listener)` | Calls `listener(event)` with a `PluginFileEvent`; returns the unsubscribe function. |
A `PluginFileEvent` is `{ type: "changed" }` or `{ type: "deleted" }`. Switch on `type`; later
versions may add fields. Your panel stays mounted when the file is deleted. `watch` reports
`{ type: "deleted" }` once a read confirms the file is gone, so a program that removes the file and
writes it back reports only `{ type: "changed" }`. A deleted file that comes back reports
`{ type: "changed" }`.
A write rejects with `PluginFileConflictError` when the file changed on disk after the handle's
last read or write. Pass `{ force: true }` to write anyway. A write rejects with
`PluginFileDeletedError` when the file is gone, with or without `force`. The handle's own writes do not notify
its watchers. Paseo owns the URL from `url()`: it stays valid until a URL for a newer version
resolves or the tab closes. Do not build daemon URLs from `file.path`; local URLs also work when
the daemon is remote.
### Read the file
Most panels read through a hook. Each hook reads when mounted, re-reads when the file changes on
disk, and keeps the current value during a re-read:
| Hook | Ready state |
| -------------------- | ---------------------------------------- |
| `useFileText(file)` | `{ status: "ready", text: string }` |
| `useFileBytes(file)` | `{ status: "ready", bytes: Uint8Array }` |
| `useFileUrl(file)` | `{ status: "ready", url: string }` |
Each also returns `{ status: "loading" }` before the first read and `{ status: "error", error }`
when a read fails. For a deleted file, `error` is a `PluginFileDeletedError`. Show `Loading` from `@getpaseo/plugin/client/ui` until
your content is on screen; it fills the panel, so render it over content that is still laying out.
```tsx
import { type PluginFilePanelProps, useFileUrl } from "@getpaseo/plugin/client";
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";
export function PdfPanel({ file, theme }: PluginFilePanelProps) {
const url = useFileUrl(file);
if (url.status === "loading") return <Loading />;
if (url.status === "error") {
return <Text style={{ color: theme.colors.statusDanger }}>{url.error.message}</Text>;
}
return <DocumentFrame url={url.url} testID="pdf-panel" />;
}
```
### Edit the file
A hook's re-read adopts the version on disk, so the next write overwrites whatever changed there.
A panel that holds unsaved edits reads with `readText()` once, then handles `watch` itself:
1. Read with `file.readText()` and show the text.
2. On each edit, call `tab.setModified(true)` and write with `file.writeText(text)`, debounced.
3. When the write resolves and no newer edit is pending, call `tab.setModified(false)`.
4. On `{ type: "changed" }` with nothing unsaved, read again. With unsaved edits, show a conflict
and let the user reload or overwrite with `{ force: true }`.
5. On `PluginFileConflictError`, show the same conflict.
6. On `{ type: "deleted" }` or `PluginFileDeletedError`, tell the user the file is gone.
Write pending edits before the component unmounts: switching panels or closing the tab unmounts
it. Paseo asks before closing a tab whose panel reports unsaved changes. The
[Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)
example implements these steps.
### Selection and Open with
A plugin panel is a candidate for every file its selector matches, text or binary. The built-in
panels require the host's file kind: Markdown, HTML, and **Source** open text files; Image opens
images. Candidates are ordered: the built-in Markdown, HTML, and Image panels, then plugin panels
in host catalog order, then **Source**, which every text file has. Source opens a file by default
only when nothing else claims it.
A remembered association selects its panel when that panel is available on the current host.
Otherwise Paseo opens the first candidate. When a plugin panel is among several candidates and no
association applies, Paseo asks once per tab open, over the panel it already opened. Built-in
panels alone never ask: a Markdown file opens with Markdown and offers Source in the switcher. With
a Markdown plugin installed, the picker offers **Markdown — Paseo**, **Rich Markdown —
markdown-panel**, and **Source — Paseo**. A plugin that claims a text extension competes with
Source, so its first open asks; a plugin that alone claims a binary file opens without asking.
**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 file URL from
`file.url()` or `useFileUrl`:
| Prop | Required | Meaning |
| -------- | -------- | -------------------------------------------------------------------------------- |
| `url` | Yes | Local file URL; `blob:` on web or `file:` on native. Other schemes are rejected. |
| `testID` | No | Test identifier for the frame. |
Web inspects the local 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. Where the platform cannot show the document inline, such as a PDF on Android,
`DocumentFrame` shows a message naming the file type instead, so render it on every platform.
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, `DocumentFrame`, and `Loading` 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.
Writing with `writeBytes` needs a daemon from Paseo 0.13.0 or later.
Complete directory examples:
- [PDF panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/pdf-panel): `useFileUrl` with `Loading` and `DocumentFrame`.
- [Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel): a bundled CodeMirror editor that reads once, saves with `writeText`, and resolves conflicts, 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.