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;