mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1941,6 +1941,7 @@ export const ar: TranslationResources = {
|
||||
noPreview: "لا تتوفر معاينة",
|
||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
|
||||
documentUnsupported: "لا يمكن عرض ملفات {{type}} على هذا الجهاز",
|
||||
failedToLoad: "فشل تحميل الملف",
|
||||
notFound: "الملف غير موجود",
|
||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1960,6 +1960,7 @@ export const ja: TranslationResources = {
|
||||
noPreview: "プレビューが利用できません",
|
||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
|
||||
documentUnsupported: "{{type}} ファイルはこのデバイスでは表示できません",
|
||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||
notFound: "ファイルが見つかりません",
|
||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||
|
||||
@@ -1951,6 +1951,7 @@ export const ko: TranslationResources = {
|
||||
noPreview: "사용 가능한 미리보기가 없습니다",
|
||||
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
||||
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
|
||||
documentUnsupported: "{{type}} 파일은 이 기기에서 표시할 수 없습니다",
|
||||
failedToLoad: "파일을 불러오지 못했습니다",
|
||||
notFound: "파일을 찾을 수 없습니다",
|
||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1973,6 +1973,7 @@ export const ru: TranslationResources = {
|
||||
noPreview: "Предварительный просмотр недоступен",
|
||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||
tooLargeToDisplay: "Этот файл слишком велик для отображения",
|
||||
documentUnsupported: "Файлы {{type}} нельзя показать на этом устройстве",
|
||||
failedToLoad: "Не удалось загрузить файл",
|
||||
notFound: "Файл не найден",
|
||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||
|
||||
@@ -1919,6 +1919,7 @@ export const zhCN: TranslationResources = {
|
||||
noPreview: "没有可用预览",
|
||||
binaryPreviewUnavailable: "二进制预览不可用",
|
||||
tooLargeToDisplay: "此文件过大,无法显示",
|
||||
documentUnsupported: "无法在此设备上显示 {{type}} 文件",
|
||||
failedToLoad: "加载文件失败",
|
||||
notFound: "找不到文件",
|
||||
failedToLoadPreview: "加载文件预览失败",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user