diff --git a/src/components/B4ACodeTree/B4ATreeActions.js b/src/components/B4ACodeTree/B4ATreeActions.js
index 8eb8e3e4ab..c4d9320825 100644
--- a/src/components/B4ACodeTree/B4ATreeActions.js
+++ b/src/components/B4ACodeTree/B4ATreeActions.js
@@ -135,7 +135,7 @@ const decodeFile = (code) => {
return Base64.decode(code.split(',')[1]);
}
-const encodeFile = async (code, extension) => {
+const encodeFile = (code, extension) => {
return extension + ',' + Base64.encode(code);
}
diff --git a/src/components/CodeEditor/B4aCodeEditorImpl.react.js b/src/components/CodeEditor/B4aCodeEditorImpl.react.js
index 15a71d1a6c..19ca2e58da 100644
--- a/src/components/CodeEditor/B4aCodeEditorImpl.react.js
+++ b/src/components/CodeEditor/B4aCodeEditorImpl.react.js
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react';
+import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
import Editor from '@monaco-editor/react';
const MONACO_THEME = 'vs-dark';
@@ -22,6 +22,11 @@ const languageMap = {
xml: 'xml',
};
+// How many emitted values we remember while waiting for the parent to hand
+// them back through the `code` prop. A parent that echoes synchronously never
+// holds more than one.
+const MAX_PENDING_ECHOES = 20;
+
const loadingFallbackStyle = {
color: '#d4d4d4',
background: '#1e1e1e',
@@ -35,23 +40,86 @@ const loadingFallbackStyle = {
};
const B4aCodeEditorImpl = forwardRef(
- ({ code: initialCode, onCodeChange, mode, readOnly = false, fontSize = 12 }, ref) => {
+ (
+ {
+ code: initialCode,
+ onCodeChange,
+ mode,
+ readOnly = false,
+ fontSize = 12,
+ documentKey,
+ focusToken,
+ },
+ ref
+ ) => {
const editorRef = useRef(null);
const monacoRef = useRef(null);
- const [code, setCode] = useState(initialCode ?? '');
+ // Monaco's model is the source of truth while a document is open. The
+ // `code` prop is only pushed into the editor when it is a genuinely
+ // external change (another file opened, content loaded), never when it is
+ // the parent handing back what the user just typed or pasted: re-applying
+ // an echo replaces the whole document, which moves the cursor to the end
+ // of the file and can drop edits made in the meantime.
+ const lastValueRef = useRef(initialCode ?? '');
+ const pendingEchoesRef = useRef([]);
+ const applyingExternalRef = useRef(false);
+ const documentKeyRef = useRef(documentKey);
+ const latestRef = useRef(null);
+ latestRef.current = { code: initialCode ?? '', documentKey, onCodeChange, readOnly, focusToken };
- useEffect(() => {
- const next = initialCode ?? '';
- // Avoid re-applying the value when the incoming prop is just an echo of
- // what the user typed (parent re-renders with the same source). This
- // prevents Monaco from doing a full setValue on every keystroke, which
- // is heavy (revalidation, undo reset) and can cause cursor jumps.
- const current = editorRef.current ? editorRef.current.getValue() : code;
- if (current === next) {
+ const applyExternalValue = (editor, next, isNewDocument) => {
+ applyingExternalRef.current = true;
+ try {
+ if (isNewDocument || latestRef.current.readOnly) {
+ // setValue() also clears the undo stack, so undo can never bring the
+ // previous file's content into the one that was just opened.
+ editor.setValue(next);
+ if (isNewDocument) {
+ editor.setScrollPosition({ scrollTop: 0, scrollLeft: 0 });
+ }
+ } else {
+ editor.executeEdits('', [
+ { range: editor.getModel().getFullModelRange(), text: next, forceMoveMarkers: true },
+ ]);
+ editor.pushUndoStop();
+ }
+ } finally {
+ applyingExternalRef.current = false;
+ }
+ lastValueRef.current = next;
+ pendingEchoesRef.current = [];
+ };
+
+ useLayoutEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) {
+ // Not mounted yet: handleMount picks up the latest code.
return;
}
- setCode(next);
- }, [initialCode]);
+ const next = initialCode ?? '';
+ const isNewDocument = documentKeyRef.current !== documentKey;
+ documentKeyRef.current = documentKey;
+ if (!isNewDocument) {
+ if (next === lastValueRef.current) {
+ pendingEchoesRef.current = [];
+ return;
+ }
+ // A parent that updates asynchronously can hand back a value the user
+ // has already typed past. It is still an echo, not an external change.
+ const echoIndex = pendingEchoesRef.current.lastIndexOf(next);
+ if (echoIndex !== -1) {
+ pendingEchoesRef.current.splice(0, echoIndex + 1);
+ return;
+ }
+ }
+ applyExternalValue(editor, next, isNewDocument);
+ }, [initialCode, documentKey]);
+
+ useLayoutEffect(() => {
+ if (focusToken && editorRef.current) {
+ editorRef.current.focus();
+ }
+ }, [focusToken]);
useImperativeHandle(ref, () => ({
get editor() {
@@ -71,12 +139,34 @@ const B4aCodeEditorImpl = forwardRef(
focus() {
editorRef.current && editorRef.current.focus();
},
+ selectAll() {
+ const editor = editorRef.current;
+ const model = editor && editor.getModel();
+ if (!model) {
+ return false;
+ }
+ editor.focus();
+ editor.setSelection(model.getFullModelRange());
+ return true;
+ },
}));
const handleMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
+ // The `code` prop may have changed between the model being created and
+ // this callback.
+ const latest = latestRef.current;
+ documentKeyRef.current = latest.documentKey;
+ if (editor.getValue() !== latest.code) {
+ applyExternalValue(editor, latest.code, true);
+ }
+ lastValueRef.current = latest.code;
+ if (latest.focusToken) {
+ editor.focus();
+ }
+
const remeasureAndLayout = () => {
if (editorRef.current !== editor || monacoRef.current !== monaco) {
return;
@@ -126,13 +216,23 @@ const B4aCodeEditorImpl = forwardRef(
}
};
- const handleChange = value => {
+ const handleChange = useCallback(value => {
const next = value ?? '';
- setCode(next);
- if (typeof onCodeChange === 'function') {
- onCodeChange(next);
+ lastValueRef.current = next;
+ if (applyingExternalRef.current) {
+ return;
}
- };
+ const notifyParent = latestRef.current.onCodeChange;
+ if (typeof notifyParent !== 'function') {
+ return;
+ }
+ const pendingEchoes = pendingEchoesRef.current;
+ pendingEchoes.push(next);
+ if (pendingEchoes.length > MAX_PENDING_ECHOES) {
+ pendingEchoes.shift();
+ }
+ notifyParent(next);
+ }, []);
const language = languageMap[mode] || 'plaintext';
@@ -156,6 +256,12 @@ const B4aCodeEditorImpl = forwardRef(
padding: { top: 8, bottom: 8 },
wordWrap: 'off',
fixedOverflowWidgets: true,
+ // Clipboard: insert exactly what was copied, synchronously. The
+ // "paste as" pipeline handles the paste asynchronously and gives up if
+ // the document changes while it is still working.
+ pasteAs: { enabled: false },
+ formatOnPaste: false,
+ autoIndentOnPaste: false,
}),
[readOnly, fontSize]
);
@@ -170,7 +276,7 @@ const B4aCodeEditorImpl = forwardRef(
height="100%"
width="100%"
language={language}
- value={code}
+ defaultValue={initialCode ?? ''}
theme={MONACO_THEME}
onChange={handleChange}
onMount={handleMount}