mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: persist CodeEditor's json state across tab switching (#7797)
This commit is contained in:
@@ -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 <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
|
||||
});
|
||||
|
||||
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
|
||||
|
||||
export default CodeEditorWithPersistenceScope;
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
};
|
||||
@@ -111,6 +111,7 @@ const Script = ({ collection }) => {
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
@@ -128,6 +129,7 @@ const Script = ({ collection }) => {
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
|
||||
@@ -35,6 +35,7 @@ const Tests = ({ collection }) => {
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
|
||||
@@ -114,6 +114,7 @@ const Script = ({ collection, folder }) => {
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
@@ -131,6 +132,7 @@ const Script = ({ collection, folder }) => {
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
|
||||
@@ -36,6 +36,7 @@ const Tests = ({ collection, folder }) => {
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
|
||||
@@ -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';
|
||||
@@ -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')}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user