Skip to content

Commit 266631e

Browse files
authored
feat(undo): preserve editor undo history across tab changes (#10299)
1 parent 982252e commit 266631e

7 files changed

Lines changed: 185 additions & 6 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect, type Page } from '@playwright/test';
2+
3+
import { loadFixture } from '../../playwright/paths';
4+
import { test } from '../../playwright/test';
5+
6+
// Regression guard for undo history surviving a tab change. Switching the active
7+
// request unmounts the previous request's pane (its editors) and mounts the next;
8+
// the undo history must survive that remount via the shared editor-state cache, so
9+
// Cmd/Ctrl+Z still undoes after returning to the request. See docs/undo-redo-baseline.md.
10+
11+
const URL_SEL = 'div.editor__container:has(textarea#request-url-bar) .CodeMirror';
12+
const isMac = process.platform === 'darwin';
13+
14+
const readUrlState = (page: Page) =>
15+
page.evaluate((sel: string) => {
16+
const node = document.querySelector(sel) as any;
17+
const cm = node?.CodeMirror;
18+
return {
19+
value: cm?.getValue() as string,
20+
undo: cm?.historySize().undo as number,
21+
};
22+
}, URL_SEL);
23+
24+
test('undo history survives switching request tabs', async ({ page, app, insomnia }) => {
25+
const text = await loadFixture('simple.yaml');
26+
await app.evaluate(async ({ clipboard }, t) => clipboard.writeText(t), text);
27+
await page.getByLabel('Import').click();
28+
await page.locator('[data-test-id="import-from-clipboard"]').click();
29+
await page.getByRole('button', { name: 'Scan' }).click();
30+
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
31+
await page.getByRole('dialog').waitFor({ state: 'hidden' });
32+
33+
// Open the first request and edit its URL, building undo history.
34+
await insomnia.navigationSidebar.clickRequestOrFolder('example http');
35+
const urlInput = page.locator(`${URL_SEL} textarea`);
36+
await urlInput.focus();
37+
await page.keyboard.type('/undo-marker');
38+
// Web-first assertion settles the debounced persist + loader revalidation.
39+
await expect.soft(page.locator(URL_SEL)).toContainText('/undo-marker');
40+
expect.soft((await readUrlState(page)).undo).toBeGreaterThan(0);
41+
42+
// Change tabs: to another request and back. This unmounts the first request's
43+
// URL editor and remounts it — the round-trip that used to drop undo history.
44+
await insomnia.navigationSidebar.clickRequestOrFolder('proxyEnabled');
45+
await insomnia.navigationSidebar.clickRequestOrFolder('example http');
46+
await expect.soft(page.locator(URL_SEL)).toContainText('/undo-marker');
47+
48+
const afterSwitch = await readUrlState(page);
49+
// Value preserved AND undo history restored across the remount.
50+
expect.soft(afterSwitch.value).toContain('/undo-marker');
51+
expect.soft(afterSwitch.undo).toBeGreaterThan(0);
52+
53+
// Undo works on the remounted editor: it reverts the edit made before the switch.
54+
await page.locator(`${URL_SEL} textarea`).focus();
55+
await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z');
56+
await expect.soft(page.locator(URL_SEL)).not.toContainText('/undo-marker');
57+
});

packages/insomnia/src/routes/organization.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.pr
1111
import { useSyncOrganizationsAndProjectsActionFetcher } from '~/routes/organization.sync-organizations-and-projects';
1212
import { useUntrackedProjectsLoaderFetcher } from '~/routes/untracked-projects';
1313
import { AnalyticsEvent } from '~/ui/analytics';
14+
import { useEditorHistoryTabCleanup } from '~/ui/components/.client/codemirror/editor-history-cleanup';
1415
import { CommandPalette } from '~/ui/components/command-palette';
1516
import { GitHubStarsButton } from '~/ui/components/github-stars-button';
1617
import { HeaderInviteButton } from '~/ui/components/header-invite-button';
@@ -256,6 +257,10 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
256257
organizationId,
257258
});
258259

260+
// Reclaim cached editor undo/redo history when tabs close, so switching between
261+
// open tabs never evicts a still-open tab's history from the shared cache.
262+
useEditorHistoryTabCleanup();
263+
259264
const [isSidebarCollapsed, setIsSidebarCollapsed] = reactUse.useLocalStorage('project-navigation-collapsed', false);
260265

261266
const toggleSidebar = useCallback(
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useEffect } from 'react';
2+
3+
import uiEventBus from '~/ui/event-bus';
4+
5+
import { purgeCachedEditorStates } from './editor-state-cache';
6+
7+
// Lifecycle cleanup for the shared editor-state cache: when a tab closes, drop the
8+
// cached undo/redo history for the editors it owned, so closed tabs never crowd out
9+
// still-open tabs (which is what caused undo to be lost when switching tabs).
10+
//
11+
// A tab's id is its resource id (e.g. the request id), and the editors inside embed
12+
// that id in their historyKeys — `request-url-bar::<id>`, `<id>::render`,
13+
// `auth::<id>::<field>`, `request-description::<id>`, etc. — so purging every cached
14+
// key that contains a closed tab's id reclaims that tab's editors. (Key-value row
15+
// keys are keyed on the pair id instead and are reclaimed on row delete; see the
16+
// key-value editor.)
17+
export const useEditorHistoryTabCleanup = () => {
18+
useEffect(() => {
19+
const handleCloseTab = (_organizationId: string, ids: string[] | 'all') => {
20+
// 'all' means every tab in the org closed — no live tab-scoped editors remain.
21+
if (ids === 'all') {
22+
purgeCachedEditorStates(() => true);
23+
return;
24+
}
25+
if (!ids.length) {
26+
return;
27+
}
28+
purgeCachedEditorStates(key => ids.some(id => key.includes(id)));
29+
};
30+
return uiEventBus.on('CLOSE_TAB', handleCloseTab);
31+
}, []);
32+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
3+
import { getCachedEditorState, purgeCachedEditorStates, setCachedEditorState } from './editor-state-cache';
4+
5+
// The cache is a module-level singleton, so clear it between tests.
6+
afterEach(() => {
7+
purgeCachedEditorStates(() => true);
8+
});
9+
10+
const state = (history: string) => ({ history });
11+
12+
describe('purgeCachedEditorStates', () => {
13+
it('removes only the entries whose key matches the predicate and returns the count', () => {
14+
setCachedEditorState('request-url-bar::req_1', state('a'));
15+
setCachedEditorState('auth::req_1::token', state('b'));
16+
setCachedEditorState('request-url-bar::req_2', state('c'));
17+
18+
const removed = purgeCachedEditorStates(key => key.includes('req_1'));
19+
20+
expect(removed).toBe(2);
21+
expect(getCachedEditorState('request-url-bar::req_1')).toBeUndefined();
22+
expect(getCachedEditorState('auth::req_1::token')).toBeUndefined();
23+
// an unrelated tab's entry is untouched
24+
expect(getCachedEditorState('request-url-bar::req_2')).toEqual(state('c'));
25+
});
26+
27+
it('purges a deleted row without touching sibling rows', () => {
28+
setCachedEditorState('key-value-editor__namepair_1', state('n1'));
29+
setCachedEditorState('key-value-editor__valuepair_1', state('v1'));
30+
setCachedEditorState('key-value-editor__namepair_2', state('n2'));
31+
32+
purgeCachedEditorStates(key => key.includes('pair_1'));
33+
34+
expect(getCachedEditorState('key-value-editor__namepair_1')).toBeUndefined();
35+
expect(getCachedEditorState('key-value-editor__valuepair_1')).toBeUndefined();
36+
expect(getCachedEditorState('key-value-editor__namepair_2')).toEqual(state('n2'));
37+
});
38+
39+
it('purge-all clears the whole cache', () => {
40+
setCachedEditorState('a', state('1'));
41+
setCachedEditorState('b', state('2'));
42+
43+
const removed = purgeCachedEditorStates(() => true);
44+
45+
expect(removed).toBe(2);
46+
expect(getCachedEditorState('a')).toBeUndefined();
47+
expect(getCachedEditorState('b')).toBeUndefined();
48+
});
49+
50+
it('returns 0 when nothing matches', () => {
51+
setCachedEditorState('keep-me', state('1'));
52+
expect(purgeCachedEditorStates(key => key.includes('nope'))).toBe(0);
53+
expect(getCachedEditorState('keep-me')).toEqual(state('1'));
54+
});
55+
});

packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ export interface CachedEditorState {
1212
marks?: Partial<CodeMirror.MarkerRange>[];
1313
}
1414

15-
// Bounded LRU. Keys can be ephemeral (e.g. one per key-value pair id, minted per
16-
// row and never reused), so without a cap this would grow unboundedly for the
17-
// lifetime of the renderer, each entry retaining a CodeMirror history stack.
18-
// The cap only needs to cover editors that might remount roughly concurrently;
19-
// a few dozen is plenty, 100 is comfortably safe.
20-
const MAX_CACHED_EDITOR_STATES = 100;
15+
// The cache is primarily bounded by lifecycle purging (see purgeCachedEditorStates):
16+
// entries are dropped when their owning tab closes or their key-value row is
17+
// deleted, so it tracks live editors rather than growing forever. The LRU cap
18+
// below is only a safety backstop for entries no lifecycle event reaches. It must
19+
// be high enough that a single param/header-heavy request — which alone can mint a
20+
// few hundred keys (three per key-value row) — never evicts its own editors, and
21+
// that switching between several open tabs never evicts a still-open tab's undo
22+
// history. Each entry is a small CodeMirror history snapshot.
23+
const MAX_CACHED_EDITOR_STATES = 2000;
2124
// Map preserves insertion order, so the first key is the least-recently-used.
2225
const editorStates = new Map<string, CachedEditorState>();
2326

@@ -43,3 +46,19 @@ export const setCachedEditorState = (historyKey: string, state: CachedEditorStat
4346
editorStates.delete(lruKey);
4447
}
4548
};
49+
50+
// Lifecycle purge: drop every cached entry whose historyKey matches `shouldPurge`.
51+
// Used to reclaim history when the owning scope goes away — e.g. a closed tab
52+
// (purge keys that embed the closed request id) or a deleted key-value row (purge
53+
// that pair's keys) — so stale entries never accumulate or crowd out live editors.
54+
// Returns the number of entries removed.
55+
export const purgeCachedEditorStates = (shouldPurge: (historyKey: string) => boolean): number => {
56+
let purged = 0;
57+
for (const key of editorStates.keys()) {
58+
if (shouldPurge(key)) {
59+
editorStates.delete(key);
60+
purged++;
61+
}
62+
}
63+
return purged;
64+
};

packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { checkNestedKeys, ensureKeyIsValid } from '~/common/utils/environment-utils';
1919
import { base64decode } from '~/common/utils/vault';
2020
import { getRuntime } from '~/runtimes';
21+
import { purgeCachedEditorStates } from '~/ui/components/.client/codemirror/editor-state-cache';
2122
import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client/codemirror/one-line-editor';
2223

2324
import { generateId } from '../../../../common/misc';
@@ -303,6 +304,9 @@ export const EnvironmentKVEditor = ({
303304
};
304305

305306
const handleDeleteItem = (id: string) => {
307+
// Drop the deleted row's cached undo history (keyed on the pair id) so its
308+
// ephemeral entries don't linger in the shared cache.
309+
purgeCachedEditorStates(key => key.includes(id));
306310
onChange(persistedPairs.filter(d => d.id !== id));
307311
};
308312

packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from 'react-aria-components';
1515

1616
import { utf8ByteLength } from '~/common/utils/utf8-bytes';
17+
import { purgeCachedEditorStates } from '~/ui/components/.client/codemirror/editor-state-cache';
1718
import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client/codemirror/one-line-editor';
1819

1920
import { describeByteSize, generateId } from '../../../common/misc';
@@ -657,6 +658,12 @@ export const KeyValueEditor: FC<Props> = ({
657658
confirmMessage=""
658659
doneMessage=""
659660
onClick={() => {
661+
// Drop the deleted row's cached undo history (keyed on pair.id)
662+
// so its ephemeral entries don't linger in the shared cache.
663+
const pairId = pair.id;
664+
if (pairId) {
665+
purgeCachedEditorStates(key => key.includes(pairId));
666+
}
660667
onChange(persistedItems.filter(item => item.id !== pair.id));
661668
}}
662669
>

0 commit comments

Comments
 (0)