From 5ced51d1631d5b782aed989579f5923e7b369426 Mon Sep 17 00:00:00 2001 From: shubh-bruno Date: Mon, 4 May 2026 19:12:24 +0530 Subject: [PATCH] feat: persist CodeEditor's json state across tab switching (#7797) --- .../src/components/CodeEditor/index.js | 102 +++++++++++++-- .../CodeEditor/state-persistence.js | 118 ++++++++++++++++++ .../CollectionSettings/Script/index.js | 2 + .../CollectionSettings/Tests/index.js | 1 + .../components/FolderSettings/Script/index.js | 2 + .../components/FolderSettings/Tests/index.js | 1 + .../components/RequestPane/Script/index.js | 4 +- .../src/components/RequestPane/Tests/index.js | 1 + .../QueryResult/QueryResultPreview/index.js | 1 + .../PersistedScopeProvider.tsx | 3 +- 10 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 packages/bruno-app/src/components/CodeEditor/state-persistence.js 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 }) => { { { { { { { { {