From 95b7ef823a49e70ea734ba449951cc2ab2fab995 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 26 Aug 2026 09:47:57 +0200 Subject: [PATCH] feat(app): edit source files on mobile Use one source-editor contract and shared editing runtime across browser and native hosts so file-pane persistence, conflicts, and line endings keep the same semantics on every platform. --- CLAUDE.md | 1 + docs/architecture.md | 1 + docs/source-editor.md | 35 ++++ packages/app/package.json | 5 +- .../build-source-editor-webview-html.mjs | 95 ++++++++++ packages/app/src/file-pane/editor/view.tsx | 57 +++--- .../app/src/file-pane/editor/view.web.tsx | 155 ----------------- packages/app/src/file-pane/pane.tsx | 6 +- .../app/src/file-pane/source/view.native.tsx | 4 +- .../app/src/file-pane/source/view.web.tsx | 10 +- .../codemirror/bridge-protocol.test.ts | 29 ++++ .../codemirror/bridge-protocol.ts | 162 ++++++++++++++++++ .../codemirror/configuration.ts} | 31 ++-- .../src/source-editor/codemirror/runtime.ts | 152 ++++++++++++++++ .../source-editor/codemirror/webview-entry.ts | 83 +++++++++ .../source-editor/codemirror/webview-html.ts | 5 + packages/app/src/source-editor/contract.ts | 46 +++++ .../app/src/source-editor/controller.test.ts | 59 +++++++ packages/app/src/source-editor/controller.ts | 61 +++++++ .../src/source-editor/document-mirror.test.ts | 38 ++++ .../app/src/source-editor/document-mirror.ts | 66 +++++++ .../app/src/source-editor/source-editor.tsx | 152 ++++++++++++++++ .../src/source-editor/source-editor.web.tsx | 85 +++++++++ 23 files changed, 1128 insertions(+), 210 deletions(-) create mode 100644 docs/source-editor.md create mode 100644 packages/app/scripts/build-source-editor-webview-html.mjs delete mode 100644 packages/app/src/file-pane/editor/view.web.tsx create mode 100644 packages/app/src/source-editor/codemirror/bridge-protocol.test.ts create mode 100644 packages/app/src/source-editor/codemirror/bridge-protocol.ts rename packages/app/src/{file-pane/editor/extensions.web.ts => source-editor/codemirror/configuration.ts} (74%) create mode 100644 packages/app/src/source-editor/codemirror/runtime.ts create mode 100644 packages/app/src/source-editor/codemirror/webview-entry.ts create mode 100644 packages/app/src/source-editor/codemirror/webview-html.ts create mode 100644 packages/app/src/source-editor/contract.ts create mode 100644 packages/app/src/source-editor/controller.test.ts create mode 100644 packages/app/src/source-editor/controller.ts create mode 100644 packages/app/src/source-editor/document-mirror.test.ts create mode 100644 packages/app/src/source-editor/document-mirror.ts create mode 100644 packages/app/src/source-editor/source-editor.tsx create mode 100644 packages/app/src/source-editor/source-editor.web.tsx diff --git a/CLAUDE.md b/CLAUDE.md index b62a53d409..17e591ab25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | +| [docs/source-editor.md](docs/source-editor.md) | Shared CodeMirror runtime, native bridge boundary, line-ending preservation, and embedded bundle workflow | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | | [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison | | [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | diff --git a/docs/architecture.md b/docs/architecture.md index f11c2ab2ee..7adf6845a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,6 +109,7 @@ Cross-platform React Native app that connects to one or more daemons. - `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication. - `SessionContext` wraps the daemon client for the active session - Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/` +- Source editing uses one CodeMirror runtime on web and in an offline native WebView. Its ownership and bridge constraints are documented in [docs/source-editor.md](source-editor.md). - Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication - Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete. - Voice features: dictation (STT) and voice agent (realtime) diff --git a/docs/source-editor.md b/docs/source-editor.md new file mode 100644 index 0000000000..60376cf24f --- /dev/null +++ b/docs/source-editor.md @@ -0,0 +1,35 @@ +# Source editor + +The source editor has one CodeMirror runtime and two hosts: + +- Web mounts the runtime directly into the page. +- iOS and Android mount the same runtime from a generated, offline HTML document in a WebView. + +Keep editor mechanics in `packages/app/src/source-editor/`. File loading, conflict handling, and +persistence belong to the file pane's `FileEditorModel`. The source editor receives a document and +emits edits; it does not know about workspaces or daemon RPCs. + +## Native bridge + +The native bridge sends compact change ranges in normalized LF coordinates. The host-side document +mirror validates and applies them, then restores the file's original line separator before calling +the file model. Full document replacements cross the bridge only for mount and external changes. + +Every message carries an editor key. Ignore messages from an old key after remounting or switching +files. Keep the bridge protocol private to this module; React components outside it use +`SourceEditorProps`. + +Do not expose raw CodeMirror extensions as the cross-platform API. Add named capabilities to the +source editor contract, then implement them once in the shared runtime or explicitly in each host. + +## Embedded bundle + +After changing the CodeMirror runtime, bridge protocol, or WebView entry point, run: + +```bash +npm --prefix packages/app run build:source-editor-webview +``` + +The generated `webview-html.ts` is committed so native builds do not need a runtime bundler or +network access. `build:source-editor-webview:check` fails when the committed artifact is stale, and +EAS post-install rebuilds all embedded WebViews. diff --git a/packages/app/package.json b/packages/app/package.json index 88a8e0b9b7..96503e9571 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -7,7 +7,7 @@ "start": "npm run start:expo", "start:expo": "cross-env APP_VARIANT=development expo start", "reset-project": "node ./scripts/reset-project.js", - "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview", + "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:embedded-webviews", "android": "npm run android:development", "android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", "android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release", @@ -35,7 +35,10 @@ "profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", + "build:embedded-webviews": "npm run build:terminal-webview && npm run build:source-editor-webview", "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", + "build:source-editor-webview": "node ./scripts/build-source-editor-webview-html.mjs", + "build:source-editor-webview:check": "node ./scripts/build-source-editor-webview-html.mjs --check", "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs" }, "dependencies": { diff --git a/packages/app/scripts/build-source-editor-webview-html.mjs b/packages/app/scripts/build-source-editor-webview-html.mjs new file mode 100644 index 0000000000..85b0e975b7 --- /dev/null +++ b/packages/app/scripts/build-source-editor-webview-html.mjs @@ -0,0 +1,95 @@ +import esbuild from "esbuild"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.resolve(scriptDirectory, ".."); +const repoRoot = path.resolve(appRoot, "../.."); +const entry = path.join(appRoot, "src/source-editor/codemirror/webview-entry.ts"); +const output = path.join(appRoot, "src/source-editor/codemirror/webview-html.ts"); +const checkOnly = process.argv.includes("--check"); + +async function resolveTsPath(basePath) { + const candidates = [ + basePath, + `${basePath}.ts`, + `${basePath}.tsx`, + `${basePath}.js`, + `${basePath}.jsx`, + path.join(basePath, "index.ts"), + path.join(basePath, "index.tsx"), + ]; + for (const candidate of candidates) { + try { + if ((await fs.stat(candidate)).isFile()) return candidate; + } catch { + // Try the next TypeScript resolution candidate. + } + } + return basePath; +} + +const aliasPlugin = { + name: "paseo-alias", + setup(build) { + build.onResolve({ filter: /^@\// }, async (args) => ({ + path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))), + })); + }, +}; + +const result = await esbuild.build({ + entryPoints: [entry], + bundle: true, + write: false, + format: "iife", + platform: "browser", + target: ["ios15", "chrome100"], + plugins: [aliasPlugin], + logLevel: "info", +}); + +const javascript = result.outputFiles[0]?.text; +if (!javascript) throw new Error("source editor WebView bundle produced no JavaScript"); + +const html = ` + + + + + + + + + + +`; + +const contents = `// Generated by packages/app/scripts/build-source-editor-webview-html.mjs. +// Do not edit by hand. + +export const sourceEditorWebViewHtml = + ${JSON.stringify(html)}; +`; + +if (checkOnly) { + const existing = await fs.readFile(output, "utf8").catch(() => null); + if (existing !== contents) { + throw new Error( + `${path.relative(repoRoot, output)} is stale. Run npm --prefix packages/app run build:source-editor-webview.`, + ); + } + console.log(`${path.relative(repoRoot, output)} is current (${html.length} bytes)`); +} else { + await fs.writeFile(output, contents); + console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`); +} diff --git a/packages/app/src/file-pane/editor/view.tsx b/packages/app/src/file-pane/editor/view.tsx index 8c27bbd8bb..fd40e7c6b8 100644 --- a/packages/app/src/file-pane/editor/view.tsx +++ b/packages/app/src/file-pane/editor/view.tsx @@ -1,36 +1,49 @@ -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import type { HighlightStyle } from "@getpaseo/highlight"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { SourceEditor } from "@/source-editor/source-editor"; +import type { SourceEditorTheme } from "@/source-editor/contract"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; -export function FileEditorView(_props: { +export function FileEditorView(props: { model: FileEditorModel; filename: string; location: WorkspaceFileLocation; navigationRevision: number; vimEnabled: boolean; - theme: { - background: string; - foreground: string; - foregroundMuted: string; - border: string; - selection: string; - monoFont: string; - codeFontSize: number; - syntax: Record; - }; + theme: SourceEditorTheme; onCursorChange(position: { line: number; column: number }): void; onVimModeChange(mode: string | null): void; }) { + const snapshot = useSyncExternalStore( + props.model.subscribe, + props.model.getSnapshot, + props.model.getSnapshot, + ); + const selectionTarget = useMemo( + () => + props.location.lineStart + ? { + lineStart: props.location.lineStart, + lineEnd: props.location.lineEnd, + revision: props.navigationRevision, + } + : undefined, + [props.location.lineEnd, props.location.lineStart, props.navigationRevision], + ); + const handleChange = useCallback((document: string) => props.model.edit(document), [props.model]); + const handleSave = useCallback(() => void props.model.save(), [props.model]); + return ( - - Source editing is available on web and desktop. - + ); } - -const styles = StyleSheet.create((theme) => ({ - container: { flex: 1, alignItems: "center", justifyContent: "center" }, - text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base }, -})); diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx deleted file mode 100644 index 03ab27a778..0000000000 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useEffect, useRef, useSyncExternalStore } from "react"; -import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { getLanguageForFile } from "@getpaseo/highlight"; -import { getCM, vim } from "@replit/codemirror-vim"; -import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; -import type { WorkspaceFileLocation } from "@/workspace/file-open"; -import type { FileEditorModel } from "./model"; -import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; - -interface FileEditorViewProps { - model: FileEditorModel; - filename: string; - location: WorkspaceFileLocation; - navigationRevision: number; - vimEnabled: boolean; - theme: EditorVisualTheme; - onCursorChange(position: { line: number; column: number }): void; - onVimModeChange(mode: string | null): void; -} - -const languageCompartment = new Compartment(); -const wrappingCompartment = new Compartment(); -const themeCompartment = new Compartment(); -const vimCompartment = new Compartment(); - -function wrappingForFile(filename: string) { - return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : []; -} - -export function FileEditorView({ - model, - filename, - location, - navigationRevision, - vimEnabled, - theme, - onCursorChange, - onVimModeChange, -}: FileEditorViewProps) { - const hostRef = useRef(null); - const viewRef = useRef(null); - const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content }); - const onCursorChangeRef = useRef(onCursorChange); - onCursorChangeRef.current = onCursorChange; - - useEffect(() => { - if (!hostRef.current) return; - const values = initial.current; - const view = new EditorView({ - parent: hostRef.current, - state: EditorState.create({ - doc: values.content, - extensions: [ - vimCompartment.of(values.vimEnabled ? vim() : []), - ...editorBaseExtensions(() => void values.model.save()), - languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []), - wrappingCompartment.of(wrappingForFile(values.filename)), - themeCompartment.of(editorTheme(values.theme)), - EditorView.updateListener.of((update) => { - if ( - update.docChanged && - !update.transactions.some((tr) => tr.annotation(remoteUpdate)) - ) { - const { lineSeparator } = values.model.getSnapshot(); - values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator)); - } - if (update.selectionSet || update.docChanged) { - const head = update.state.selection.main.head; - const line = update.state.doc.lineAt(head); - onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 }); - } - }), - ], - }), - }); - viewRef.current = view; - onCursorChangeRef.current({ line: 1, column: 1 }); - return () => { - view.destroy(); - viewRef.current = null; - }; - }, []); - - useEffect(() => { - const view = viewRef.current; - if (!view) return; - const document = view.state.toText(snapshot.content); - if (view.state.doc.eq(document)) return; - const head = Math.min(view.state.selection.main.head, document.length); - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: document }, - selection: { anchor: head }, - annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)], - }); - }, [snapshot.content]); - - useEffect(() => { - const view = viewRef.current; - if (!view || !location.lineStart) return; - const lineStart = Math.min(location.lineStart, view.state.doc.lines); - const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines); - const from = view.state.doc.line(lineStart).from; - const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to; - view.dispatch({ - selection: { anchor: from, head: lineEnd > lineStart ? to : from }, - effects: EditorView.scrollIntoView(from, { y: "center" }), - }); - }, [location.lineEnd, location.lineStart, navigationRevision]); - - useEffect(() => { - viewRef.current?.dispatch({ - effects: [ - languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []), - wrappingCompartment.reconfigure(wrappingForFile(filename)), - ], - }); - }, [filename]); - - useEffect(() => { - viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) }); - }, [theme]); - - useEffect(() => { - const view = viewRef.current; - if (!view) return; - view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) }); - if (!vimEnabled) { - onVimModeChange(null); - return; - } - const cm = getCM(view); - if (!cm) return; - function handleModeChange(event: { mode?: string }) { - onVimModeChange((event.mode ?? "normal").toUpperCase()); - } - cm.on("vim-mode-change", handleModeChange); - onVimModeChange("NORMAL"); - return () => cm.off("vim-mode-change", handleModeChange); - }, [onVimModeChange, vimEnabled]); - - return ( -
- ); -} - -const remoteUpdate = Annotation.define(); -const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..06600629b5 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -22,7 +22,6 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { isFileQueryEnabled } from "@/components/file-pane-enabled"; -import { isWeb } from "@/constants/platform"; import { useAppSettings } from "@/hooks/use-settings"; import { useLiveFile } from "./live-file/hook"; import { useFilePreview } from "./preview-lifecycle/hook"; @@ -329,10 +328,7 @@ function isEditableTextFile(input: { supportsEditing: boolean; }): boolean { return Boolean( - isWeb && - input.supportsEditing && - input.preview?.kind === "text" && - input.preview.size <= 1024 * 1024, + input.supportsEditing && input.preview?.kind === "text" && input.preview.size <= 1024 * 1024, ); } diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx index de7c5eeae1..1bc35c049f 100644 --- a/packages/app/src/file-pane/source/view.native.tsx +++ b/packages/app/src/file-pane/source/view.native.tsx @@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef } from "react"; import { FlatList, Text, View, type ListRenderItem } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; +import type { SourceEditorTheme } from "@/source-editor/contract"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; -import type { EditorVisualTheme } from "../editor/extensions.web"; import { selectSourcePresentation } from "./presentation"; interface FileSourceViewProps { @@ -13,7 +13,7 @@ interface FileSourceViewProps { location: WorkspaceFileLocation; navigationRevision: number; size: number; - theme: EditorVisualTheme; + theme: SourceEditorTheme; tooLargeMessage: string; } diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx index 3a38b5d073..52c4dcc8c9 100644 --- a/packages/app/src/file-pane/source/view.web.tsx +++ b/packages/app/src/file-pane/source/view.web.tsx @@ -2,9 +2,9 @@ import { useEffect, useRef } from "react"; import { Compartment, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; +import type { SourceEditorTheme } from "@/source-editor/contract"; +import { sourceEditorTheme } from "@/source-editor/codemirror/configuration"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; -import type { EditorVisualTheme } from "../editor/extensions.web"; -import { editorTheme } from "../editor/extensions.web"; import { selectSourcePresentation, type SourcePresentation } from "./presentation"; interface FileSourceViewProps { @@ -13,7 +13,7 @@ interface FileSourceViewProps { location: WorkspaceFileLocation; navigationRevision: number; size: number; - theme: EditorVisualTheme; + theme: SourceEditorTheme; tooLargeMessage: string; } @@ -76,7 +76,7 @@ function ReadonlyCodeMirror({ languageCompartment.of( languageFor({ filename: values.filename, presentation: values.presentation }), ), - themeCompartment.of(editorTheme(values.theme)), + themeCompartment.of(sourceEditorTheme(values.theme)), ], }), }); @@ -97,7 +97,7 @@ function ReadonlyCodeMirror({ viewRef.current?.dispatch({ effects: [ languageCompartment.reconfigure(languageFor({ filename, presentation })), - themeCompartment.reconfigure(editorTheme(theme)), + themeCompartment.reconfigure(sourceEditorTheme(theme)), ], }); }, [filename, presentation, theme]); diff --git a/packages/app/src/source-editor/codemirror/bridge-protocol.test.ts b/packages/app/src/source-editor/codemirror/bridge-protocol.test.ts new file mode 100644 index 0000000000..8106815370 --- /dev/null +++ b/packages/app/src/source-editor/codemirror/bridge-protocol.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; +import { parseSourceEditorBridgeMessage } from "./bridge-protocol"; + +describe("parseSourceEditorBridgeMessage", () => { + test("accepts compact document changes", () => { + expect( + parseSourceEditorBridgeMessage( + JSON.stringify({ + type: "change", + editorKey: "editor-1", + changes: [{ from: 2, to: 4, insert: "replacement" }], + }), + ), + ).toEqual({ + type: "change", + editorKey: "editor-1", + changes: [{ from: 2, to: 4, insert: "replacement" }], + }); + }); + + test("rejects malformed bridge input", () => { + expect(parseSourceEditorBridgeMessage("not json")).toBeNull(); + expect( + parseSourceEditorBridgeMessage( + JSON.stringify({ type: "change", editorKey: "editor-1", changes: [{ from: "2" }] }), + ), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/source-editor/codemirror/bridge-protocol.ts b/packages/app/src/source-editor/codemirror/bridge-protocol.ts new file mode 100644 index 0000000000..66ed3c77cd --- /dev/null +++ b/packages/app/src/source-editor/codemirror/bridge-protocol.ts @@ -0,0 +1,162 @@ +import type { + SourceChange, + SourceEditorConfiguration, + SourceEditorPosition, + SourceEditorSelectionTarget, +} from "../contract"; + +export type SourceEditorHostMessage = + | { + type: "mount"; + editorKey: string; + document: string; + configuration: SourceEditorConfiguration; + } + | { type: "replaceDocument"; editorKey: string; document: string } + | { type: "configure"; editorKey: string; configuration: SourceEditorConfiguration } + | ({ type: "reveal"; editorKey: string } & SourceEditorSelectionTarget) + | { type: "destroy"; editorKey: string }; + +export type SourceEditorBridgeMessage = + | { type: "bridgeReady" } + | { type: "ready"; editorKey: string } + | { type: "change"; editorKey: string; changes: readonly SourceChange[] } + | ({ type: "cursor"; editorKey: string } & SourceEditorPosition) + | { type: "save"; editorKey: string } + | { type: "vimMode"; editorKey: string; mode: string | null }; + +export function parseSourceEditorBridgeMessage(value: string): SourceEditorBridgeMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + if (!isRecord(parsed) || typeof parsed.type !== "string") return null; + if (parsed.type === "bridgeReady") return { type: "bridgeReady" }; + if (typeof parsed.editorKey !== "string") return null; + switch (parsed.type) { + case "ready": + case "save": + return { type: parsed.type, editorKey: parsed.editorKey }; + case "vimMode": + return parsed.mode === null || typeof parsed.mode === "string" + ? { type: "vimMode", editorKey: parsed.editorKey, mode: parsed.mode } + : null; + case "cursor": + return isPositiveInteger(parsed.line) && isPositiveInteger(parsed.column) + ? { + type: "cursor", + editorKey: parsed.editorKey, + line: parsed.line, + column: parsed.column, + } + : null; + case "change": { + const changes = parseChanges(parsed.changes); + return changes ? { type: "change", editorKey: parsed.editorKey, changes } : null; + } + default: + return null; + } +} + +export function parseSourceEditorHostMessage(value: string): SourceEditorHostMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + if ( + !isRecord(parsed) || + typeof parsed.type !== "string" || + typeof parsed.editorKey !== "string" + ) { + return null; + } + switch (parsed.type) { + case "mount": + return typeof parsed.document === "string" && isConfiguration(parsed.configuration) + ? { + type: "mount", + editorKey: parsed.editorKey, + document: parsed.document, + configuration: parsed.configuration, + } + : null; + case "replaceDocument": + return typeof parsed.document === "string" + ? { type: "replaceDocument", editorKey: parsed.editorKey, document: parsed.document } + : null; + case "configure": + return isConfiguration(parsed.configuration) + ? { type: "configure", editorKey: parsed.editorKey, configuration: parsed.configuration } + : null; + case "reveal": + return isPositiveInteger(parsed.lineStart) && + (parsed.lineEnd === undefined || isPositiveInteger(parsed.lineEnd)) && + Number.isInteger(parsed.revision) + ? { + type: "reveal", + editorKey: parsed.editorKey, + lineStart: parsed.lineStart, + lineEnd: parsed.lineEnd, + revision: parsed.revision as number, + } + : null; + case "destroy": + return { type: "destroy", editorKey: parsed.editorKey }; + default: + return null; + } +} + +function parseChanges(value: unknown): SourceChange[] | null { + if (!Array.isArray(value)) return null; + const changes: SourceChange[] = []; + for (const change of value) { + if ( + !isRecord(change) || + !Number.isInteger(change.from) || + !Number.isInteger(change.to) || + typeof change.insert !== "string" + ) { + return null; + } + changes.push({ from: change.from as number, to: change.to as number, insert: change.insert }); + } + return changes; +} + +function isConfiguration(value: unknown): value is SourceEditorConfiguration { + if ( + !isRecord(value) || + typeof value.filename !== "string" || + typeof value.vimEnabled !== "boolean" + ) { + return false; + } + const theme = value.theme; + return ( + isRecord(theme) && + (theme.colorScheme === "light" || theme.colorScheme === "dark") && + typeof theme.background === "string" && + typeof theme.foreground === "string" && + typeof theme.cursor === "string" && + typeof theme.foregroundMuted === "string" && + typeof theme.border === "string" && + typeof theme.selection === "string" && + typeof theme.monoFont === "string" && + typeof theme.codeFontSize === "number" && + isRecord(theme.syntax) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isPositiveInteger(value: unknown): value is number { + return Number.isInteger(value) && (value as number) > 0; +} diff --git a/packages/app/src/file-pane/editor/extensions.web.ts b/packages/app/src/source-editor/codemirror/configuration.ts similarity index 74% rename from packages/app/src/file-pane/editor/extensions.web.ts rename to packages/app/src/source-editor/codemirror/configuration.ts index 47fc0ab3c5..695e927cd7 100644 --- a/packages/app/src/file-pane/editor/extensions.web.ts +++ b/packages/app/src/source-editor/codemirror/configuration.ts @@ -13,22 +13,10 @@ import { keymap, lineNumbers, } from "@codemirror/view"; -import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight"; +import { createCodeMirrorHighlightStyle } from "@getpaseo/highlight"; +import type { SourceEditorTheme } from "../contract"; -export interface EditorVisualTheme { - colorScheme: "light" | "dark"; - background: string; - foreground: string; - cursor: string; - foregroundMuted: string; - border: string; - selection: string; - monoFont: string; - codeFontSize: number; - syntax: Record; -} - -export function editorBaseExtensions(onSave: () => void) { +export function sourceEditorBaseExtensions(onSave: () => void) { return [ lineNumbers(), history(), @@ -47,7 +35,7 @@ export function editorBaseExtensions(onSave: () => void) { ]; } -export function editorTheme(theme: EditorVisualTheme) { +export function sourceEditorTheme(theme: SourceEditorTheme) { return [ EditorView.theme( { @@ -62,8 +50,13 @@ export function editorTheme(theme: EditorVisualTheme) { overflow: "auto", fontFamily: theme.monoFont, lineHeight: "1.45", + WebkitOverflowScrolling: "touch", + }, + ".cm-content": { + caretColor: theme.foreground, + padding: "16px 0", + minHeight: "100%", }, - ".cm-content": { caretColor: theme.foreground, padding: "16px 0" }, ".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor }, ".cm-gutters": { backgroundColor: theme.background, @@ -75,9 +68,7 @@ export function editorTheme(theme: EditorVisualTheme) { "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { backgroundColor: theme.selection, }, - ".cm-selectionBackground, ::selection": { - backgroundColor: theme.selection, - }, + ".cm-selectionBackground, ::selection": { backgroundColor: theme.selection }, "&.cm-focused": { outline: "none" }, }, { dark: theme.colorScheme === "dark" }, diff --git a/packages/app/src/source-editor/codemirror/runtime.ts b/packages/app/src/source-editor/codemirror/runtime.ts new file mode 100644 index 0000000000..538c3a4194 --- /dev/null +++ b/packages/app/src/source-editor/codemirror/runtime.ts @@ -0,0 +1,152 @@ +import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state"; +import { EditorView, type ViewUpdate } from "@codemirror/view"; +import { getLanguageForFile } from "@getpaseo/highlight"; +import { getCM, vim } from "@replit/codemirror-vim"; +import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; +import type { + SourceChange, + SourceEditorConfiguration, + SourceEditorPosition, + SourceEditorSelectionTarget, +} from "../contract"; +import { sourceEditorBaseExtensions, sourceEditorTheme } from "./configuration"; + +interface CodeMirrorRuntimeCallbacks { + onChange(changes: readonly SourceChange[]): void; + onSave(): void; + onCursorChange(position: SourceEditorPosition): void; + onVimModeChange(mode: string | null): void; +} + +const remoteUpdate = Annotation.define(); + +export class CodeMirrorRuntime { + private readonly languageCompartment = new Compartment(); + private readonly wrappingCompartment = new Compartment(); + private readonly themeCompartment = new Compartment(); + private readonly vimCompartment = new Compartment(); + private view: EditorView | null = null; + private callbacks: CodeMirrorRuntimeCallbacks | null = null; + private vimModeListener: ((event: { mode?: string }) => void) | null = null; + + mount(input: { + host: HTMLElement; + document: string; + configuration: SourceEditorConfiguration; + callbacks: CodeMirrorRuntimeCallbacks; + }): void { + this.destroy(); + this.callbacks = input.callbacks; + this.view = new EditorView({ + parent: input.host, + state: EditorState.create({ + doc: input.document, + extensions: [ + this.vimCompartment.of(input.configuration.vimEnabled ? vim() : []), + ...sourceEditorBaseExtensions(() => this.callbacks?.onSave()), + this.languageCompartment.of(languageForFile(input.configuration.filename)), + this.wrappingCompartment.of(wrappingForFile(input.configuration.filename)), + this.themeCompartment.of(sourceEditorTheme(input.configuration.theme)), + EditorView.updateListener.of((update) => this.receiveUpdate(update)), + ], + }), + }); + this.configureVim(input.configuration.vimEnabled); + this.reportCursor(); + } + + replaceDocument(document: string): void { + const view = this.view; + if (!view || view.state.doc.toString() === document) return; + const head = Math.min(view.state.selection.main.head, document.length); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: document }, + selection: { anchor: head }, + annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)], + }); + } + + configure(configuration: SourceEditorConfiguration): void { + const view = this.view; + if (!view) return; + this.removeVimModeListener(); + view.dispatch({ + effects: [ + this.languageCompartment.reconfigure(languageForFile(configuration.filename)), + this.wrappingCompartment.reconfigure(wrappingForFile(configuration.filename)), + this.themeCompartment.reconfigure(sourceEditorTheme(configuration.theme)), + this.vimCompartment.reconfigure(configuration.vimEnabled ? vim() : []), + ], + }); + this.configureVim(configuration.vimEnabled); + } + + reveal(target: SourceEditorSelectionTarget): void { + const view = this.view; + if (!view) return; + const lineStart = Math.min(target.lineStart, view.state.doc.lines); + const lineEnd = Math.min(target.lineEnd ?? lineStart, view.state.doc.lines); + const from = view.state.doc.line(lineStart).from; + const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to; + view.dispatch({ + selection: { anchor: from, head: lineEnd > lineStart ? to : from }, + effects: EditorView.scrollIntoView(from, { y: "center" }), + }); + } + + destroy(): void { + this.removeVimModeListener(); + this.view?.destroy(); + this.view = null; + this.callbacks = null; + } + + private receiveUpdate(update: ViewUpdate): void { + if ( + update.docChanged && + !update.transactions.some((transaction) => transaction.annotation(remoteUpdate)) + ) { + const changes: SourceChange[] = []; + update.changes.iterChanges((from, to, _fromNew, _toNew, inserted) => { + changes.push({ from, to, insert: inserted.toString() }); + }); + this.callbacks?.onChange(changes); + } + if (update.selectionSet || update.docChanged) this.reportCursor(); + } + + private reportCursor(): void { + const view = this.view; + if (!view) return; + const head = view.state.selection.main.head; + const line = view.state.doc.lineAt(head); + this.callbacks?.onCursorChange({ line: line.number, column: head - line.from + 1 }); + } + + private configureVim(enabled: boolean): void { + if (!enabled || !this.view) { + this.callbacks?.onVimModeChange(null); + return; + } + const cm = getCM(this.view); + if (!cm) return; + this.vimModeListener = (event) => + this.callbacks?.onVimModeChange((event.mode ?? "normal").toUpperCase()); + cm.on("vim-mode-change", this.vimModeListener); + this.callbacks?.onVimModeChange("NORMAL"); + } + + private removeVimModeListener(): void { + if (!this.view || !this.vimModeListener) return; + getCM(this.view)?.off("vim-mode-change", this.vimModeListener); + this.vimModeListener = null; + } +} + +function languageForFile(filename: string) { + return getLanguageForFile(filename)?.extension ?? []; +} + +function wrappingForFile(filename: string) { + return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : []; +} diff --git a/packages/app/src/source-editor/codemirror/webview-entry.ts b/packages/app/src/source-editor/codemirror/webview-entry.ts new file mode 100644 index 0000000000..c90f59bc2d --- /dev/null +++ b/packages/app/src/source-editor/codemirror/webview-entry.ts @@ -0,0 +1,83 @@ +import { CodeMirrorRuntime } from "./runtime"; +import { + parseSourceEditorHostMessage, + type SourceEditorBridgeMessage, + type SourceEditorHostMessage, +} from "./bridge-protocol"; + +declare global { + interface Window { + ReactNativeWebView?: { postMessage?: (data: string) => void }; + __PASEO_SOURCE_EDITOR_RECEIVE__?: (message: string) => void; + } +} + +function send(message: SourceEditorBridgeMessage): void { + window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); +} + +class SourceEditorWebViewBridge { + private runtime: CodeMirrorRuntime | null = null; + private editorKey: string | null = null; + + receive = (rawMessage: string): void => { + const message = parseSourceEditorHostMessage(rawMessage); + if (!message) return; + if (message.type === "mount") { + this.mount(message); + return; + } + if (message.editorKey !== this.editorKey) return; + switch (message.type) { + case "replaceDocument": + this.runtime?.replaceDocument(message.document); + break; + case "configure": + this.runtime?.configure(message.configuration); + break; + case "reveal": + this.runtime?.reveal(message); + break; + case "destroy": + this.destroy(); + break; + } + }; + + private mount(message: Extract): void { + this.destroy(); + this.editorKey = message.editorKey; + const runtime = new CodeMirrorRuntime(); + this.runtime = runtime; + runtime.mount({ + host, + document: message.document, + configuration: message.configuration, + callbacks: { + onChange: (changes) => send({ type: "change", editorKey: message.editorKey, changes }), + onSave: () => send({ type: "save", editorKey: message.editorKey }), + onCursorChange: (position) => + send({ type: "cursor", editorKey: message.editorKey, ...position }), + onVimModeChange: (mode) => send({ type: "vimMode", editorKey: message.editorKey, mode }), + }, + }); + send({ type: "ready", editorKey: message.editorKey }); + } + + private destroy(): void { + this.runtime?.destroy(); + this.runtime = null; + this.editorKey = null; + } +} + +const root = document.createElement("div"); +root.id = "source-editor-root"; +const host = document.createElement("div"); +host.id = "source-editor-host"; +root.appendChild(host); +document.body.appendChild(root); + +const bridge = new SourceEditorWebViewBridge(); +window.__PASEO_SOURCE_EDITOR_RECEIVE__ = bridge.receive; +send({ type: "bridgeReady" }); diff --git a/packages/app/src/source-editor/codemirror/webview-html.ts b/packages/app/src/source-editor/codemirror/webview-html.ts new file mode 100644 index 0000000000..708bc26660 --- /dev/null +++ b/packages/app/src/source-editor/codemirror/webview-html.ts @@ -0,0 +1,5 @@ +// Generated by packages/app/scripts/build-source-editor-webview-html.mjs. +// Do not edit by hand. + +export const sourceEditorWebViewHtml = + "\n\n \n \n \n \n \n \n \n \n \n"; diff --git a/packages/app/src/source-editor/contract.ts b/packages/app/src/source-editor/contract.ts new file mode 100644 index 0000000000..d8cddae3db --- /dev/null +++ b/packages/app/src/source-editor/contract.ts @@ -0,0 +1,46 @@ +import type { HighlightStyle } from "@getpaseo/highlight"; + +export interface SourceChange { + from: number; + to: number; + insert: string; +} + +export interface SourceEditorPosition { + line: number; + column: number; +} + +export interface SourceEditorSelectionTarget { + lineStart: number; + lineEnd?: number; + revision: number; +} + +export interface SourceEditorTheme { + colorScheme: "light" | "dark"; + background: string; + foreground: string; + cursor: string; + foregroundMuted: string; + border: string; + selection: string; + monoFont: string; + codeFontSize: number; + syntax: Record; +} + +export interface SourceEditorConfiguration { + filename: string; + vimEnabled: boolean; + theme: SourceEditorTheme; +} + +export interface SourceEditorProps extends SourceEditorConfiguration { + document: string; + selectionTarget?: SourceEditorSelectionTarget; + onChange(document: string): void; + onSave(): void; + onCursorChange(position: SourceEditorPosition): void; + onVimModeChange(mode: string | null): void; +} diff --git a/packages/app/src/source-editor/controller.test.ts b/packages/app/src/source-editor/controller.test.ts new file mode 100644 index 0000000000..33f58d5ef9 --- /dev/null +++ b/packages/app/src/source-editor/controller.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { SourceEditorController } from "./controller"; + +function makeController() { + const events: unknown[] = []; + const controller = new SourceEditorController({ + editorKey: "current", + document: "one\r\ntwo", + callbacks: { + onChange: (document) => events.push(["change", document]), + onSave: () => events.push(["save"]), + onCursorChange: (position) => events.push(["cursor", position]), + onVimModeChange: (mode) => events.push(["vim", mode]), + }, + }); + return { controller, events }; +} + +describe("SourceEditorController", () => { + test("turns compact runtime changes into a document callback", () => { + const { controller, events } = makeController(); + + expect( + controller.receive({ + type: "change", + editorKey: "current", + changes: [{ from: 4, to: 7, insert: "second" }], + }), + ).toBe(true); + expect(events).toEqual([["change", "one\r\nsecond"]]); + }); + + test("rejects messages from a stale editor generation", () => { + const { controller, events } = makeController(); + + expect(controller.receive({ type: "save", editorKey: "stale" })).toBe(false); + expect(events).toEqual([]); + }); + + test("rejects invalid changes at the document boundary", () => { + const { controller, events } = makeController(); + + expect( + controller.receive({ + type: "change", + editorKey: "current", + changes: [{ from: 0, to: 99, insert: "bad" }], + }), + ).toBe(false); + expect(events).toEqual([]); + }); + + test("returns a normalized replacement only when the controlled document changed", () => { + const { controller } = makeController(); + + expect(controller.replaceDocument("one\r\ntwo")).toBeNull(); + expect(controller.replaceDocument("replacement\r\ndocument")).toBe("replacement\ndocument"); + }); +}); diff --git a/packages/app/src/source-editor/controller.ts b/packages/app/src/source-editor/controller.ts new file mode 100644 index 0000000000..1e98179737 --- /dev/null +++ b/packages/app/src/source-editor/controller.ts @@ -0,0 +1,61 @@ +import type { SourceEditorBridgeMessage } from "./codemirror/bridge-protocol"; +import type { SourceEditorPosition } from "./contract"; +import { SourceDocumentMirror } from "./document-mirror"; + +interface SourceEditorControllerCallbacks { + onChange(document: string): void; + onSave(): void; + onCursorChange(position: SourceEditorPosition): void; + onVimModeChange(mode: string | null): void; +} + +export class SourceEditorController { + private readonly editorKey: string; + private readonly mirror: SourceDocumentMirror; + private readonly callbacks: SourceEditorControllerCallbacks; + + constructor(input: { + editorKey: string; + document: string; + callbacks: SourceEditorControllerCallbacks; + }) { + this.editorKey = input.editorKey; + this.mirror = new SourceDocumentMirror(input.document); + this.callbacks = input.callbacks; + } + + getRuntimeDocument(): string { + return this.mirror.getRuntimeDocument(); + } + + replaceDocument(document: string): string | null { + return this.mirror.replace(document) ? this.mirror.getRuntimeDocument() : null; + } + + receive(message: SourceEditorBridgeMessage): boolean { + if (message.type === "bridgeReady" || message.editorKey !== this.editorKey) return false; + switch (message.type) { + case "ready": + return true; + case "change": { + let document: string; + try { + document = this.mirror.apply(message.changes); + } catch { + return false; + } + this.callbacks.onChange(document); + return true; + } + case "cursor": + this.callbacks.onCursorChange({ line: message.line, column: message.column }); + return true; + case "save": + this.callbacks.onSave(); + return true; + case "vimMode": + this.callbacks.onVimModeChange(message.mode); + return true; + } + } +} diff --git a/packages/app/src/source-editor/document-mirror.test.ts b/packages/app/src/source-editor/document-mirror.test.ts new file mode 100644 index 0000000000..25738c5653 --- /dev/null +++ b/packages/app/src/source-editor/document-mirror.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; +import { SourceDocumentMirror } from "./document-mirror"; + +describe("SourceDocumentMirror", () => { + test("applies CodeMirror changes without replacing unchanged text", () => { + const mirror = new SourceDocumentMirror("one two three"); + + const document = mirror.apply([ + { from: 0, to: 3, insert: "ONE" }, + { from: 8, to: 13, insert: "THREE" }, + ]); + + expect(document).toBe("ONE two THREE"); + }); + + test("preserves the file line separator while runtime offsets stay normalized", () => { + const mirror = new SourceDocumentMirror("one\r\ntwo\r\n"); + + expect(mirror.getRuntimeDocument()).toBe("one\ntwo\n"); + expect(mirror.apply([{ from: 4, to: 7, insert: "second\nline" }])).toBe( + "one\r\nsecond\r\nline\r\n", + ); + }); + + test("rejects overlapping or out-of-range changes", () => { + const mirror = new SourceDocumentMirror("one two"); + + expect(() => + mirror.apply([ + { from: 0, to: 4, insert: "" }, + { from: 3, to: 7, insert: "" }, + ]), + ).toThrow("Invalid source editor change range"); + expect(() => mirror.apply([{ from: 0, to: 8, insert: "" }])).toThrow( + "Invalid source editor change range", + ); + }); +}); diff --git a/packages/app/src/source-editor/document-mirror.ts b/packages/app/src/source-editor/document-mirror.ts new file mode 100644 index 0000000000..8f40473593 --- /dev/null +++ b/packages/app/src/source-editor/document-mirror.ts @@ -0,0 +1,66 @@ +import type { SourceChange } from "./contract"; + +type LineSeparator = "\n" | "\r\n" | "\r"; + +export class SourceDocumentMirror { + private text: string; + private lineSeparator: LineSeparator; + + constructor(document: string) { + this.lineSeparator = detectLineSeparator(document); + this.text = normalizeLineSeparators(document); + } + + getRuntimeDocument(): string { + return this.text; + } + + getDocument(): string { + return serializeDocument(this.text, this.lineSeparator); + } + + replace(document: string): boolean { + if (document === this.getDocument()) return false; + this.lineSeparator = detectLineSeparator(document); + this.text = normalizeLineSeparators(document); + return true; + } + + apply(changes: readonly SourceChange[]): string { + validateChanges(changes, this.text.length); + for (let index = changes.length - 1; index >= 0; index -= 1) { + const change = changes[index]; + this.text = `${this.text.slice(0, change.from)}${normalizeLineSeparators(change.insert)}${this.text.slice(change.to)}`; + } + return this.getDocument(); + } +} + +function validateChanges(changes: readonly SourceChange[], documentLength: number): void { + let previousEnd = 0; + for (const change of changes) { + if ( + !Number.isInteger(change.from) || + !Number.isInteger(change.to) || + change.from < previousEnd || + change.from > change.to || + change.to > documentLength + ) { + throw new Error("Invalid source editor change range"); + } + previousEnd = change.to; + } +} + +function normalizeLineSeparators(document: string): string { + return document.replace(/\r\n?|\n/g, "\n"); +} + +function serializeDocument(document: string, lineSeparator: LineSeparator): string { + return lineSeparator === "\n" ? document : document.replace(/\n/g, lineSeparator); +} + +function detectLineSeparator(document: string): LineSeparator { + const match = /\r\n|\r|\n/.exec(document); + return (match?.[0] as LineSeparator | undefined) ?? "\n"; +} diff --git a/packages/app/src/source-editor/source-editor.tsx b/packages/app/src/source-editor/source-editor.tsx new file mode 100644 index 0000000000..7dbe560a1d --- /dev/null +++ b/packages/app/src/source-editor/source-editor.tsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { WebViewMessageEvent } from "react-native-webview"; +import { WebView } from "react-native-webview"; +import { StyleSheet } from "react-native-unistyles"; +import { SourceEditorController } from "./controller"; +import type { SourceEditorConfiguration, SourceEditorProps } from "./contract"; +import { + parseSourceEditorBridgeMessage, + type SourceEditorHostMessage, +} from "./codemirror/bridge-protocol"; +import { sourceEditorWebViewHtml } from "./codemirror/webview-html"; + +const BASE_URL = "about:blank"; +const ORIGIN_WHITELIST = ["*"]; +let nextEditorKey = 1; + +export function SourceEditor(props: SourceEditorProps) { + const webViewRef = useRef(null); + const bridgeReadyRef = useRef(false); + const propsRef = useRef(props); + propsRef.current = props; + const [editorKey] = useState(() => `native-source-editor-${nextEditorKey++}`); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new SourceEditorController({ + editorKey, + document: props.document, + callbacks: { + onChange: (document) => propsRef.current.onChange(document), + onSave: () => propsRef.current.onSave(), + onCursorChange: (position) => propsRef.current.onCursorChange(position), + onVimModeChange: (mode) => propsRef.current.onVimModeChange(mode), + }, + }); + } + const controller = controllerRef.current; + const source = useMemo(() => ({ html: sourceEditorWebViewHtml, baseUrl: BASE_URL }), []); + const configuration = useMemo( + () => ({ filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }), + [props.filename, props.theme, props.vimEnabled], + ); + + const send = useCallback((message: SourceEditorHostMessage) => { + if (!bridgeReadyRef.current) return; + const serialized = JSON.stringify(JSON.stringify(message)); + webViewRef.current?.injectJavaScript( + `window.__PASEO_SOURCE_EDITOR_RECEIVE__?.(${serialized}); true;`, + ); + }, []); + + const mount = useCallback(() => { + send({ + type: "mount", + editorKey, + document: controller.getRuntimeDocument(), + configuration: configurationFromProps(propsRef.current), + }); + }, [controller, editorKey, send]); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + const message = parseSourceEditorBridgeMessage(event.nativeEvent.data); + if (!message) return; + if (message.type === "bridgeReady") { + bridgeReadyRef.current = true; + mount(); + const selectionTarget = propsRef.current.selectionTarget; + if (selectionTarget) send({ type: "reveal", editorKey, ...selectionTarget }); + return; + } + controller.receive(message); + }, + [controller, editorKey, mount, send], + ); + + useEffect(() => { + const replacement = controller.replaceDocument(props.document); + if (replacement !== null) { + send({ type: "replaceDocument", editorKey, document: replacement }); + } + }, [controller, editorKey, props.document, send]); + + useEffect(() => { + send({ + type: "configure", + editorKey, + configuration, + }); + }, [configuration, editorKey, send]); + + useEffect(() => { + if (props.selectionTarget) { + send({ type: "reveal", editorKey, ...props.selectionTarget }); + } + }, [editorKey, props.selectionTarget, send]); + + useEffect( + () => () => { + send({ type: "destroy", editorKey }); + bridgeReadyRef.current = false; + }, + [editorKey, send], + ); + + const allowOnlyInitialDocument = useCallback(({ url }: { url: string }) => { + return url === BASE_URL; + }, []); + + const reloadAfterProcessFailure = useCallback(() => { + bridgeReadyRef.current = false; + webViewRef.current?.reload(); + }, []); + const handleLoadStart = useCallback(() => { + bridgeReadyRef.current = false; + }, []); + const handleRenderProcessGone = useCallback(() => { + reloadAfterProcessFailure(); + return true; + }, [reloadAfterProcessFailure]); + + return ( + + ); +} + +function configurationFromProps(props: SourceEditorProps): SourceEditorConfiguration { + return { filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }; +} + +const styles = StyleSheet.create(() => ({ + webview: { flex: 1 }, +})); diff --git a/packages/app/src/source-editor/source-editor.web.tsx b/packages/app/src/source-editor/source-editor.web.tsx new file mode 100644 index 0000000000..8f33bfe748 --- /dev/null +++ b/packages/app/src/source-editor/source-editor.web.tsx @@ -0,0 +1,85 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { SourceEditorController } from "./controller"; +import type { SourceEditorConfiguration, SourceEditorProps } from "./contract"; +import { CodeMirrorRuntime } from "./codemirror/runtime"; + +let nextEditorKey = 1; + +export function SourceEditor(props: SourceEditorProps) { + const hostRef = useRef(null); + const runtimeRef = useRef(null); + const propsRef = useRef(props); + propsRef.current = props; + const initial = useRef(props); + const [editorKey] = useState(() => `web-source-editor-${nextEditorKey++}`); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new SourceEditorController({ + editorKey, + document: initial.current.document, + callbacks: { + onChange: (document) => propsRef.current.onChange(document), + onSave: () => propsRef.current.onSave(), + onCursorChange: (position) => propsRef.current.onCursorChange(position), + onVimModeChange: (mode) => propsRef.current.onVimModeChange(mode), + }, + }); + } + const controller = controllerRef.current; + const configuration = useMemo( + () => ({ filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }), + [props.filename, props.theme, props.vimEnabled], + ); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const runtime = new CodeMirrorRuntime(); + runtimeRef.current = runtime; + runtime.mount({ + host, + document: controller.getRuntimeDocument(), + configuration: configurationFromProps(initial.current), + callbacks: { + onChange: (changes) => controller.receive({ type: "change", editorKey, changes }), + onSave: () => controller.receive({ type: "save", editorKey }), + onCursorChange: (position) => + controller.receive({ type: "cursor", editorKey, ...position }), + onVimModeChange: (mode) => controller.receive({ type: "vimMode", editorKey, mode }), + }, + }); + return () => { + runtime.destroy(); + runtimeRef.current = null; + }; + }, [controller, editorKey]); + + useEffect(() => { + const replacement = controller.replaceDocument(props.document); + if (replacement !== null) runtimeRef.current?.replaceDocument(replacement); + }, [controller, props.document]); + + useEffect(() => { + runtimeRef.current?.configure(configuration); + }, [configuration]); + + useEffect(() => { + if (props.selectionTarget) runtimeRef.current?.reveal(props.selectionTarget); + }, [props.selectionTarget]); + + return ( +
+ ); +} + +function configurationFromProps(props: SourceEditorProps): SourceEditorConfiguration { + return { filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }; +} + +const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;