fix(editor): isolate per-tab undo history on tab switches

This commit is contained in:
t8y2 committed 2026-09-07 13:20:35 +08:00
1 parent 7bd2c62f65
commit d29befe81c
3 files changed
+281 -59

No files matched your search

@@ -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<string, import("@codemirror/state").EditorState>();
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 },
);
@@ -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"