fix(editor): isolate per-tab undo history on tab switches
This commit is contained in:
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"
|
||||
|
||||
Reference in new issue
Block a user