diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 94754c2fb8..aeeee11299 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -178,6 +178,8 @@ import type { CompletionAssistantObjectKind, ColumnInfo, DatabaseType, IndexInfo const props = defineProps<{ modelValue: string; + /** Identity of the tab owning the document. Changing it swaps in that tab's cached editor state (fresh undo history on first visit). */ + tabId?: string; connectionId?: string; catalog?: string; database?: string; @@ -557,6 +559,8 @@ let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBindin let readOnlyComp: import("@codemirror/state").Compartment | null = null; let runGutterComp: import("@codemirror/state").Compartment | null = null; let runKeymapComp: import("@codemirror/state").Compartment | null = null; +let historyResetComp: import("@codemirror/state").Compartment | null = null; +let codeMirrorHistory: typeof import("@codemirror/commands").history | null = null; let defaultKeymapComp: import("@codemirror/state").Compartment | null = null; let completionComp: import("@codemirror/state").Compartment | null = null; let diagnosticComp: import("@codemirror/state").Compartment | null = null; @@ -5632,6 +5636,7 @@ onMounted(async () => { readOnlyComp = new Compartment(); runGutterComp = new Compartment(); runKeymapComp = new Compartment(); + historyResetComp = new Compartment(); defaultKeymapComp = new Compartment(); completionComp = new Compartment(); diagnosticComp = new Compartment(); @@ -5659,6 +5664,7 @@ onMounted(async () => { codeMirrorMoveLineDown = moveLineDown; codeMirrorUndo = undo; codeMirrorRedo = redo; + codeMirrorHistory = history; codeMirrorSelectAll = selectAll; codeMirrorInsertNewlineKeepIndent = insertNewlineKeepIndent; codeMirrorToggleLineComment = toggleLineComment; @@ -6122,7 +6128,7 @@ onMounted(async () => { currentStatementFrameExtension, highlightActiveLineGutter(), highlightSpecialChars(), - history(), + historyResetComp.of(history()), foldGutter({ markerDOM(open: boolean) { const span = document.createElement("span"); @@ -6657,18 +6663,83 @@ watch( }, ); -watch( - () => props.modelValue, - (val) => { - if (view.value && val !== view.value.state.doc.toString()) { - if (isEditorComposing(view.value)) return; - view.value.dispatch({ - changes: { from: 0, to: view.value.state.doc.length, insert: val }, - }); - scheduleSemanticDiagnostics(); +// A single editor instance serves every tab, so the document swap on tab +// switches must not push "previous tab's content → new content" onto a shared +// undo history (one undo in the new tab restored the old tab's text). Each +// tab's editor state — including its undo history — is cached per tabId and +// reinstalled with setState; first-seen tabs get the document swapped in with a +// transaction excluded from history, and the history extension is dropped and +// re-added in two separate transactions (a compartment reconfigure alone keeps +// the old field value) so the previous tab's edits cannot leak in. +const tabStateCache = new Map(); +const MAX_CACHED_TAB_STATES = 16; + +function swapEditorDocument(doc: string) { + const currentView = view.value; + if (!currentView || !historyResetComp || !codeMirrorHistory) return; + if (doc !== currentView.state.doc.toString()) { + currentView.dispatch({ + changes: { from: 0, to: currentView.state.doc.length, insert: doc }, + annotations: Transaction.addToHistory.of(false), + }); + } + currentView.dispatch({ effects: historyResetComp.reconfigure([]) }); + currentView.dispatch({ effects: historyResetComp.reconfigure(codeMirrorHistory()) }); + scheduleSemanticDiagnostics(); +} + +function activateTabDocument(prevTabId: string | undefined, tabId: string | undefined, doc: string) { + const currentView = view.value; + if (!currentView) return; + if (prevTabId !== undefined) { + tabStateCache.set(prevTabId, currentView.state); + if (tabStateCache.size > MAX_CACHED_TAB_STATES) { + const oldest = tabStateCache.keys().next(); + if (!oldest.done) tabStateCache.delete(oldest.value); } - }, -); + } + const cached = tabId === undefined ? undefined : tabStateCache.get(tabId); + if (!cached) { + swapEditorDocument(doc); + return; + } + // setState swaps doc, selection, undo history and all fields at once, but it + // is not a transaction, so update-listener side effects are re-run manually. + currentView.setState(cached); + // Compartments in the restored state may lag behind settings that changed + // while another tab was active; re-sync them from current values. + void applyEditorAppearance(); + applyEditorShortcutKeymaps(); + applyEditorIndentExtension(); + applyEditorCompletionExtension(); + if (doc !== currentView.state.doc.toString()) { + // Content changed while the tab was inactive (external file change, AI + // edit, another split group): apply it as a regular undoable edit. + currentView.dispatch({ + changes: { from: 0, to: currentView.state.doc.length, insert: doc }, + }); + } + searchPanelRef.value?.scheduleDocumentSearchUpdate(); + invalidateSemanticDiagnosticsForDocumentChange(); + restoreEditorSelection(); + restoreEditorViewport(); + scheduleSemanticDiagnostics(); +} + +watch([() => props.tabId, () => props.modelValue], ([tabId, val], [prevTabId]) => { + if (!view.value) return; + if (tabId !== prevTabId) { + activateTabDocument(prevTabId, tabId, val); + return; + } + if (val !== view.value.state.doc.toString()) { + if (isEditorComposing(view.value)) return; + view.value.dispatch({ + changes: { from: 0, to: view.value.state.doc.length, insert: val }, + }); + scheduleSemanticDiagnostics(); + } +}); watch( () => props.formatRequestId, @@ -6782,75 +6853,97 @@ function getCurrentCustomThemeColors() { return activeTheme?.colors ?? settings.customThemeColors; } -// Reactively apply editor settings changes +// Reactively apply editor settings changes. Also called after restoring a +// cached per-tab state, whose compartments predate any settings changed while +// another tab was active. +async function applyEditorAppearance() { + const ss = queryEditorAppearanceSettings.value; + if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !lineNumbersComp || !vimModeComp || !closeBracketsComp || !runGutterComp || !runKeymapComp || !editorViewModule) { + return; + } + if (!isGestureZooming.value && !zoomCommitScheduler.hasPendingCommit() && liveFontSize.value !== ss.fontSize) { + liveFontSize.value = ss.fontSize; + } + syncEditorFontCssVars(liveFontSize.value, ss.fontFamily); + syncEditorDiagnosticCssVars(); + const themeColors = getCurrentCustomThemeColors(); + const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors, themePalette.value), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]); + if (!view.value || !codeMirrorTheme || !wordWrapComp || !lineNumbersComp || !vimModeComp || !closeBracketsComp || !runGutterComp || !runKeymapComp || !editorViewModule) { + return; + } + view.value.dispatch({ + effects: [ + codeMirrorTheme.reconfigure(themeExt), + wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), + lineNumbersComp.reconfigure(lineNumbersExtension(ss.showLineNumbers)), + vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)), + closeBracketsComp.reconfigure(closeBracketsExtension(settingsStore.editorSettings.autoCloseBrackets)), + runGutterComp.reconfigure(runStatementGutterExtension()), + runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)), + ], + }); +} + watch( [queryEditorAppearanceSettings, () => isDark.value, () => themePalette.value, editorThemeAppearance], - async ([ss]) => { - if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !lineNumbersComp || !vimModeComp || !closeBracketsComp || !runGutterComp || !runKeymapComp || !editorViewModule) { - return; - } - if (!isGestureZooming.value && !zoomCommitScheduler.hasPendingCommit() && liveFontSize.value !== ss.fontSize) { - liveFontSize.value = ss.fontSize; - } - syncEditorFontCssVars(liveFontSize.value, ss.fontFamily); - syncEditorDiagnosticCssVars(); - const themeColors = getCurrentCustomThemeColors(); - const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors, themePalette.value), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]); - if (!view.value || !codeMirrorTheme || !wordWrapComp || !lineNumbersComp || !vimModeComp || !closeBracketsComp || !runGutterComp || !runKeymapComp || !editorViewModule) { - return; - } - view.value.dispatch({ - effects: [ - codeMirrorTheme.reconfigure(themeExt), - wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), - lineNumbersComp.reconfigure(lineNumbersExtension(ss.showLineNumbers)), - vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)), - closeBracketsComp.reconfigure(closeBracketsExtension(settingsStore.editorSettings.autoCloseBrackets)), - runGutterComp.reconfigure(runStatementGutterExtension()), - runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)), - ], - }); + () => { + void applyEditorAppearance(); }, { deep: true }, ); +// Re-sync shortcut-driven keymap compartments; shared with per-tab state restore. +function applyEditorShortcutKeymaps() { + if (!view.value || !editorViewModule) return; + const effects = []; + if (defaultKeymapComp) { + effects.push(defaultKeymapComp.reconfigure(defaultKeymapExtension())); + } + if (runKeymapComp) { + effects.push(runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap))); + } + if (effects.length > 0) { + view.value.dispatch({ effects }); + } +} + watch( () => [settingsStore.editorSettings.shortcuts, settingsStore.editorSettings.sqlShortcuts], () => { - if (!view.value || !editorViewModule) return; - const effects = []; - if (defaultKeymapComp) { - effects.push(defaultKeymapComp.reconfigure(defaultKeymapExtension())); - } - if (runKeymapComp) { - effects.push(runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap))); - } - if (effects.length > 0) { - view.value.dispatch({ effects }); - } + applyEditorShortcutKeymaps(); }, { deep: true }, ); +// Re-sync the indent compartment; shared with per-tab state restore. +function applyEditorIndentExtension() { + if (!view.value || !indentComp) return; + view.value.dispatch({ effects: indentComp.reconfigure(indentExtension()) }); +} + watch( () => [settingsStore.editorSettings.sqlFormatter.tabWidth, settingsStore.editorSettings.sqlFormatter.useTabs], () => { - if (!view.value || !indentComp) return; - view.value.dispatch({ effects: indentComp.reconfigure(indentExtension()) }); + applyEditorIndentExtension(); }, ); +// Re-sync the completion compartment; shared with per-tab state restore. +function applyEditorCompletionExtension() { + completionEpoch++; + if (!view.value || !completionComp || !buildSqlCompletionExtension) return; + view.value.dispatch({ + effects: completionComp.reconfigure(buildSqlCompletionExtension()), + }); + if (codeMirrorCompletionStatus?.(view.value.state) === "active") { + codeMirrorStartCompletion?.(view.value); + } +} + watch( () => [settingsStore.editorSettings.snippets, settingsStore.editorSettings.sortCompletionColumnsAlphabetically, settingsStore.editorSettings.selectFirstCompletionOnOpen], () => { - completionEpoch++; - if (!view.value || !completionComp || !buildSqlCompletionExtension) return; - view.value.dispatch({ - effects: completionComp.reconfigure(buildSqlCompletionExtension()), - }); - if (codeMirrorCompletionStatus?.(view.value.state) === "active") { - codeMirrorStartCompletion?.(view.value); - } + applyEditorCompletionExtension(); }, { deep: true }, ); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 89d351acaf..69be1e865d 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1275,6 +1275,7 @@ defineExpose({ class="relative z-0 flex-1" :auto-focus="autoFocus !== false" :model-value="activeTab.sql" + :tab-id="activeTab.id" :connection-id="activeTab.connectionId" :catalog="activeTab.catalog" :database="activeTab.database" diff --git a/packages/app-tests/editorTabUndoIsolation.test.ts b/packages/app-tests/editorTabUndoIsolation.test.ts new file mode 100644 index 0000000000..da1ec5cfb4 --- /dev/null +++ b/packages/app-tests/editorTabUndoIsolation.test.ts @@ -0,0 +1,128 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { Compartment, EditorState, Transaction } from "@codemirror/state"; +import { history, redo, undo, redoDepth, undoDepth } from "@codemirror/commands"; + +/** + * Mirrors the tab-switch document swap in QueryEditor.vue (swapEditorDocument). + * One editor instance serves every tab, so the swap must not leave the previous + * tab's edits in the undo history. The swap transaction is annotated + * addToHistory:false, and the history extension is dropped and re-added in two + * separate transactions — a single compartment reconfigure would carry the old + * field value over, but a field absent from the intermediate configuration is + * re-initialized empty. + */ +const historyComp = new Compartment(); + +function createState(doc: string) { + return EditorState.create({ doc, extensions: [historyComp.of(history())] }); +} + +function type(state: EditorState, insert: string, at = state.doc.length) { + return state.update({ changes: { from: at, to: at, insert }, userEvent: "input.type" }).state; +} + +function replaceDocument(state: EditorState, doc: string, { isolateHistory }: { isolateHistory: boolean }) { + let next = state.update({ + changes: { from: 0, to: state.doc.length, insert: doc }, + ...(isolateHistory ? { annotations: Transaction.addToHistory.of(false) } : {}), + }).state; + if (isolateHistory) { + next = next.update({ effects: historyComp.reconfigure([]) }).state.update({ effects: historyComp.reconfigure(history()) }).state; + } + return next; +} + +function runUndo(state: EditorState) { + let next = state; + const applied = undo({ state, dispatch: (tr) => (next = tr.state) }); + return { applied, state: next }; +} + +test("plain document swap leaks tab 1's history into tab 2 (the regression)", () => { + let state = createState(""); + state = type(state, "select 1"); // tab 1 edits + state = replaceDocument(state, "select 2", { isolateHistory: false }); // switch to tab 2 without isolation + + assert.ok(undoDepth(state) >= 1, "the swap and tab 1's edits stay in the shared history"); + + // Undoing inside tab 2 walks back through tab 1's states. + let current = state; + for (let guard = 0; guard < 10; guard++) { + const result = runUndo(current); + if (!result.applied) break; + current = result.state; + } + assert.equal(current.doc.toString(), "", "undoing in tab 2 reaches tab 1's original document"); +}); + +test("tab swap resets the undo history so undo stays within the active tab", () => { + let state = createState(""); + state = type(state, "select 1"); + state = replaceDocument(state, "select 2", { isolateHistory: true }); + + assert.equal(undoDepth(state), 0, "previous tab's edits must not remain undoable"); + assert.equal(redoDepth(state), 0, "the swap itself must not be redoable"); + + const { applied, state: after } = runUndo(state); + + assert.equal(applied, false, "undo should be a no-op"); + assert.equal(after.doc.toString(), "select 2", "document must stay tab 2's content"); +}); + +test("typing after a tab swap is undoable back to the swapped document only", () => { + let state = createState(""); + state = type(state, "select 1"); + state = replaceDocument(state, "select 2", { isolateHistory: true }); + state = type(state, " 3"); + + const first = runUndo(state); + assert.equal(first.state.doc.toString(), "select 2", "undo removes only this tab's typing"); + + const second = runUndo(first.state); + assert.equal(second.applied, false, "no further undo steps exist"); + assert.equal(second.state.doc.toString(), "select 2", "undo cannot reach tab 1's content"); +}); + +test("same-tab external replacement stays undoable", () => { + let state = createState("select 1"); + // Formats and similar in-tab rewrites go through a plain dispatch (no tabId change). + state = state.update({ changes: { from: 0, to: state.doc.length, insert: "select\n 1" } }).state; + + const { applied, state: after } = runUndo(state); + + assert.equal(applied, true, "external in-tab rewrites remain undoable"); + assert.equal(after.doc.toString(), "select 1"); +}); + +test("redo after a tab swap cannot resurrect the previous tab's content", () => { + let state = createState(""); + state = type(state, "select 1"); + state = replaceDocument(state, "select 2", { isolateHistory: true }); + state = type(state, " 3"); + state = runUndo(state).state; + + let next = state; + const applied = redo({ state, dispatch: (tr) => (next = tr.state) }); + + assert.equal(applied, true, "redo reapplies this tab's typing"); + assert.equal(next.doc.toString(), "select 2 3", "redo stays within tab 2's own edits"); +}); + +test("undo history survives switching away and back (cached per-tab state)", () => { + // Tab 1 types, its editor state is cached on switch, and reinstated on return. + let tab1 = createState(""); + tab1 = type(tab1, "select 1"); + const cachedTab1 = tab1; + assert.equal(undoDepth(cachedTab1), 1, "tab 1 has its own undo step while away"); + + // Meanwhile tab 2 is active and edits its own document. + let tab2 = replaceDocument(cachedTab1, "select 2", { isolateHistory: true }); + tab2 = type(tab2, " 3"); + assert.equal(tab2.doc.toString(), "select 2 3"); + + // Switch back to tab 1: the restored state still undoes tab 1's own edit only. + const restored = runUndo(cachedTab1); + assert.equal(restored.applied, true, "undo still works after the round trip"); + assert.equal(restored.state.doc.toString(), "", "undo replays only tab 1's history"); +});