mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
feat(file-panels): panels read and write through a file handle
The file tab stats the file and mounts its panel without reading it. Every panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url, version-checked writeText/writeBytes (PluginFileConflictError unless force), and watch. useFileText, useFileBytes and useFileUrl read through it, and Loading joins @getpaseo/plugin/client/ui. Panels own their loading state. The host document pipeline, the explorer preview cache and the preview lifecycle are deleted. The daemon classifies a file version from a bounded sniff and writes base64 content as bytes behind fileByteWrites. A deleted file keeps its panel mounted; a read confirms the deletion first, so a remove-and-rewrite is not reported as one. Native Source decodes with builtins and highlights in idle-time slices, the lines around the opening line first, so a large file no longer blocks the JS thread.
This commit is contained in:
@@ -12,9 +12,11 @@ Open a `.md` or `.markdown` file in Files. Choose **Rich Markdown — markdown-p
|
||||
picker. Check **Don't ask me again** to remember the choice on this device.
|
||||
|
||||
The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo, redo, and line
|
||||
wrapping. Edits call the host document's `setText`; Paseo owns autosave, the dirty dot, and
|
||||
external-change conflicts. The bar's switcher still offers Paseo's Markdown and Source panels. Files above 1 MiB or hosts
|
||||
without editing support render read-only. Native shows selectable, read-only text.
|
||||
wrapping. It reads the file once with `readText`, saves 500 ms after the last edit with
|
||||
`writeText`, and reports unsaved edits with `tab.setModified`. When the file changes on disk with
|
||||
nothing unsaved, it reads again; with unsaved edits, or when a write rejects with
|
||||
`PluginFileConflictError`, it offers **Reload** and **Overwrite**. The bar's switcher still offers
|
||||
Paseo's Markdown and Source panels. Native shows selectable, read-only text through `useFileText`.
|
||||
|
||||
Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native
|
||||
path uses React Native primitives. DOM integration lives in `client/web.ts`.
|
||||
|
||||
@@ -1,53 +1,169 @@
|
||||
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { mountEditor } from "./web";
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
type PluginFilePanelProps,
|
||||
useFileText,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { Loading } from "@getpaseo/plugin/client/ui";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { type MarkdownEditorHandle, mountEditor } from "./web";
|
||||
|
||||
interface TextPanelProps {
|
||||
document: PluginTextDocument;
|
||||
theme: PluginFilePanelProps["theme"];
|
||||
}
|
||||
const SAVE_DELAY_MS = 500;
|
||||
|
||||
const editorStyle = { flex: 1, minHeight: 0 };
|
||||
type EditorState = "loading" | "ready" | "conflict" | "deleted";
|
||||
|
||||
function MarkdownEditor({ document, theme }: TextPanelProps) {
|
||||
/**
|
||||
* Reads the file once, writes 500 ms after the last edit, and follows the disk while nothing is
|
||||
* unsaved. With unsaved edits, a change on disk is a conflict: Reload or Overwrite.
|
||||
*/
|
||||
function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) {
|
||||
const parent = useRef<View>(null);
|
||||
const editor = useRef<ReturnType<typeof mountEditor>>(null);
|
||||
const currentDocument = useRef(document);
|
||||
currentDocument.current = document;
|
||||
const editor = useRef<MarkdownEditorHandle | null>(null);
|
||||
const text = useRef("");
|
||||
const unsaved = useRef<string | null>(null);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [state, setState] = useState<EditorState>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
text.current = await file.readText();
|
||||
} catch (cause) {
|
||||
setError(messageOf(cause));
|
||||
return;
|
||||
}
|
||||
unsaved.current = null;
|
||||
tab.setModified(false);
|
||||
editor.current?.setText(text.current);
|
||||
setError(null);
|
||||
setState("ready");
|
||||
}, [file, tab]);
|
||||
|
||||
const save = useCallback(
|
||||
async (force: boolean) => {
|
||||
const next = unsaved.current;
|
||||
if (next === null) return;
|
||||
try {
|
||||
await file.writeText(next, { force });
|
||||
} catch (cause) {
|
||||
if (cause instanceof PluginFileConflictError) setState("conflict");
|
||||
else setError(messageOf(cause));
|
||||
return;
|
||||
}
|
||||
if (unsaved.current === next) {
|
||||
unsaved.current = null;
|
||||
tab.setModified(false);
|
||||
}
|
||||
setError(null);
|
||||
setState("ready");
|
||||
},
|
||||
[file, tab],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
editor.current = mountEditor(parent.current, currentDocument.current, theme, (text) => {
|
||||
currentDocument.current.setText(text);
|
||||
editor.current = mountEditor(parent.current, text.current, theme, (next) => {
|
||||
text.current = next;
|
||||
unsaved.current = next;
|
||||
tab.setModified(true);
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => void save(false), SAVE_DELAY_MS);
|
||||
});
|
||||
return () => {
|
||||
editor.current?.destroy();
|
||||
editor.current = null;
|
||||
};
|
||||
}, [theme, document.editable]);
|
||||
}, [save, tab, theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
editor.current?.setText(document.text);
|
||||
}, [document.text]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const unwatch = file.watch((event) => {
|
||||
if (event === "deleted") setState("deleted");
|
||||
else if (unsaved.current === null) void load();
|
||||
else setState("conflict");
|
||||
});
|
||||
return () => {
|
||||
unwatch();
|
||||
clearTimeout(saveTimer.current);
|
||||
// Switching panels keeps the edit. Closing the tab asks first and drops it if discarded.
|
||||
if (unsaved.current !== null) void file.writeText(unsaved.current).catch(() => {});
|
||||
};
|
||||
}, [file, load]);
|
||||
|
||||
return <View ref={parent} style={editorStyle} />;
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
panel: { flex: 1, minHeight: 0, backgroundColor: theme.colors.surface0 },
|
||||
editor: { flex: 1, minHeight: 0 },
|
||||
banner: {
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "center" as const,
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
bannerText: { flex: 1, color: theme.colors.foreground },
|
||||
error: { color: theme.colors.statusDanger, paddingHorizontal: 16, paddingVertical: 8 },
|
||||
action: { color: theme.colors.accent },
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const overwrite = useCallback(() => void save(true), [save]);
|
||||
const reload = useCallback(() => void load(), [load]);
|
||||
|
||||
return (
|
||||
<View style={styles.panel}>
|
||||
{state === "conflict" ? (
|
||||
<View style={styles.banner} testID="rich-markdown-conflict">
|
||||
<Text style={styles.bannerText}>The file changed on disk.</Text>
|
||||
<Pressable accessibilityRole="button" onPress={reload}>
|
||||
<Text style={styles.action}>Reload</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={overwrite}>
|
||||
<Text style={styles.action}>Overwrite</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{state === "deleted" ? (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>The file was deleted. Edits are kept here.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<View ref={parent} style={styles.editor} />
|
||||
{state === "loading" && !error ? <Loading /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function RichMarkdownPanel({ document, theme, layout }: PluginFilePanelProps) {
|
||||
/** Native has no editor: selectable, read-only text that follows the disk. */
|
||||
function MarkdownText({ file, theme }: PluginFilePanelProps) {
|
||||
const text = useFileText(file);
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
||||
text: { color: theme.colors.foreground, padding: 16 },
|
||||
error: { color: theme.colors.statusDanger, padding: 16 },
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
if (document.kind !== "text") return null;
|
||||
if (layout.platform === "web") return <MarkdownEditor document={document} theme={theme} />;
|
||||
if (text.status === "loading") return <Loading />;
|
||||
if (text.status === "error") return <Text style={styles.error}>{text.error.message}</Text>;
|
||||
return (
|
||||
<ScrollView style={styles.screen}>
|
||||
<Text selectable style={styles.text}>
|
||||
{document.text}
|
||||
{text.text}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export function RichMarkdownPanel(props: PluginFilePanelProps) {
|
||||
if (props.layout.platform === "web") return <MarkdownEditor {...props} />;
|
||||
return <MarkdownText {...props} />;
|
||||
}
|
||||
|
||||
function messageOf(cause: unknown): string {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
|
||||
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
|
||||
import type { EditorView, EditorViewConfig } from "@codemirror/view";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export interface MarkdownEditorHandle {
|
||||
/** Replaces the editor's text without reporting it as an edit. */
|
||||
setText(text: string): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** DOM code stays here; native never evaluates the editor dependencies. */
|
||||
export function mountEditor(
|
||||
parent: unknown,
|
||||
document: PluginTextDocument,
|
||||
text: string,
|
||||
theme: PluginFilePanelProps["theme"],
|
||||
onChange: (text: string) => void,
|
||||
) {
|
||||
): MarkdownEditorHandle | null {
|
||||
if (Platform.OS !== "web" || !parent) return null;
|
||||
const { EditorState } = require("@codemirror/state") as typeof import("@codemirror/state");
|
||||
const { EditorView, keymap, lineNumbers } =
|
||||
@@ -23,7 +29,7 @@ export function mountEditor(
|
||||
const view: EditorView = new EditorView({
|
||||
parent: parent as EditorViewConfig["parent"],
|
||||
state: EditorState.create({
|
||||
doc: document.text,
|
||||
doc: text,
|
||||
extensions: [
|
||||
markdown(),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
@@ -31,8 +37,6 @@ export function mountEditor(
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(!document.editable),
|
||||
EditorView.editable.of(document.editable),
|
||||
EditorView.contentAttributes.of({ "aria-label": "Rich Markdown editor" }),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
@@ -55,11 +59,11 @@ export function mountEditor(
|
||||
}),
|
||||
});
|
||||
return {
|
||||
setText(text: string) {
|
||||
if (view.state.doc.toString() === text) return;
|
||||
setText(next) {
|
||||
if (view.state.doc.toString() === next) return;
|
||||
applyingHostText = true;
|
||||
try {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: next } });
|
||||
} finally {
|
||||
applyingHostText = false;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export default function contribute(client: PluginClientContext) {
|
||||
title: "Rich Markdown",
|
||||
icon: "FileType",
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
document: "text",
|
||||
Component: RichMarkdownPanel,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
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 passes Paseo's local document URL to `DocumentFrame` on web and iOS.
|
||||
Files up to 50 MiB match. Android shows the filename, size, and a message that inline PDF rendering is unavailable. The plugin does not read files or construct daemon URLs.
|
||||
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.
|
||||
|
||||
See the [file panel reference](https://paseo.sh/docs/plugins/reference#file-panels).
|
||||
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
|
||||
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
|
||||
import { type PluginFilePanelProps, useFileUrl } from "@getpaseo/plugin/client";
|
||||
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
const fallbackStyle = {
|
||||
const messageStyle = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
} as const;
|
||||
|
||||
export function PdfPanel({ document, file, theme, layout }: PluginFilePanelProps) {
|
||||
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
|
||||
if (document.kind !== "bytes") return null;
|
||||
if (layout.platform === "android") {
|
||||
export function PdfPanel(props: PluginFilePanelProps) {
|
||||
if (props.layout.platform === "android") return <AndroidPdf {...props} />;
|
||||
return <InlinePdf {...props} />;
|
||||
}
|
||||
|
||||
function InlinePdf({ file, theme }: PluginFilePanelProps) {
|
||||
const url = useFileUrl(file);
|
||||
const textStyle = useMemo(() => ({ color: theme.colors.statusDanger }), [theme]);
|
||||
if (url.status === "loading") return <Loading />;
|
||||
if (url.status === "error") {
|
||||
return (
|
||||
<View style={fallbackStyle}>
|
||||
<Text style={textStyle}>
|
||||
{file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline.
|
||||
</Text>
|
||||
<View style={messageStyle}>
|
||||
<Text style={textStyle}>{url.error.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <DocumentFrame url={document.url} testID="pdf-panel" />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ export default function contribute(client: PluginClientContext) {
|
||||
title: "PDF",
|
||||
icon: "FileText",
|
||||
files: { extensions: ["pdf"] },
|
||||
document: "bytes",
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
Component: PdfPanel,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
Reference in New Issue
Block a user