The Edit menu's Undo/Redo used role: 'undo'/'redo', driving only the native webContents stack. On macOS the menu accelerator swallows Cmd+Z before CodeMirror's keymap, so CodeMirror surfaces had no working undo — two competing stacks with the native one always winning and doing nothing useful for CM. Menu Undo/Redo now send edit:undo/edit:redo to the focused window; a single renderer handler (editor-undo.ts, wired in renderer-listeners.ts) routes by focus: CodeMirror surfaces drive cm.undo()/redo(); everything else replays the native execCommand, preserving prior plain-input behaviour. No double-fire: the accelerator still keeps CM's own keymap from firing independently.
14 KiB
Undo/Redo Baseline
Catalog of the current undo/redo behaviour across Insomnia's text-input surfaces, established before attempting any improvement. Findings below are split into confirmed by code, confirmed at runtime (Playwright probe against the dev build), and open questions.
TL;DR
- There are three input technologies, each with different undo semantics.
- CodeMirror editors have built-in undo history; plain inputs rely on native browser/OS undo.
- The dominant defect is that undo history is destroyed on component remount, and the
single-line editor (
OneLineEditor) has no mechanism to restore it (the multi-lineCodeEditordoes, via a module-level cache — but only when its cache key is stable). - A native Electron Edit menu binds
Cmd/Ctrl+Zto the OS-level undo, which competes with CodeMirror's internal history.
The three input technologies
A. Multi-line CodeMirror — CodeEditor
| Aspect | Behaviour | Ref |
|---|---|---|
| Undo engine | CodeMirror built-in history | — |
| Init | initEditor runs once via useMount; defaultValue applied on mount only → uncontrolled |
:560 |
| Seed guard | clearHistory() after first setValue so the seed isn't undoable |
:488 |
| External writes | maybePrettifyAndSetValue no-ops when value is unchanged |
:296 |
| Remount survival | History is persisted to module-global editorStates[uniquenessKey] (getHistory()) and restored (setHistory()) on re-init — but only if uniquenessKey is unchanged across the remount |
:324, :503 |
| Persist to model | debounced onChange, DEBOUNCE_MILLIS = 100 |
:598 |
Consumers (~21): raw body, GraphQL query/variables, environment JSON editor, request headers/params editors, request-script, markdown, mock response, code-prompt modal, etc.
B. Single-line CodeMirror — OneLineEditor
| Aspect | Behaviour | Ref |
|---|---|---|
| Undo engine | CodeMirror built-in history | — |
| Init | initEditor once via useMount, defaultValue on mount only → uncontrolled |
:255 |
| Seed guard | clearHistory() after first set |
:221 |
| Remount survival | None. No editorStates equivalent — history is destroyed on every remount |
— |
setValue handle |
Preserves cursor but cm.setValue() clears CM undo history |
:366 |
| Persist to model | debounced onChange (100ms) + flush on blur |
:295, :304 |
Consumers (~14): URL bar, all key-value rows (name/value/description for headers, query, form-data, env), auth-input rows, cookies modal, WebSocket/gRPC/Socket.IO URL + panes, MCP url bar.
The "uncontrolled + manual setValue" design is deliberate — see the comment at
request-pane.tsx:71-75:
controlling the editor would make typed input lag behind the model round-trip.
C. Plain inputs — React Aria Input/TextField, raw <input>/<textarea>
Approx counts in packages/insomnia/src/ui: ~79 <Input>, ~42 <TextField>, ~19 <input>,
~10 <textarea>. These are mostly controlled (value + onChange). Undo relies on the
browser/OS native undo stack. A controlled input that re-renders on every keystroke can reset
that native stack — but this is not universal (see runtime results).
Native Electron Edit menu
window-utils.ts:305-342 defines an Edit menu:
- Undo →
role: 'undo', acceleratorCmdOrCtrl+Z - Redo →
role: 'redo', acceleratorShift+CmdOrCtrl+Z
These originally mapped to Electron's native webContents.undo()/redo(), which act on the focused
native editable element — independent of CodeMirror's internal history. This is why plain inputs
got working undo "for free", and why there were two competing undo stacks in CodeMirror surfaces.
This is now reconciled — the menu items route through one app-level handler (see opportunity 4
below).
Remount triggers
The request pane builds a composite key and applies it to the URL bar editor and body editor
(request-pane.tsx:82):
const uniqueKey = `${activeEnvironment?.modified}::${requestId}::${gitVersion}::${vcsVersion}::${activeRequestMeta?.activeResponseId}`;
Any change to a segment remounts the editor:
- Sending a request →
activeResponseIdchanges. - Editing an environment →
activeEnvironment.modifiedchanges. - Git/Sync version bump →
gitVersion/vcsVersionchanges. - Initial load settling after creating/opening a request (observed below).
On remount, OneLineEditor loses all undo history; CodeEditor can only restore it if the
uniquenessKey is unchanged — but several of these triggers change the key itself, defeating
the restore.
Runtime findings (Playwright probe, dev build)
Method: drive the real Electron renderer, type via keyboard, read CodeMirror state directly
(node.CodeMirror.historySize(), getValue()). Probe was temporary and has been removed.
| Scenario | Observation |
|---|---|
| URL bar, stable pane, type then settle | undoDepth = 1 — history retained |
| URL bar typed as the first mutation after creating a collection, then settle | undoDepth = 1 immediately → 0 after revalidation settles (initial-load remount wiped it) |
| Body editor, stable pane, type then settle | undoDepth = 1 — history retained |
| Body editor, then switch tab away and back (remount) | undoDepth = 0, value preserved — remount wipes history |
Body editor, real Cmd+Z |
Undid the edit ({"a":1} → {"a"}) |
URL bar, real Cmd+Z (single run) |
Value unchanged; focus retained — Cmd+Z did not undo |
Sidebar filter (plain controlled input), real Cmd+Z |
Native undo worked ("abc" → "") |
What this confirms
- Remount is the dominant history-killer. A stable editor keeps its undo stack across the 100ms persist/revalidation cycle; a remount destroys it (value is re-seeded from the model, history is not).
OneLineEditoris the most exposed because it has no history-restore cache and its consumers (URL bar, key-value rows) are keyed on volatile composite keys.- "All inputs have undo disabled" is not universally true — at least one plain controlled input (sidebar filter) has working native undo.
Resolved: why the URL bar lost undo / focus
Follow-up probing (real keyboard + MutationObserver) showed the URL bar's OneLineEditor
remounts ~once shortly after the first edit: the first patchRequest triggers a loader
revalidation, which changes a volatile segment of the editor's React key (uniqueKey =
activeEnvironment?.modified::requestId::gitVersion::vcsVersion::activeResponseId). The remount
blurs the editor and drops its undo history. So a Cmd+Z right after typing finds an
already-blurred, empty-history editor — exactly the "re-render + loss of focus" symptom.
The earlier "history non-empty when stable" reading was just a timing window before that remount
landed. Confirmed: a stable editor keeps focus and undoes correctly; the remount is the disease.
Improvement opportunities (ranked, least disruptive first)
- DONE. Give
OneLineEditorhistory persistence across remounts via a sharededitor-state-cache(also used byCodeEditor), keyed by a stableuniquenessKey. - DONE. Narrow the URL bar editor's remount key to
requestId::environmentId::environment.modifiedso it refreshes on request/environment change but not on sends or local edits (the focus-loss paths); external value changes resync in place via OneLineEditor'sdefaultValueeffect. - DONE.
setValueis non-destructive — it replaces the value viareplaceRange(history preserved, undoable) and no-ops when unchanged. - DONE. Reconcile the two undo stacks (native menu vs. CodeMirror) through one app-level
handler. The Edit menu's
Undo/Redono longer userole: 'undo'/'redo'(which only drove the native stack and left CodeMirror's Cmd+Z inert behind the menu accelerator); they now sendedit:undo/edit:redoto the focused window. A single renderer handler (editor-undo.ts, wired inrenderer-listeners.ts) routes by focus: a CodeMirror surface drivescm.undo()/redo(); anything else replays the browser's native edit command (execCommand), preserving the old behaviour for plain inputs. - Plain controlled inputs (Category C). Largest surface, lowest per-item value; defer unless a specific input is reported.
Suggested characterization tests (pin current behaviour before changing it)
- E2E (Playwright): type in URL bar → assert undo depth retained when stable; Send → assert remount occurs and history is lost (documents the bug); body editor → assert tab-switch remount loses history. These lock the baseline so a fix's improvement is measurable.
- Unit (Vitest):
shouldIndentWithTabsand any extracted history save/restore helper. - Co-locate per
AGENTS.md; E2E underpackages/insomnia-smoke-test/.