fix(editor): smooth SQL editor state synchronization
This commit is contained in:
4 files changed
+157
-33
No files matched your search
@@ -4,6 +4,7 @@ import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste,
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||
import { Transaction, StateEffect } from "@codemirror/state";
|
||||
import type { EditorState, Text } from "@codemirror/state";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { search as cmSearch } from "@codemirror/search";
|
||||
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
||||
@@ -259,6 +260,8 @@ let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = prop
|
||||
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
|
||||
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
|
||||
let contextMenuDoc: Text | null = null;
|
||||
let contextMenuDocText = "";
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
@@ -632,6 +635,7 @@ let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let semanticDiagnosticRunId = 0;
|
||||
let pendingSemanticDiagnosticPreserveOutsideRanges = false;
|
||||
let deferredCompletionTriggerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let editorIsActive = true;
|
||||
let tableReferenceDropListenerRegistered = false;
|
||||
let imeCompositionActive = false;
|
||||
@@ -678,6 +682,40 @@ function sqlCompletionDialectOptions() {
|
||||
};
|
||||
}
|
||||
|
||||
let editorCompletionContextCache: {
|
||||
doc: Text;
|
||||
editorState: EditorState | undefined;
|
||||
position: number;
|
||||
databaseType: DatabaseType | undefined;
|
||||
dialect: string | undefined;
|
||||
context: ReturnType<typeof getSqlCompletionContext>;
|
||||
} | null = null;
|
||||
|
||||
function getEditorSqlCompletionContext(sql: string, position: number, editorState = view.value?.state): ReturnType<typeof getSqlCompletionContext> {
|
||||
const dialect = sqlBehaviorDialect();
|
||||
const doc = editorState?.doc;
|
||||
if (doc && editorCompletionContextCache?.doc === doc && editorCompletionContextCache.editorState === editorState && editorCompletionContextCache.position === position && editorCompletionContextCache.databaseType === props.databaseType && editorCompletionContextCache.dialect === dialect) {
|
||||
return editorCompletionContextCache.context;
|
||||
}
|
||||
|
||||
const context = getSqlCompletionContext(sql, position, {
|
||||
databaseType: props.databaseType,
|
||||
dialect,
|
||||
editorState,
|
||||
});
|
||||
if (doc) {
|
||||
editorCompletionContextCache = {
|
||||
doc,
|
||||
editorState,
|
||||
position,
|
||||
databaseType: props.databaseType,
|
||||
dialect,
|
||||
context,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function usesOracleSessionCompletionColumns(schema?: string | null): boolean {
|
||||
return shouldUseOracleSessionCompletionColumns({
|
||||
databaseType: props.databaseType,
|
||||
@@ -1079,17 +1117,49 @@ function insertLineBelow(currentView: EditorViewType): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncContextMenuState(currentView: EditorViewType, starPosition?: number, previewPosition?: number) {
|
||||
function currentEditorDocText(currentView: EditorViewType): string {
|
||||
const doc = currentView.state.doc;
|
||||
if (contextMenuDoc !== doc) {
|
||||
contextMenuDoc = doc;
|
||||
contextMenuDocText = doc.toString();
|
||||
}
|
||||
return contextMenuDocText;
|
||||
}
|
||||
|
||||
function syncEditorSelectionState(currentView: EditorViewType) {
|
||||
selectedSql.value = selectedSqlFromView(currentView);
|
||||
executableSql.value = executableSqlFromView(currentView);
|
||||
executableSql.value = resolveExecutableSql(currentEditorDocText(currentView), selectedSql.value);
|
||||
}
|
||||
|
||||
function syncContextMenuState(currentView: EditorViewType, starPosition?: number, previewPosition?: number) {
|
||||
syncEditorSelectionState(currentView);
|
||||
previewContextSql.value = resolvePreviewDmlCandidate(previewPosition);
|
||||
selectStarExpansionTarget.value = selectStarExpansionTargetForView(currentView, starPosition);
|
||||
}
|
||||
|
||||
function clearScheduledPreviewContextRefresh() {
|
||||
if (previewContextRefreshTimer === null) return;
|
||||
clearTimeout(previewContextRefreshTimer);
|
||||
previewContextRefreshTimer = null;
|
||||
}
|
||||
|
||||
function schedulePreviewContextRefresh(currentView: EditorViewType) {
|
||||
clearScheduledPreviewContextRefresh();
|
||||
const expectedDoc = currentView.state.doc;
|
||||
const expectedSelection = currentView.state.selection.main;
|
||||
previewContextRefreshTimer = setTimeout(() => {
|
||||
previewContextRefreshTimer = null;
|
||||
const currentSelection = currentView.state.selection.main;
|
||||
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentSelection.from !== expectedSelection.from || currentSelection.to !== expectedSelection.to || !editorIsActive) return;
|
||||
previewContextSql.value = resolvePreviewDmlCandidate();
|
||||
emit("previewChangesAvailable", !!previewContextSql.value);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null {
|
||||
if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
|
||||
|
||||
const sql = currentView.state.doc.toString();
|
||||
const sql = currentEditorDocText(currentView);
|
||||
const selection = currentView.state.selection.main;
|
||||
let cursor: number;
|
||||
if (position != null) {
|
||||
@@ -1149,7 +1219,7 @@ function selectStarExpansionTargetForView(currentView: EditorViewType, position?
|
||||
aliasSql: source.aliasSpan ? sql.slice(source.aliasSpan.start, source.aliasSpan.end) : source.alias,
|
||||
};
|
||||
});
|
||||
const legacyContext = getSqlCompletionContext(sql, cursor, sqlCompletionDialectOptions());
|
||||
const legacyContext = getEditorSqlCompletionContext(sql, cursor);
|
||||
const context = sqlCompletionContextFromSemantic(model, legacyContext);
|
||||
if (context.statementKind !== "select" || !context.onStar) return null;
|
||||
|
||||
@@ -1166,6 +1236,7 @@ function selectStarExpansionTargetForView(currentView: EditorViewType, position?
|
||||
|
||||
function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEvent) {
|
||||
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
|
||||
clearScheduledPreviewContextRefresh();
|
||||
// 预览按“右键点击处”解析当前语句(执行按光标处),右键处与光标一致时才直觉一致。
|
||||
syncContextMenuState(currentView, pos ?? undefined, pos ?? undefined);
|
||||
if (pos == null) {
|
||||
@@ -1173,7 +1244,7 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = currentView.state.doc.toString();
|
||||
const sql = currentEditorDocText(currentView);
|
||||
if (!props.connectionId || props.database == null) {
|
||||
const candidate = queryTableCandidateAtSqlPosition({
|
||||
connectionId: "",
|
||||
@@ -1454,12 +1525,16 @@ function resolvePreviewDmlCandidate(position?: number): string {
|
||||
const text = currentView.state.sliceDoc(selection.from, selection.to);
|
||||
return looksLikeDmlStatement(text) ? text : "";
|
||||
}
|
||||
const doc = currentView.state.doc.toString();
|
||||
const cursorPos = position ?? selection.head;
|
||||
const parameterOptions = sqlStatementParameterOptions();
|
||||
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType, parameterOptions);
|
||||
const cursorCandidate = candidates.find((item) => item.supportedKinds.includes("cursor"));
|
||||
return cursorCandidate && looksLikeDmlStatement(cursorCandidate.sql) ? cursorCandidate.sql : "";
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
const cursorRange = executableStatementRangeAtCursor(executableStatementRangeCache, cursorPos);
|
||||
return cursorRange && looksLikeDmlStatement(cursorRange.sql) ? cursorRange.sql : "";
|
||||
}
|
||||
|
||||
function emitModelValue(currentView: EditorViewType): string {
|
||||
const sql = currentEditorDocText(currentView);
|
||||
emit("update:modelValue", sql);
|
||||
return sql;
|
||||
}
|
||||
|
||||
// 「预览变更」:把当前 DML 语句改写为只读 SELECT,作为新结果标签执行(干跑,不写库)。
|
||||
@@ -2416,10 +2491,6 @@ function selectedSqlFromView(currentView: EditorViewType): string {
|
||||
return currentView.state.sliceDoc(selection.from, selection.to);
|
||||
}
|
||||
|
||||
function executableSqlFromView(currentView: EditorViewType): string {
|
||||
return resolveExecutableSql(currentView.state.doc.toString(), selectedSqlFromView(currentView));
|
||||
}
|
||||
|
||||
function sqlExecutionSnapshotFromView(currentView: EditorViewType): SqlExecutionSnapshot {
|
||||
const selection = currentView.state.selection.main;
|
||||
return {
|
||||
@@ -3050,7 +3121,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
||||
};
|
||||
}
|
||||
|
||||
const legacyContext = getSqlCompletionContext(sql, pos, sqlCompletionDialectOptions());
|
||||
const legacyContext = getEditorSqlCompletionContext(sql, pos);
|
||||
const context = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyContext) : legacyContext;
|
||||
const candidates = qualifier ? context.referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : context.referencedTables;
|
||||
|
||||
@@ -3340,7 +3411,10 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
}
|
||||
|
||||
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
|
||||
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType);
|
||||
if (props.databaseType !== "sqlserver") {
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
}
|
||||
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : executableStatementRangeCache?.ranges);
|
||||
if (diagnosticRanges.length === 0) {
|
||||
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
|
||||
return;
|
||||
@@ -4534,7 +4608,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
|
||||
// require-prefix: only compute local facts (no positionalEligible).
|
||||
if (mode === "require-prefix") {
|
||||
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin,
|
||||
@@ -4547,7 +4621,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
|
||||
// positional: compute positionalEligible (lazy).
|
||||
if (mode === "positional") {
|
||||
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
@@ -4596,7 +4670,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
return buildCompletionResult(buildPostgresSequenceLiteralCompletionItems(sequenceLiteralContext, sequences), sequenceLiteralContext.from, undefined, sequenceLiteralContext.prefix);
|
||||
}
|
||||
|
||||
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const legacyCompletionContext = getEditorSqlCompletionContext(fullDoc, position);
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, sqlCompletionDialectOptions()) : null;
|
||||
let completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
|
||||
|
||||
@@ -4777,10 +4851,11 @@ function flushImeComposition() {
|
||||
const currentView = view.value;
|
||||
if (!currentView || !pendingImeModelEmit) return;
|
||||
pendingImeModelEmit = false;
|
||||
emit("update:modelValue", currentView.state.doc.toString());
|
||||
emitModelValue(currentView);
|
||||
invalidateSemanticDiagnosticsForDocumentChange();
|
||||
scheduleSemanticDiagnostics();
|
||||
syncContextMenuState(currentView);
|
||||
syncEditorSelectionState(currentView);
|
||||
schedulePreviewContextRefresh(currentView);
|
||||
emit("selectionChange", selectedSqlFromView(currentView));
|
||||
emit("cursorChange", currentView.state.selection.main.head);
|
||||
latestSelection = readEditorSelection(currentView);
|
||||
@@ -4810,7 +4885,7 @@ function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number
|
||||
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
|
||||
|
||||
if (mode === "require-prefix") {
|
||||
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin: "typing",
|
||||
@@ -4822,7 +4897,7 @@ function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number
|
||||
}
|
||||
|
||||
// positional
|
||||
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
@@ -4847,19 +4922,19 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s
|
||||
if (props.databaseType === "redis" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
|
||||
// Preserve old character-based checks for non-SQL providers.
|
||||
if (!insertedText && removedText) {
|
||||
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const completionContext = getEditorSqlCompletionContext(fullDoc, position);
|
||||
return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
}
|
||||
if (insertedText.endsWith(".")) return true;
|
||||
if (/[,(]$/.test(insertedText)) {
|
||||
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const completionContext = getEditorSqlCompletionContext(fullDoc, position);
|
||||
return !!completionContext.insertTable;
|
||||
}
|
||||
if (/\s$/.test(insertedText)) {
|
||||
return shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
}
|
||||
if (!/[\w$@]$/.test(insertedText)) return false;
|
||||
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const completionContext = getEditorSqlCompletionContext(fullDoc, position);
|
||||
return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
}
|
||||
|
||||
@@ -6305,7 +6380,7 @@ onMounted(async () => {
|
||||
pendingImeModelEmit = true;
|
||||
completionEpoch++;
|
||||
} else {
|
||||
emit("update:modelValue", update.state.doc.toString());
|
||||
emitModelValue(update.view);
|
||||
invalidateSemanticDiagnosticsForDocumentChange();
|
||||
scheduleSemanticDiagnostics();
|
||||
let insertedText = "";
|
||||
@@ -6322,10 +6397,10 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
syncContextMenuState(update.view);
|
||||
emit("selectionChange", selectedSqlFromView(update.view));
|
||||
syncEditorSelectionState(update.view);
|
||||
schedulePreviewContextRefresh(update.view);
|
||||
emit("selectionChange", selectedSql.value);
|
||||
emit("cursorChange", update.state.selection.main.head);
|
||||
emit("previewChangesAvailable", !!previewContextSql.value);
|
||||
latestSelection = readEditorSelection(update.view);
|
||||
if (editorIsActive) emitEditorSelection(latestSelection);
|
||||
}
|
||||
@@ -6753,6 +6828,7 @@ function swapEditorDocument(doc: string) {
|
||||
function activateTabDocument(prevTabId: string | undefined, tabId: string | undefined, doc: string) {
|
||||
const currentView = view.value;
|
||||
if (!currentView) return;
|
||||
clearScheduledPreviewContextRefresh();
|
||||
if (prevTabId !== undefined) {
|
||||
tabStateCache.set(prevTabId, currentView.state);
|
||||
if (tabStateCache.size > MAX_CACHED_TAB_STATES) {
|
||||
@@ -6771,6 +6847,9 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde
|
||||
// to the previous tab's latest position (#8378).
|
||||
restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 });
|
||||
restoreEditorViewport(props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 });
|
||||
clearScheduledPreviewContextRefresh();
|
||||
syncContextMenuState(currentView);
|
||||
emit("previewChangesAvailable", !!previewContextSql.value);
|
||||
return;
|
||||
}
|
||||
// setState swaps doc, selection, undo history and all fields at once, but it
|
||||
@@ -6793,6 +6872,9 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde
|
||||
invalidateSemanticDiagnosticsForDocumentChange();
|
||||
restoreEditorSelection();
|
||||
restoreEditorViewport();
|
||||
clearScheduledPreviewContextRefresh();
|
||||
syncContextMenuState(currentView);
|
||||
emit("previewChangesAvailable", !!previewContextSql.value);
|
||||
scheduleSemanticDiagnostics();
|
||||
}
|
||||
|
||||
@@ -6803,7 +6885,7 @@ watch([() => props.tabId, () => props.modelValue], ([tabId, val], [prevTabId]) =
|
||||
if (props.autoFocus) restoreEditorFocus();
|
||||
return;
|
||||
}
|
||||
if (val !== view.value.state.doc.toString()) {
|
||||
if (val !== currentEditorDocText(view.value)) {
|
||||
if (isEditorComposing(view.value)) return;
|
||||
view.value.dispatch({
|
||||
changes: { from: 0, to: view.value.state.doc.length, insert: val },
|
||||
@@ -7049,6 +7131,7 @@ function pauseQueryEditorBackgroundWork() {
|
||||
clearPendingCompletionEnter();
|
||||
clearPendingCompletionTab();
|
||||
clearDeferredCompletionTrigger();
|
||||
clearScheduledPreviewContextRefresh();
|
||||
executionViewportOwnership.reset();
|
||||
editorIsActive = false;
|
||||
clearScheduledSemanticDiagnostics();
|
||||
@@ -7060,6 +7143,7 @@ function resumeQueryEditorBackgroundWork() {
|
||||
editorIsActive = true;
|
||||
registerTableReferenceDropListener();
|
||||
scheduleSemanticDiagnostics();
|
||||
if (view.value) schedulePreviewContextRefresh(view.value);
|
||||
restoreEditorSelection();
|
||||
restoreEditorFocus();
|
||||
restoreEditorViewport();
|
||||
|
||||
@@ -9,6 +9,40 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC
|
||||
const source = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
|
||||
|
||||
describe("QueryEditor context menu lifecycle", () => {
|
||||
it("keeps heavyweight context derivation out of the per-update path", () => {
|
||||
const updateStart = source.indexOf("EditorView.updateListener.of((update) => {");
|
||||
const updateEnd = source.indexOf("\n fontThemeComp.of(", updateStart);
|
||||
const updateSource = source.slice(updateStart, updateEnd);
|
||||
|
||||
expect(updateStart).toBeGreaterThanOrEqual(0);
|
||||
expect(updateEnd).toBeGreaterThan(updateStart);
|
||||
expect(updateSource).toContain("syncEditorSelectionState(update.view);");
|
||||
expect(updateSource).toContain("schedulePreviewContextRefresh(update.view);");
|
||||
expect(updateSource).not.toContain("syncContextMenuState(update.view);");
|
||||
});
|
||||
|
||||
it("does not suppress legitimate external model updates", () => {
|
||||
expect(source).not.toContain("lastEmittedModelValue");
|
||||
expect(source).toContain("if (val !== currentEditorDocText(view.value)) {");
|
||||
});
|
||||
|
||||
it("resynchronizes context state after restoring a tab document", () => {
|
||||
const activateStart = source.indexOf("function activateTabDocument");
|
||||
const activateEnd = source.indexOf("\nwatch([() => props.tabId", activateStart);
|
||||
const activateSource = source.slice(activateStart, activateEnd);
|
||||
|
||||
expect(activateStart).toBeGreaterThanOrEqual(0);
|
||||
expect(activateEnd).toBeGreaterThan(activateStart);
|
||||
expect(activateSource).toContain("syncContextMenuState(currentView);");
|
||||
expect(activateSource).toContain('emit("previewChangesAvailable", !!previewContextSql.value);');
|
||||
});
|
||||
|
||||
it("guards deferred preview refresh against stale selection ranges", () => {
|
||||
expect(source).toContain("const expectedSelection = currentView.state.selection.main;");
|
||||
expect(source).toContain("currentSelection.from !== expectedSelection.from");
|
||||
expect(source).toContain("currentSelection.to !== expectedSelection.to");
|
||||
});
|
||||
|
||||
it("resolves menu items after synchronizing the right-click target", () => {
|
||||
const syncStart = source.indexOf("function syncContextMenuStateAtEvent");
|
||||
const syncEnd = source.indexOf("\n}", syncStart);
|
||||
|
||||
Reference in new issue
Block a user