mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b7ef823a |
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<style>
|
||||
html, body, #source-editor-root, #source-editor-host {
|
||||
width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden;
|
||||
}
|
||||
html, body { overscroll-behavior: none; }
|
||||
#source-editor-root, #source-editor-host { display: flex; min-width: 0; min-height: 0; }
|
||||
#source-editor-host { flex: 1; }
|
||||
* { -webkit-tap-highlight-color: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>${javascript}</script>
|
||||
</body>
|
||||
</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)`);
|
||||
}
|
||||
@@ -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<HighlightStyle, string>;
|
||||
};
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
|
||||
</View>
|
||||
<SourceEditor
|
||||
document={snapshot.content}
|
||||
filename={props.filename}
|
||||
selectionTarget={selectionTarget}
|
||||
vimEnabled={props.vimEnabled}
|
||||
theme={props.theme}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCursorChange={props.onCursorChange}
|
||||
onVimModeChange={props.onVimModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
|
||||
}));
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(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 (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
+11
-20
@@ -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<HighlightStyle, string>;
|
||||
}
|
||||
|
||||
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" },
|
||||
@@ -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<boolean>();
|
||||
|
||||
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 : [];
|
||||
}
|
||||
@@ -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<SourceEditorHostMessage, { type: "mount" }>): 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" });
|
||||
File diff suppressed because one or more lines are too long
@@ -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<HighlightStyle, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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<WebView>(null);
|
||||
const bridgeReadyRef = useRef(false);
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
const [editorKey] = useState(() => `native-source-editor-${nextEditorKey++}`);
|
||||
const controllerRef = useRef<SourceEditorController | null>(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<SourceEditorConfiguration>(
|
||||
() => ({ 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 (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
testID="file-source-editor"
|
||||
style={[styles.webview, { backgroundColor: props.theme.background }]}
|
||||
source={source}
|
||||
originWhitelist={ORIGIN_WHITELIST}
|
||||
onShouldStartLoadWithRequest={allowOnlyInitialDocument}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={handleLoadStart}
|
||||
onContentProcessDidTerminate={reloadAfterProcessFailure}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
setSupportMultipleWindows={false}
|
||||
javaScriptCanOpenWindowsAutomatically={false}
|
||||
domStorageEnabled={false}
|
||||
thirdPartyCookiesEnabled={false}
|
||||
cacheEnabled={false}
|
||||
incognito
|
||||
scrollEnabled={false}
|
||||
overScrollMode="never"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function configurationFromProps(props: SourceEditorProps): SourceEditorConfiguration {
|
||||
return { filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme };
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
webview: { flex: 1 },
|
||||
}));
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const runtimeRef = useRef<CodeMirrorRuntime | null>(null);
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
const initial = useRef(props);
|
||||
const [editorKey] = useState(() => `web-source-editor-${nextEditorKey++}`);
|
||||
const controllerRef = useRef<SourceEditorController | null>(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<SourceEditorConfiguration>(
|
||||
() => ({ 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 (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${props.filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user