Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 95 additions & 7 deletions packages/bruno-app/src/components/CodeEditor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import React, { createRef } from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { isEqual } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
Expand All @@ -17,14 +17,22 @@ import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
captureEditorState,
getDocKey,
readPersistedEditorState,
writePersistedEditorState
} from './state-persistence';
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';

const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
window.JSHINT = JSHINT;

const TAB_SIZE = 2;

export default class CodeEditor extends React.Component {
class CodeEditor extends React.Component {
constructor(props) {
super(props);

Expand All @@ -48,6 +56,12 @@ export default class CodeEditor extends React.Component {
};
}

// Thin wrapper around the pure getDocKey helper from state-persistence.js.
// Kept on the class so the rest of the lifecycle code reads naturally.
_getDocKey() {
return getDocKey(this.props);
}

componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
Expand Down Expand Up @@ -184,6 +198,19 @@ export default class CodeEditor extends React.Component {
});

if (editor) {
// CM5 was constructed with props.value, so the editor already shows the
// right content. Read this tab's previously persisted view state from
// localStorage and apply it on top — restores folds, cursor, selection,
// undo history, and scroll position.
const docKey = getDocKey(this.props);
this._currentDocKey = docKey;
this.cachedValue = editor.getValue();
applyEditorState(
editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
this.cachedValue
);

editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
editor.scrollTo(null, this.props.initialScroll);
Expand Down Expand Up @@ -236,11 +263,52 @@ export default class CodeEditor extends React.Component {
this.editor.options.jump.schema = this.props.schema;
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
if (this.editor) {
// Two distinct update paths:
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
// 2. Same doc, value changed → external content update → setValue (view state resets)
const newDocKey = getDocKey(this.props);
const docKeyChanged = newDocKey !== this._currentDocKey;

if (docKeyChanged) {
// Path 1 — tab switch.
// Snapshot the outgoing tab's view state to localStorage so a future
// visit can restore it. Then setValue the incoming content and apply
// any view state previously persisted for the incoming tab.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this._currentDocKey = newDocKey;
applyEditorState(
this.editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
this.cachedValue
);
// setValue resets the editor's mode-overlay state — re-apply the
// brunovariables overlay and re-evaluate lint config for the new content.
this.addOverlay();
this.editor.setOption(
'lint',
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
);
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
// Path 2 — same tab, new external value (e.g. a fresh response arrived
// while this tab was active). Update content; view state resets because
// line positions no longer correspond to anything. Invalidate the
// persisted snapshot too, since the saved cursor/folds/history reflect
// the prior content.
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
}
}

if (this.editor) {
Expand Down Expand Up @@ -289,6 +357,17 @@ export default class CodeEditor extends React.Component {
this.props.onScroll(this._lastScrollTop);
}

// Snapshot view state to localStorage before tearing down the editor so
// the next mount of a CodeEditor with this docKey can restore folds,
// cursor, selection, undo history, and scroll position.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}

this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);

Expand Down Expand Up @@ -355,3 +434,12 @@ export default class CodeEditor extends React.Component {
}
};
}

const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
});

CodeEditorWithPersistenceScope.displayName = 'CodeEditor';

export default CodeEditorWithPersistenceScope;
118 changes: 118 additions & 0 deletions packages/bruno-app/src/components/CodeEditor/state-persistence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* CodeEditor view-state persistence — extracted for testability.
*
* Why this exists:
* Every tab switch causes CodeMirror's setValue() to wipe folds, cursor,
* selection, undo history, and scroll position. To preserve them, we serialize
* the relevant pieces to localStorage under a stable key for each editor and
* re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable
* representation of its undo stack via getHistory()/setHistory(), which is what
* makes Cmd-Z continue working across switches.
*
* Note: we deliberately do NOT persist the content itself — the canonical value
* lives in Redux (props.value). We only persist the editor's "view" state on
* top of that content. If content has drifted between save and restore, fold
* positions are applied leniently (foldCode silently no-ops on invalid lines)
* and history is skipped to avoid an inconsistent undo stack.
*/

export const STORAGE_PREFIX = 'persisted::';
export const DEFAULT_PERSISTENCE_SCOPE = 'global';
export const STORAGE_SEGMENT = 'codeeditor';

export const getScopedStorageKey = (scope, key) => {
const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE;
return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`;
};

// Identifies which Doc state belongs to a given CodeEditor instance.
//
// Callers can pass an explicit `docKey` prop when the auto-derived key would
// collide — e.g. Pre-Request vs Post-Response script editors share the same
// item/mode/readOnly and need an extra disambiguator.
//
// Auto-derived parts:
// id — distinguishes different tabs (requests or collections)
// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script)
// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match
export const getDocKey = (props) => {
if (props.docKey) return props.docKey;
const id = props.item?.uid || props.collection?.uid || 'default';
const mode = props.mode || 'default';
const readOnly = props.readOnly ? 'ro' : 'rw';
return `${id}:${mode}:${readOnly}`;
};

export const readPersistedEditorState = ({ scope, key }) => {
try {
const raw = localStorage.getItem(getScopedStorageKey(scope, key));
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
};

export const writePersistedEditorState = ({ scope, key, state }) => {
try {
const storageKey = getScopedStorageKey(scope, key);
if (state == null) {
localStorage.removeItem(storageKey);
} else {
localStorage.setItem(storageKey, JSON.stringify(state));
}
} catch {
// localStorage may be unavailable or full (Chromium ~10 MB cap). Editor
// state is non-critical — content lives in Redux — so silently ignore.
}
};

export const captureEditorState = (editor) => {
if (!editor) return null;
const doc = editor.getDoc();
const folds = editor
.getAllMarks()
.filter((m) => m.__isFold)
.map((m) => m.find())
.filter(Boolean)
.map((range) => range.from);
return {
contentLength: doc.getValue().length,
cursor: doc.getCursor(),
selections: doc.listSelections(),
history: doc.getHistory(),
folds,
scrollY: editor.getScrollInfo().top
};
};

export const applyEditorState = (editor, state, currentContent) => {
if (!editor || !state) return;
const doc = editor.getDoc();
const contentMatches = state.contentLength === (currentContent || '').length;

// History/cursor/selection only make sense if content didn't drift — applying
// a stale undo stack to different content would let Cmd-Z replay edits that
// no longer correspond to anything visible.
if (contentMatches) {
if (state.history) {
try { doc.setHistory(state.history); } catch {}
}
if (state.cursor) {
try { doc.setCursor(state.cursor); } catch {}
}
if (state.selections && state.selections.length) {
try { doc.setSelections(state.selections); } catch {}
}
}
// Folds are cheap and lenient — try them either way.
if (state.folds && state.folds.length) {
editor.operation(() => {
state.folds.forEach((from) => {
try { editor.foldCode(from); } catch {}
});
});
}
if (state.scrollY != null) {
try { editor.scrollTo(null, state.scrollY); } catch {}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const Script = ({ collection }) => {
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
Expand All @@ -128,6 +129,7 @@ const Script = ({ collection }) => {
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const Tests = ({ collection }) => {
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="collection-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const Script = ({ collection, folder }) => {
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
Expand All @@ -131,6 +132,7 @@ const Script = ({ collection, folder }) => {
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const Tests = ({ collection, folder }) => {
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="folder-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
Expand Down Expand Up @@ -107,6 +107,7 @@ const Script = ({ item, collection }) => {
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
Expand All @@ -125,6 +126,7 @@ const Script = ({ item, collection }) => {
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const Tests = ({ item, collection }) => {
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="tests"
value={tests || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const QueryResultPreview = ({
<CodeEditor
ref={editorRef}
collection={collection}
docKey="response:editor"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
Expand Down
Loading