diff --git a/packages/insomnia-smoke-test/tests/smoke/editor-toggle-undo.test.ts b/packages/insomnia-smoke-test/tests/smoke/editor-toggle-undo.test.ts new file mode 100644 index 000000000000..6f0d2abee00a --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/editor-toggle-undo.test.ts @@ -0,0 +1,88 @@ +import { expect, type Page } from '@playwright/test'; + +import { test } from '../../playwright/test'; + +// Toggling a mode/sub-tab that hides a CodeEditor (markdown write<->preview, +// pre/after-response scripts) unmounts it while it has no viewport size. Its undo +// history must still be persisted and restored so Cmd/Ctrl+Z keeps working after +// toggling back. See docs/undo-redo-baseline.md. + +const isMac = process.platform === 'darwin'; +const MD_SEL = 'div.editor__container:has(textarea#markdown-editor) .CodeMirror'; +const PRE_SEL = 'div.editor__container:has(textarea[id$="pre-request-script"]) .CodeMirror'; + +const readState = (page: Page, sel: string) => + page.evaluate((s: string) => { + const node = document.querySelector(s) as any; + const cm = node?.CodeMirror; + return { + value: cm?.getValue() as string, + undo: cm?.historySize().undo as number, + }; + }, sel); + +test('markdown editor: toggling write/preview keeps undo history', async ({ page }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + await page.getByRole('tab', { name: 'Docs' }).click(); + + // Scope everything to the Docs panel: the markdown editor's DOM id is not unique + // app-wide, so target the one CodeEditor inside this tabpanel. + const docs = page.getByRole('tabpanel', { name: 'Docs' }); + const mdCm = docs.locator(MD_SEL); + const readCm = () => + mdCm.evaluate((node: any) => ({ + value: node.CodeMirror?.getValue() as string, + undo: node.CodeMirror?.historySize().undo as number, + })); + + // Click into the editor to focus it reliably, then type. + await mdCm.click(); + await page.keyboard.type('hello markdown undo'); + await expect.soft(mdCm).toContainText('hello markdown undo'); + const before = await readCm(); + expect.soft(before.undo).toBeGreaterThan(0); + + // Toggle to Preview (unmounts the editor) and back to Write (remounts it). + const mdTabs = docs.getByRole('tablist', { name: 'Markdown editor tabs' }); + await mdTabs.getByRole('tab', { name: 'Preview' }).click(); + await expect.soft(mdCm).toBeHidden(); + await mdTabs.getByRole('tab', { name: 'Write' }).click(); + await expect.soft(mdCm).toContainText('hello markdown undo'); + + // Value preserved AND the undo stack restored across the remount (was clobbered + // before the fix). + const after = await readCm(); + expect.soft(after.value).toContain('hello markdown undo'); + expect.soft(after.undo).toBe(before.undo); + + // Undo works on the remounted editor: it reverts the edit made before the toggle. + await mdCm.click(); + await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'); + await expect.poll(async () => (await readCm()).value).not.toContain('hello markdown undo'); +}); + +test('request scripts: toggling pre/after-response keeps undo history', async ({ page }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + await page.getByRole('tab', { name: 'Scripts' }).click(); + + await page.locator(`${PRE_SEL} textarea`).first().focus(); + // Plain text: avoids JS-mode bracket/quote auto-closing that would skew assertions. + await page.keyboard.type('undoMarkerVariable'); + await expect.soft(page.locator(PRE_SEL).first()).toContainText('undoMarkerVariable'); + expect.soft((await readState(page, PRE_SEL)).undo).toBeGreaterThan(0); + + // Toggle to After-response (unmounts the pre-request editor) and back. + const scriptTabs = page.getByRole('tablist', { name: 'Request scripts tabs' }); + await scriptTabs.getByRole('tab', { name: 'After-response' }).click(); + await expect.soft(page.locator(PRE_SEL)).toBeHidden(); + await scriptTabs.getByRole('tab', { name: 'Pre-request' }).click(); + await expect.soft(page.locator(PRE_SEL).first()).toContainText('undoMarkerVariable'); + + const after = await readState(page, PRE_SEL); + expect.soft(after.value).toContain('undoMarkerVariable'); + expect.soft(after.undo).toBeGreaterThan(0); + + await page.locator(`${PRE_SEL} textarea`).first().focus(); + await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'); + await expect.soft(page.locator(PRE_SEL).first()).not.toContainText('undoMarkerVariable'); +}); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 5c76ba630e90..b089e9b1e160 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -299,7 +299,10 @@ const main: Window['main'] = { deleteCompiledRuleset: options => invokeWithNormalizedError('deleteCompiledRuleset', options), refreshCompiledRuleset: options => invokeWithNormalizedError('refreshCompiledRuleset', options), writeResponseBodyToFile: options => invokeWithNormalizedError('writeResponseBodyToFile', options), - getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise<{ header?: RequestHeader; timeline?: ResponseTimelineEntry[] }> => + getAuthHeader: ( + renderedRequest: RenderedRequest, + url: string, + ): Promise<{ header?: RequestHeader; timeline?: ResponseTimelineEntry[] }> => invokeWithNormalizedError('getAuthHeader', renderedRequest, url), getOAuth2Token: ( requestId: string, diff --git a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx index 76d14be046a8..95697e48fa10 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -378,12 +378,21 @@ export const CodeEditor = memo( const persistState = useCallback(() => { if (historyKey && codeMirror.current) { const scrollInfo = codeMirror.current.getScrollInfo(); - // ignore invalid scroll positions - if (scrollInfo.height <= 0 || scrollInfo.width <= 0) { - return; - } + // A hidden or unmounting editor reports a zero-size viewport, so only + // persist a valid scroll position — but ALWAYS persist history (and + // cursor/selections/marks), which are layout-independent. Bailing here + // would drop the undo stack whenever the editor is toggled away while + // hidden (markdown write<->preview, pre/after-response script tabs). + const scroll = scrollInfo.height > 0 && scrollInfo.width > 0 ? scrollInfo : undefined; setCachedEditorState(historyKey, { - scroll: scrollInfo, + scroll, + // Only cache the value for editable editors, whose content can diverge + // from `defaultValue` (unsaved edits). Read-only editors are always + // authoritative from `defaultValue`. `valueSeed` records the model + // baseline so restore can tell an unchanged model (reuse the cached + // value + history) from an externally-updated one (use fresh defaultValue). + value: readOnly ? undefined : codeMirror.current.getValue(), + valueSeed: readOnly ? undefined : defaultValue ?? '', selections: codeMirror.current.listSelections(), cursor: codeMirror.current.getCursor(), history: codeMirror.current.getHistory(), @@ -401,7 +410,7 @@ export const CodeEditor = memo( }), }); } - }, [historyKey, codeMirror]); + }, [historyKey, codeMirror, readOnly, defaultValue]); const initEditor = useCallback(() => { if (!textAreaRef.current) { @@ -567,11 +576,27 @@ export const CodeEditor = memo( // Restore the state const cachedState = historyKey ? getCachedEditorState(historyKey) : undefined; if (cachedState) { - const { scroll, selections, cursor, history, marks } = cachedState; + const { scroll, selections, cursor, history, marks, value, valueSeed } = cachedState; + // Reuse the cached content + undo stack only when the current model is + // consistent with what was cached, i.e. `defaultValue` equals either: + // - the cached value -> the model caught up to / saved that content + // (e.g. markdown's debounced onChange landed), or + // - the cached seed -> the model is unchanged and the cached value is + // just unsaved edits on top of it. + // If it matches neither, the model changed externally between two mounts + // sharing a historyKey (a script/sync updated the environment/response), + // so the fresh defaultValue wins and the stale value + history are dropped. + // Restore the value first so it stays consistent with the restored undo + // stack; the setHistory below replaces the throwaway history setValue creates. + const currentDefault = defaultValue ?? ''; + const canReuse = value !== undefined && (currentDefault === value || currentDefault === valueSeed); + if (canReuse && value !== codeMirror.current.getValue()) { + codeMirror.current.setValue(value); + } if (scroll) { codeMirror.current.scrollTo(scroll.left, scroll.top); } - if (history) { + if (canReuse && history) { codeMirror.current.setHistory(history); } // NOTE: These won't be visible unless the editor is focused diff --git a/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts b/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts index aabf8027b6f4..3329f083cd8f 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts @@ -6,6 +6,15 @@ import type CodeMirror from 'codemirror'; // fields are only populated by the richer multi-line CodeEditor. export interface CachedEditorState { history: any; + // The editor content at unmount, plus the `defaultValue` baseline it was cached + // against. On remount the value + history are only reused when the current + // `defaultValue` still equals `valueSeed` — i.e. the model has NOT changed + // externally since. This keeps content consistent with the restored history when + // `defaultValue` merely lags (markdown write<->preview toggle), while letting a + // genuinely-updated model win (e.g. a pre-request script or sync changed the + // environment/response between two mounts sharing a historyKey). + value?: string; + valueSeed?: string; scroll?: CodeMirror.ScrollInfo; selections?: CodeMirror.Range[]; cursor?: CodeMirror.Position; diff --git a/packages/insomnia/src/ui/components/markdown-editor.tsx b/packages/insomnia/src/ui/components/markdown-editor.tsx index f94a089e890d..d3334a04ec7e 100644 --- a/packages/insomnia/src/ui/components/markdown-editor.tsx +++ b/packages/insomnia/src/ui/components/markdown-editor.tsx @@ -38,7 +38,7 @@ export const MarkdownEditor = forwardRef( > = ({ environmentId, settings, onPaste }) => - + = ({ environmentId, settings, onPaste }) => - + = ({ environmentId, settings, onPaste }) =>