Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 95b7ef823a feat(app): edit source files on mobile
Use one source-editor contract and shared editing runtime across browser and native hosts so file-pane persistence, conflicts, and line endings keep the same semantics on every platform.
2026-08-30 21:21:48 +02:00
23 changed files with 1128 additions and 210 deletions
+1
View File
@@ -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 |
+1
View File
@@ -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)
+35
View File
@@ -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.
+4 -1
View File
@@ -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)`);
}
+35 -22
View File
@@ -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;
+1 -5
View File
@@ -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;
}
@@ -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;