diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js
index b0421424c..061528e11 100644
--- a/packages/bruno-app/src/components/CodeEditor/index.js
+++ b/packages/bruno-app/src/components/CodeEditor/index.js
@@ -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';
@@ -17,6 +17,14 @@ 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;
@@ -24,7 +32,7 @@ window.JSHINT = JSHINT;
const TAB_SIZE = 2;
-export default class CodeEditor extends React.Component {
+class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -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 = () => {
@@ -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);
@@ -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) {
@@ -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);
@@ -355,3 +434,12 @@ export default class CodeEditor extends React.Component {
}
};
}
+
+const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
+ const persistenceScope = usePersistenceScope();
+ return ;
+});
+
+CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
+
+export default CodeEditorWithPersistenceScope;
diff --git a/packages/bruno-app/src/components/CodeEditor/state-persistence.js b/packages/bruno-app/src/components/CodeEditor/state-persistence.js
new file mode 100644
index 000000000..de071b439
--- /dev/null
+++ b/packages/bruno-app/src/components/CodeEditor/state-persistence.js
@@ -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 {}
+ }
+};
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
index 2a8fdd6a1..260f0135e 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
@@ -111,6 +111,7 @@ const Script = ({ collection }) => {
{
{
{
{
{
{
{
{