mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +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");
|
||||
|
||||
Reference in New Issue
Block a user