diff --git a/packages/app/src/file-panels/document-frame/index.tsx b/packages/app/src/file-panels/document-frame/index.tsx index aae416031b..c931ef5f08 100644 --- a/packages/app/src/file-panels/document-frame/index.tsx +++ b/packages/app/src/file-panels/document-frame/index.tsx @@ -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(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 ; + } return ( ); } + +function UnsupportedDocument({ extension, testID }: { extension: string; testID?: string }) { + const { t } = useTranslation(); + return ( + + + {t("panels.file.documentUnsupported", { type: extension.toUpperCase() })} + + + ); +} + +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", + }, +})); diff --git a/packages/app/src/file-panels/file/handle.test.ts b/packages/app/src/file-panels/file/handle.test.ts index 39a2dfcb77..cbedbbfc2e 100644 --- a/packages/app/src/file-panels/file/handle.test.ts +++ b/packages/app/src/file-panels/file/handle.test.ts @@ -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(); diff --git a/packages/app/src/file-panels/file/handle.ts b/packages/app/src/file-panels/file/handle.ts index 016b0aad9b..580b342763 100644 --- a/packages/app/src/file-panels/file/handle.ts +++ b/packages/app/src/file-panels/file/handle.ts @@ -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 { @@ -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; diff --git a/packages/app/src/file-panels/file/hooks.test.tsx b/packages/app/src/file-panels/file/hooks.test.tsx index ee8396a5fb..d4230d45b8 100644 --- a/packages/app/src/file-panels/file/hooks.test.tsx +++ b/packages/app/src/file-panels/file/hooks.test.tsx @@ -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, ); }); diff --git a/packages/app/src/file-panels/source/editor/model.test.ts b/packages/app/src/file-panels/source/editor/model.test.ts index a01dae5d9d..1d0468e7bc 100644 --- a/packages/app/src/file-panels/source/editor/model.test.ts +++ b/packages/app/src/file-panels/source/editor/model.test.ts @@ -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(); diff --git a/packages/app/src/file-panels/source/editor/model.ts b/packages/app/src/file-panels/source/editor/model.ts index f208799b60..bdd1749723 100644 --- a/packages/app/src/file-panels/source/editor/model.ts +++ b/packages/app/src/file-panels/source/editor/model.ts @@ -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; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 824136dbe3..accd9cec6d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1941,6 +1941,7 @@ export const ar: TranslationResources = { noPreview: "لا تتوفر معاينة", binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه", + documentUnsupported: "لا يمكن عرض ملفات {{type}} على هذا الجهاز", failedToLoad: "فشل تحميل الملف", notFound: "الملف غير موجود", failedToLoadPreview: "فشل تحميل معاينة الملف", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 5d1d1a514f..8002c0ff46 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 86904300af..464b1ab208 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index db4edfa12b..4f8b43889a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 33d2aa360f..9fb11cb2e1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1960,6 +1960,7 @@ export const ja: TranslationResources = { noPreview: "プレビューが利用できません", binaryPreviewUnavailable: "バイナリプレビューが利用できません", tooLargeToDisplay: "このファイルは大きすぎて表示できません", + documentUnsupported: "{{type}} ファイルはこのデバイスでは表示できません", failedToLoad: "ファイルの読み込みに失敗しました", notFound: "ファイルが見つかりません", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index b94189a49d..e079ae90f1 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1951,6 +1951,7 @@ export const ko: TranslationResources = { noPreview: "사용 가능한 미리보기가 없습니다", binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다", tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다", + documentUnsupported: "{{type}} 파일은 이 기기에서 표시할 수 없습니다", failedToLoad: "파일을 불러오지 못했습니다", notFound: "파일을 찾을 수 없습니다", failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 4deb724890..84a5cb7c5f 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f1a9120cdb..54f0c2d6ca 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1973,6 +1973,7 @@ export const ru: TranslationResources = { noPreview: "Предварительный просмотр недоступен", binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", tooLargeToDisplay: "Этот файл слишком велик для отображения", + documentUnsupported: "Файлы {{type}} нельзя показать на этом устройстве", failedToLoad: "Не удалось загрузить файл", notFound: "Файл не найден", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 69613bc3f2..8476e55464 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1919,6 +1919,7 @@ export const zhCN: TranslationResources = { noPreview: "没有可用预览", binaryPreviewUnavailable: "二进制预览不可用", tooLargeToDisplay: "此文件过大,无法显示", + documentUnsupported: "无法在此设备上显示 {{type}} 文件", failedToLoad: "加载文件失败", notFound: "找不到文件", failedToLoadPreview: "加载文件预览失败", diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 4d12358ae4..40d79dd969 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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"); diff --git a/packages/plugin/src/client/file.ts b/packages/plugin/src/client/file.ts index 3120c6b9b2..2c11fcdd0a 100644 --- a/packages/plugin/src/client/file.ts +++ b/packages/plugin/src/client/file.ts @@ -24,7 +24,8 @@ export interface PluginFile { url(): Promise; /** * 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; writeBytes(bytes: Uint8Array, options?: PluginFileWriteOptions): Promise; @@ -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 () => { diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 54fce017c8..b3f7e97b2b 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -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"; diff --git a/plugin-examples/markdown-panel/client/rich-markdown.tsx b/plugin-examples/markdown-panel/client/rich-markdown.tsx index 26831b8410..b11eb4bf51 100644 --- a/plugin-examples/markdown-panel/client/rich-markdown.tsx +++ b/plugin-examples/markdown-panel/client/rich-markdown.tsx @@ -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"); }); diff --git a/plugin-examples/pdf-panel/README.md b/plugin-examples/pdf-panel/README.md index ce0acdfdc1..f3fb4e0a02 100644 --- a/plugin-examples/pdf-panel/README.md +++ b/plugin-examples/pdf-panel/README.md @@ -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). diff --git a/plugin-examples/pdf-panel/client/pdf-panel.tsx b/plugin-examples/pdf-panel/client/pdf-panel.tsx index de1c5accbd..006f586a84 100644 --- a/plugin-examples/pdf-panel/client/pdf-panel.tsx +++ b/plugin-examples/pdf-panel/client/pdf-panel.tsx @@ -10,12 +10,7 @@ const messageStyle = { padding: 24, } as const; -export function PdfPanel(props: PluginFilePanelProps) { - if (props.layout.platform === "android") return ; - return ; -} - -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 ; @@ -28,14 +23,3 @@ function InlinePdf({ file, theme }: PluginFilePanelProps) { } return ; } - -function AndroidPdf({ file, theme }: PluginFilePanelProps) { - const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]); - return ( - - - {file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline. - - - ); -} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index f5c1a6f1ab..ef8c3148fa 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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