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.
This commit is contained in:
Mohamed Boudra
2026-10-20 08:18:20 +02:00
parent cd102d08b6
commit 58edbdcd0e
22 changed files with 168 additions and 56 deletions
@@ -1,9 +1,30 @@
import { useCallback, useMemo, useRef } from "react";
import { Platform, Text, View } from "react-native";
import { WebView } from "react-native-webview";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
const frameStyle = { flex: 1, backgroundColor: "white" };
const originWhitelist = ["*"];
/** What Android's WebView shows inline. It has no viewer for PDF or other documents. */
const ANDROID_INLINE_EXTENSIONS = new Set([
"html",
"htm",
"xhtml",
"xml",
"svg",
"txt",
"json",
"png",
"jpg",
"jpeg",
"gif",
"webp",
"bmp",
"avif",
]);
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
const source = useMemo(() => ({ uri: url }), [url]);
const loaded = useRef<string | null>(null);
@@ -16,6 +37,11 @@ export function DocumentFrame({ url, testID }: { url: string; testID?: string })
[url],
);
if (!url.startsWith("file:")) return null;
// The host names a file URL after the file, so its extension is the document's type.
const extension = urlExtension(url);
if (Platform.OS === "android" && !ANDROID_INLINE_EXTENSIONS.has(extension)) {
return <UnsupportedDocument extension={extension} testID={testID} />;
}
return (
<WebView
testID={testID}
@@ -37,3 +63,36 @@ export function DocumentFrame({ url, testID }: { url: string; testID?: string })
/>
);
}
function UnsupportedDocument({ extension, testID }: { extension: string; testID?: string }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID={testID}>
<Text style={styles.mutedText}>
{t("panels.file.documentUnsupported", { type: extension.toUpperCase() })}
</Text>
</View>
);
}
function urlExtension(url: string): string {
const path = url.split(/[?#]/)[0];
const name = path.slice(path.lastIndexOf("/") + 1);
const dot = name.lastIndexOf(".");
return dot < 0 ? "" : name.slice(dot + 1).toLowerCase();
}
const styles = StyleSheet.create((theme) => ({
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
backgroundColor: theme.colors.surface0,
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
@@ -1,4 +1,8 @@
import { PluginFileConflictError, type PluginFileEvent } from "@getpaseo/plugin/client";
import {
PluginFileConflictError,
PluginFileDeletedError,
type PluginFileEvent,
} from "@getpaseo/plugin/client";
import { describe, expect, it } from "vitest";
import { FileHandle } from "./handle";
import { InMemoryFileSession } from "./testing";
@@ -9,8 +13,8 @@ async function openFile(content: string | Uint8Array = "first\n") {
const handle = new FileHandle(session, { cwd: "/repo", path: "notes.md" }, "/repo/notes.md");
handle.setObserving(true);
await settle();
const events: PluginFileEvent[] = [];
handle.file.watch((event) => events.push(event));
const events: PluginFileEvent["type"][] = [];
handle.file.watch((event) => events.push(event.type));
return { session, handle, file: handle.file, events };
}
@@ -56,6 +60,18 @@ describe("FileHandle", () => {
expect(events).toEqual(["changed"]);
});
it("rejects a write over a deleted file it has not observed yet", async () => {
const { session, handle, file } = await openFile();
await file.readText();
handle.setObserving(false);
session.remove("notes.md");
await expect(file.writeText("mine\n")).rejects.toBeInstanceOf(PluginFileDeletedError);
await expect(file.writeText("mine\n", { force: true })).rejects.toBeInstanceOf(
PluginFileDeletedError,
);
});
it("forces over a change it has not observed yet", async () => {
const { session, handle, file } = await openFile();
await file.readText();
+5 -5
View File
@@ -1,5 +1,6 @@
import {
PluginFileConflictError,
PluginFileDeletedError,
type PluginFile,
type PluginFileEvent,
type PluginFileWriteOptions,
@@ -253,7 +254,7 @@ export class FileHandle {
this.deleted = false;
if (this.seen.has(key) && !restored) return;
this.seen.add(key);
this.emit("changed");
this.emit({ type: "changed" });
}
private async confirmDeletion(): Promise<void> {
@@ -274,7 +275,7 @@ export class FileHandle {
}
if (this.latest?.status !== "missing") return;
this.deleted = true;
this.emit("deleted");
this.emit({ type: "deleted" });
}
private emit(event: PluginFileEvent): void {
@@ -359,9 +360,8 @@ export class FileHandle {
expected = disk;
continue;
}
throw new PluginFileConflictError(
disk.status === "missing" ? "The file was deleted." : undefined,
);
if (disk.status === "missing") throw new PluginFileDeletedError();
throw new PluginFileConflictError();
}
} finally {
this.writing -= 1;
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { PluginFileDeletedError, useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { describe, expect, it } from "vitest";
import { FileHandle } from "./handle";
import { InMemoryFileSession } from "./testing";
@@ -31,7 +31,7 @@ describe("file hooks", () => {
expect(states.lastIndexOf("loading")).toBeLessThan(states.indexOf("ready"));
});
it("reports a deleted file as an error", async () => {
it("reports a deleted file as a PluginFileDeletedError", async () => {
const { session, file } = openFile();
const { result } = renderHook(() => useFileText(file));
await waitFor(() => expect(result.current.status).toBe("ready"));
@@ -39,8 +39,8 @@ describe("file hooks", () => {
act(() => session.remove("notes.md"));
await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.status === "error" && result.current.error.message).toBe(
"The file was deleted.",
expect(result.current.status === "error" && result.current.error).toBeInstanceOf(
PluginFileDeletedError,
);
});
@@ -188,6 +188,18 @@ describe("FileEditorModel", () => {
expect(clock.scheduled).toBe(false);
});
test("a save over a deletion it has not heard about shows the deleted notice", async () => {
const { session, handle, model } = await openEditor();
handle.setObserving(false);
session.remove("source.ts");
model.edit("local\n");
await model.save();
expect(getFileConflictCallout(model.getSnapshot())).toEqual({ kind: "deleted" });
expect(model.getSnapshot().content).toBe("local\n");
});
test("an external change during a save is reconciled after it", async () => {
const { session, handle, model } = await openEditor();
@@ -1,5 +1,6 @@
import {
PluginFileConflictError,
PluginFileDeletedError,
type PluginFile,
type PluginFileEvent,
} from "@getpaseo/plugin/client";
@@ -145,14 +146,14 @@ export class FileEditorModel {
private receiveFileEvent(event: PluginFileEvent): void {
if (this.disposed) return;
if (!this.isLoaded()) {
if (event === "changed") void this.reload();
if (event.type === "changed") void this.reload();
return;
}
if (this.saving) {
this.eventDuringSave = event;
return;
}
if (event === "deleted") {
if (event.type === "deleted") {
this.enterConflict("deleted");
return;
}
@@ -170,7 +171,7 @@ export class FileEditorModel {
}
if (this.disposed || sequence !== this.readSequence) return;
if (this.saving) {
this.eventDuringSave = "changed";
this.eventDuringSave = { type: "changed" };
return;
}
if (content === this.persisted) {
@@ -198,6 +199,7 @@ export class FileEditorModel {
this.saving = false;
if (this.disposed || sequence !== this.saveSequence) return;
if (error instanceof PluginFileConflictError) this.enterConflict("changed");
else if (error instanceof PluginFileDeletedError) this.enterConflict("deleted");
else this.setSnapshot({ ...this.snapshot, status: "error", error: errorMessage(error) });
this.receivePendingEvent();
return;
+1
View File
@@ -1941,6 +1941,7 @@ export const ar: TranslationResources = {
noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
documentUnsupported: "لا يمكن عرض ملفات {{type}} على هذا الجهاز",
failedToLoad: "فشل تحميل الملف",
notFound: "الملف غير موجود",
failedToLoadPreview: "فشل تحميل معاينة الملف",
+1
View File
@@ -1966,6 +1966,7 @@ export const en = {
noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable",
tooLargeToDisplay: "This file is too large to display",
documentUnsupported: "{{type}} files can't be shown on this device",
failedToLoad: "Failed to load file",
notFound: "File not found",
failedToLoadPreview: "Failed to load file preview",
+1
View File
@@ -1989,6 +1989,7 @@ export const es: TranslationResources = {
noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible",
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
documentUnsupported: "Los archivos {{type}} no se pueden mostrar en este dispositivo",
failedToLoad: "No se pudo cargar el archivo",
notFound: "No se encontró el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
+1
View File
@@ -1987,6 +1987,7 @@ export const fr: TranslationResources = {
noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu indisponible pour un fichier binaire",
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
documentUnsupported: "Les fichiers {{type}} ne peuvent pas être affichés sur cet appareil",
failedToLoad: "Impossible de charger le fichier",
notFound: "Fichier introuvable",
failedToLoadPreview: "Impossible de charger l’aperçu du fichier",
+1
View File
@@ -1960,6 +1960,7 @@ export const ja: TranslationResources = {
noPreview: "プレビューが利用できません",
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
documentUnsupported: "{{type}} ファイルはこのデバイスでは表示できません",
failedToLoad: "ファイルの読み込みに失敗しました",
notFound: "ファイルが見つかりません",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
+1
View File
@@ -1951,6 +1951,7 @@ export const ko: TranslationResources = {
noPreview: "사용 가능한 미리보기가 없습니다",
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
documentUnsupported: "{{type}} 파일은 이 기기에서 표시할 수 없습니다",
failedToLoad: "파일을 불러오지 못했습니다",
notFound: "파일을 찾을 수 없습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
+1
View File
@@ -1974,6 +1974,7 @@ export const ptBR: TranslationResources = {
noPreview: "Nenhuma prévia disponível",
binaryPreviewUnavailable: "Prévia binária indisponível",
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
documentUnsupported: "Arquivos {{type}} não podem ser exibidos neste dispositivo",
failedToLoad: "Falha ao carregar arquivo",
notFound: "Arquivo não encontrado",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
+1
View File
@@ -1973,6 +1973,7 @@ export const ru: TranslationResources = {
noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
tooLargeToDisplay: "Этот файл слишком велик для отображения",
documentUnsupported: "Файлы {{type}} нельзя показать на этом устройстве",
failedToLoad: "Не удалось загрузить файл",
notFound: "Файл не найден",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
+1
View File
@@ -1919,6 +1919,7 @@ export const zhCN: TranslationResources = {
noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用",
tooLargeToDisplay: "此文件过大,无法显示",
documentUnsupported: "无法在此设备上显示 {{type}} 文件",
failedToLoad: "加载文件失败",
notFound: "找不到文件",
failedToLoadPreview: "加载文件预览失败",
+1 -1
View File
@@ -698,7 +698,7 @@ describe("evaluatePluginClientBundle", () => {
const { ExternalLink, Loading } = require("@getpaseo/plugin/client/ui");
if (typeof ExternalLink !== "function") throw new Error("ExternalLink");
if (typeof Loading !== "function") throw new Error("Loading");
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl", "useFileText", "useFileBytes", "useFileUrl", "PluginFileConflictError"]) {
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl", "useFileText", "useFileBytes", "useFileUrl", "PluginFileConflictError", "PluginFileDeletedError"]) {
if (name in shared || typeof client[name] !== "function") throw new Error(name);
}
if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports");
+26 -7
View File
@@ -24,7 +24,8 @@ export interface PluginFile {
url(): Promise<string>;
/**
* Writes the file if it has not changed since this handle last read or wrote it. Otherwise
* rejects with `PluginFileConflictError`; `force: true` writes anyway.
* rejects with `PluginFileConflictError`; `force: true` writes anyway. Rejects with
* `PluginFileDeletedError` when the file is gone.
*/
writeText(text: string, options?: PluginFileWriteOptions): Promise<void>;
writeBytes(bytes: Uint8Array, options?: PluginFileWriteOptions): Promise<void>;
@@ -40,7 +41,8 @@ export interface PluginFileWriteOptions {
force?: boolean;
}
export type PluginFileEvent = "changed" | "deleted";
/** What happened to the file on disk. Switch on `type`; later versions may add fields. */
export type PluginFileEvent = { type: "changed" } | { type: "deleted" };
/** What the panel reports back to its tab. */
export interface PluginFileTab {
@@ -63,6 +65,14 @@ export class PluginFileConflictError extends Error {
}
}
/** The file was deleted on disk. */
export class PluginFileDeletedError extends Error {
constructor(message = "The file was deleted.") {
super(message);
this.name = "PluginFileDeletedError";
}
}
export type PluginFileTextState =
| { status: "loading" }
| { status: "ready"; text: string }
@@ -78,19 +88,28 @@ export type PluginFileUrlState =
| { status: "ready"; url: string }
| { status: "error"; error: Error };
/** The file's text, re-read when it changes on disk. Keeps the current text during a re-read. */
/**
* The file's text, re-read when it changes on disk. Keeps the current text during a re-read. A
* deleted file is an error holding a `PluginFileDeletedError`.
*/
export function useFileText(file: PluginFile): PluginFileTextState {
const state = useFileRead(file, "text");
return state.status === "ready" ? { status: "ready", text: state.value as string } : state;
}
/** The file's bytes, re-read when it changes on disk. Keeps the current bytes during a re-read. */
/**
* The file's bytes, re-read when it changes on disk. Keeps the current bytes during a re-read. A
* deleted file is an error holding a `PluginFileDeletedError`.
*/
export function useFileBytes(file: PluginFile): PluginFileBytesState {
const state = useFileRead(file, "bytes");
return state.status === "ready" ? { status: "ready", bytes: state.value as Uint8Array } : state;
}
/** A local URL of the file, replaced when it changes on disk. */
/**
* A local URL of the file, replaced when it changes on disk. A deleted file is an error holding a
* `PluginFileDeletedError`.
*/
export function useFileUrl(file: PluginFile): PluginFileUrlState {
const state = useFileRead(file, "url");
return state.status === "ready" ? { status: "ready", url: state.value as string } : state;
@@ -121,7 +140,7 @@ function useFileRead(file: PluginFile, read: FileRead): FileReadState {
};
void load();
const unwatch = file.watch((event) => {
if (event === "changed") {
if (event.type === "changed") {
void load();
return;
}
@@ -129,7 +148,7 @@ function useFileRead(file: PluginFile, read: FileRead): FileReadState {
setState({
file,
read,
state: { status: "error", error: new Error("The file was deleted.") },
state: { status: "error", error: new PluginFileDeletedError() },
});
});
return () => {
+7 -1
View File
@@ -58,7 +58,13 @@ export type {
PluginFileUrlState,
PluginFileWriteOptions,
} from "./file.js";
export { PluginFileConflictError, useFileBytes, useFileText, useFileUrl } from "./file.js";
export {
PluginFileConflictError,
PluginFileDeletedError,
useFileBytes,
useFileText,
useFileUrl,
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
@@ -1,5 +1,6 @@
import {
PluginFileConflictError,
PluginFileDeletedError,
type PluginFilePanelProps,
useFileText,
} from "@getpaseo/plugin/client";
@@ -47,6 +48,7 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) {
await file.writeText(next, { force });
} catch (cause) {
if (cause instanceof PluginFileConflictError) setState("conflict");
else if (cause instanceof PluginFileDeletedError) setState("deleted");
else setError(messageOf(cause));
return;
}
@@ -77,7 +79,7 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) {
useEffect(() => {
void load();
const unwatch = file.watch((event) => {
if (event === "deleted") setState("deleted");
if (event.type === "deleted") setState("deleted");
else if (unsaved.current === null) void load();
else setState("conflict");
});
+2 -2
View File
@@ -4,8 +4,8 @@ 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 resolves the file's local URL with `useFileUrl`, shows `Loading` until it does, and passes
it to `DocumentFrame` on web and iOS. Android shows the filename, size, and a message that inline
PDF rendering is unavailable. The plugin does not construct daemon URLs.
it to `DocumentFrame`. Where a platform cannot show a PDF inline (Android), `DocumentFrame` says so.
The plugin does not construct daemon URLs.
See the [file panel reference](https://paseo.sh/docs/plugins/reference#file-panels).
+1 -17
View File
@@ -10,12 +10,7 @@ const messageStyle = {
padding: 24,
} as const;
export function PdfPanel(props: PluginFilePanelProps) {
if (props.layout.platform === "android") return <AndroidPdf {...props} />;
return <InlinePdf {...props} />;
}
function InlinePdf({ file, theme }: PluginFilePanelProps) {
export function PdfPanel({ file, theme }: PluginFilePanelProps) {
const url = useFileUrl(file);
const textStyle = useMemo(() => ({ color: theme.colors.statusDanger }), [theme]);
if (url.status === "loading") return <Loading />;
@@ -28,14 +23,3 @@ function InlinePdf({ file, theme }: PluginFilePanelProps) {
}
return <DocumentFrame url={url.url} testID="pdf-panel" />;
}
function AndroidPdf({ file, theme }: PluginFilePanelProps) {
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
return (
<View style={messageStyle}>
<Text style={textStyle}>
{file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline.
</Text>
</View>
);
}
+15 -12
View File
@@ -1819,14 +1819,17 @@ rendering; changing them does not re-render your panel. A `watch` listener, or a
| `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("changed")` or `listener("deleted")`; returns the unsubscribe function. |
| `watch(listener)` | Calls `listener(event)` with a `PluginFileEvent`; returns the unsubscribe function. |
Your panel stays mounted when the file is deleted. `watch` reports `"deleted"` once a read confirms
the file is gone, so a program that removes the file and writes it back reports only `"changed"`.
A deleted file that comes back reports `"changed"`.
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. The handle's own writes do not notify
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.
@@ -1843,7 +1846,7 @@ disk, and keeps the current value during a re-read:
| `useFileUrl(file)` | `{ status: "ready", url: string }` |
Each also returns `{ status: "loading" }` before the first read and `{ status: "error", error }`
when a read fails or the file is deleted. Show `Loading` from `@getpaseo/plugin/client/ui` until
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
@@ -1869,10 +1872,10 @@ A panel that holds unsaved edits reads with `readText()` once, then handles `wat
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 `"changed"` with nothing unsaved, read again. With unsaved edits, show a conflict and let the
user reload or overwrite with `{ force: true }`.
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 `"deleted"`, tell the user the file is gone.
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
@@ -1919,8 +1922,8 @@ Import `DocumentFrame` from `@getpaseo/plugin/client/ui` to display a local file
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. iOS supports inline PDF rendering; Android's WebView does not. Provide an
Android fallback or bundle your own renderer.
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`.
@@ -1934,7 +1937,7 @@ 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 `DocumentFrame` and an Android fallback.
- [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