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");