Files
dbx/apps/desktop/src/components/editor/QueryEditor.vue
T

8363 lines
369 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
// Replay guard for the toolbar format/compress requests. The ids are global
// monotonic counters shared by every tab, and ContentArea delivers them by
// gating the inactive tab's prop back to `undefined`; returning to the tab
// re-arms the same stale id on the reused editor instance (a replay, not a new
// command). The cursors live at module scope — not in `<script setup>` — so
// they also survive an editor unmount/remount (data-page switches), letting a
// freshly mounted editor consume the stale id instead of replaying it. The
// check is strictly monotonic (`>`), not `!==`: because ids are shared across
// tabs, a lower id is always an already-handled request from an active tab
// (delivery is same-tick), never a pending undelivered one.
let lastHandledFormatRequestId = 0;
let lastHandledCompressRequestId = 0;
</script>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Download, Eye, FileCode, Highlighter, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
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";
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
import DelimitedListDialog from "./DelimitedListDialog.vue";
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { completionMatchRanges } from "@/lib/common/completionMatch";
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import {
executableStatementRangeAtCursor,
executableStatementRangeCacheForDoc,
executableStatementRangeStartingAt as executableStatementRangeStartingAtLine,
mapStatementGutterStartIndex,
statementGutterStartIndexForCache,
statementGutterStartIndexHasStartAt,
type ExecutableStatementRangeCache,
type StatementGutterStartIndex,
} from "@/lib/sql/executableStatementRangeCache";
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
import { canFormatSqlForDatabaseType, formatSqlForDisplay, formatSqlForEditing, compressSqlText, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisible } from "@/lib/editor/queryEditorExecutionViewport";
import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines";
import { insertQueryEditorNewline } from "@/lib/editor/queryEditorNewline";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
import { convertSqlSelectionCase, type SqlSelectionCaseMode } from "@/lib/sql/sqlSelectionCase";
import { convertToNextNamingStyle } from "@/lib/naming/namingStyleConverter";
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
import { detectAndFormatElasticsearchRequests } from "@/lib/elasticsearch/elasticsearchFormatter";
import { useConnectionStore, COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { useToast } from "@/composables/useToast";
import {
buildSelectStarExpansion,
buildSqlCompletionItemsFromContext,
buildPostgresSequenceLiteralCompletionItems,
getSqlFunctionSignatureHelp,
getSqlCompletionContext,
getPostgresSequenceLiteralCompletionContext,
getSqlCompletionResultValidFor,
isSqlCompletionSuppressedContext,
isSqlLikeCompletionStatement,
prepareSqlCompletionReplacement,
recordCompletionSelection,
selectStarResultColumnsMatch,
shouldAutoOpenSqlCompletion,
shouldChainSqlCompletionAfterAccept,
extractCteDefinitions,
} from "@/lib/sql/sqlCompletion";
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts, type SqlCompletionTriggerOrigin } from "@/lib/sql/sqlCompletionTriggerPolicy";
import { driverProfileHasCompletionCandidates } from "@/lib/database/driverProfileExtensions";
import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSources } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references";
import {
buildElasticsearchCompletionItemsFromContext,
elasticsearchCompletionNeedsFields,
getElasticsearchCompletionContext,
getElasticsearchCompletionResultValidFor,
shouldAutoOpenElasticsearchCompletion,
type ElasticsearchCompletionField,
type ElasticsearchCompletionItem,
} from "@/lib/elasticsearch/elasticsearchCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import {
buildSqlServerUseDatabaseCompletionItems,
mergeSqlCompletionQualifierNames,
resolveSqlCompletionRoutineLookupTarget,
resolveSqlCompletionSchemaLookupDatabase,
resolveSqlCompletionScope,
resolveSqlCompletionTableLookupTarget,
resolveSqlServerUseDatabaseCompletion,
sqlServerUseCompletionDatabaseNames,
sqlServerUseDatabaseBeforeCursor,
type SqlCompletionScope,
} from "@/lib/sql/sqlCompletionLookupTarget";
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
import {
extractIdentifierDetailsAt,
extractQualifiedIdentifierAt,
isSqlKeyword,
matchSqlObject,
matchTable,
mergeSqlObjectNavigationType,
resolveSqlObjectNavigationIdentity,
splitQualifiedIdentifier,
sqlObjectHoverDetail,
sqlObjectNavigationSourceKind,
sqlObjectNavigationTarget,
sqlObjectNavigationTargetFromIdentity,
sqlObjectNavigationTypeFromCompletionObjectType,
type SqlObjectNavigationTarget,
} from "@/lib/sql/sqlNavigation";
import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normalizeAlignedSqlWhitespace, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql";
import { matchHoverTableCandidates, resolveHoverTableLookupTarget } from "@/lib/editor/hoverTableLookup";
import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout";
import { createHoverSearch, type HoverSearchController } from "@/lib/editor/sqlHoverSearch";
import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange, sqlErrorSqlMatchesEditor } from "@/lib/sql/sqlDiagnostics";
import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics";
import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics";
import {
DBX_TABLE_REFERENCE_MIME,
DBX_TABLE_REFERENCE_DROP_EVENT,
DBX_TABLE_REFERENCE_HOVER_EVENT,
DBX_TABLE_REFERENCE_DRAG_END_EVENT,
activeTableReferencePayloadValue,
clearActiveTableReferencePayload,
hasTableReferencePayloadType,
parseTableReferencePayload,
tableReferenceInsertText,
type QueryEditorTableReferenceDropDetail,
type QueryEditorTableReferenceHoverDetail,
type QueryEditorTableReferencePayload,
} from "@/lib/editor/queryEditorTableDrop";
import { isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { copySqlAsRichText } from "@/lib/sql/sqlRichText";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
import { isPanelResizing } from "@/lib/app/panelResizeState";
import { uiTuning } from "@/lib/app/uiTuning";
import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut";
import { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler, createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
import { createQueryEditorEscapeHandler } from "@/lib/editor/queryEditorEscape";
import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignmentExtension } from "@/lib/editor/queryEditorLineNumbers";
import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap";
import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap";
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection";
import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation";
import { clampEditorFontSize, createEditorWheelZoomGestureGuard, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
import { buildSqlShortcutExecutionSql, enabledSqlShortcutActions, resolveSqlShortcutForDatabase, uniqueSqlShortcutBindings } from "@/lib/sql/sqlShortcutActions";
import { resolveSqlShortcutTableToken } from "@/lib/sql/sqlShortcutTableTarget";
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
import { createSqlAliasHighlights } from "@/lib/editor/codemirrorSqlAliasHighlights";
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { stabilizeUnfocusedQueryEditorPointerDown } from "@/lib/editor/queryEditorUnfocusedPointer";
import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight";
import { startsQueryEditorRectangularSelection, startsQueryEditorSelectionDrag, usesQueryEditorObjectNavigationModifier } from "@/lib/editor/queryEditorPointerSelection";
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync";
import { queryEditorCommentTokens, queryEditorLineCommentToken, queryEditorWordLanguageData } from "@/lib/editor/queryEditorLineComment";
import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight";
import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection";
import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOccurrences } from "@/lib/editor/queryEditorOccurrenceSelection";
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut";
import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance";
import { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut";
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
import { analyzeIntentionActions, prepareExpandWildcardContext, buildExpandWildcardReplacement, type IntentionAction } from "@/lib/editor/sqlIntentionActions";
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isMacOS } from "@/lib/backend/platform";
import {
areSqlSemanticDiagnosticsEqual,
buildSqlParserErrorDiagnostic,
buildSqlSemanticDiagnostics,
isSqlSemanticDiagnosticInputContext,
isSqlVirtualTableReference,
shouldRunSqlSemanticDiagnostics,
sqlSemanticDiagnosticRangesForViewport,
tableReferenceKey,
type SqlSemanticDiagnostic,
} from "@/lib/sql/semantic/diagnostics";
import { resolveSqlDialectId, sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion";
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { CompletionAssistantObjectKind, ColumnInfo, DatabaseType, IndexInfo, SqlReferenceAnalysis, SqlServerCompletionContext, SqlTableReference, SqlTextSpan } from "@/types/database";
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;
schema?: string;
clientSessionId?: string;
completionContextVersion?: number;
databaseType?: DatabaseType;
dialect?: "mysql" | "postgres" | "sqlserver";
syntaxDialect?: CodeMirrorSqlDialectName;
formatDialect?: SqlFormatDialect;
formatRequestId?: number;
compressRequestId?: number;
executionError?: string;
executionErrorSql?: string;
resultColumns?: string[];
resultSourceStatement?: string;
resultSourceFrom?: number;
resultSourceTo?: number;
readOnly?: boolean;
autoFocus?: boolean;
forceWordWrap?: boolean;
hideExecutionControls?: boolean;
enableExplainShortcut?: boolean;
canExplain?: boolean;
initialViewport?: { scrollTop: number; scrollLeft: number };
initialSelection?: { anchor: number; head: number };
statementExecutionMarkers?: StatementExecutionMarker[];
}>();
function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined {
return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect);
}
function queryEditorSelectionLanguage(): "sql" | "text" {
const databaseType = props.databaseType;
return databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "victoriametrics" ? "text" : "sql";
}
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
const COMPLETION_DEBOUNCE_DELAY_MS = 150;
const COMPLETION_TRIGGER_DEFER_DELAY_MS = 50;
const COMPLETION_TAB_RETRY_DELAY_MS = 16;
const COMPLETION_TAB_MAX_WAIT_MS = COMPLETION_DEBOUNCE_DELAY_MS + COMPLETION_REMOTE_LATENCY_BUDGET_MS + 100;
const COMPLETION_ENTER_MAX_WAIT_MS = 125;
// Signature-help only ever looks backward from the cursor; past this distance
// no human-authored call site is worth the scan on huge documents.
const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000;
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
const emit = defineEmits<{
"update:modelValue": [value: string];
selectionChange: [value: string];
cursorChange: [pos: number];
previewChangesAvailable: [value: boolean];
formatError: [message: string];
execute: [source: SqlExecutionOverride];
executeInNewResultTab: [source: SqlExecutionOverride];
explain: [];
exportQuery: [payload: { sql: string; format: "csv" | "xlsx" | "txt"; columnComments?: (string | null)[] }];
save: [];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [target: SqlObjectNavigationTarget];
viewTableDdl: [target: SqlObjectNavigationTarget];
editTableStructure: [target: SqlObjectNavigationTarget];
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
closeColumnPanel: [];
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }, tabId?: string];
selectionStateChange: [selection: { anchor: number; head: number }];
editorStateFlushed: [];
sendSelectionToAi: [sql: string];
}>();
const editorRef = ref<HTMLDivElement>();
// While any panel divider is dragged, CodeMirror re-wraps and re-measures
// every visible line on every frame the editor box changes — with a 100+ line
// script that is the dominant drag cost (profiler: 2k+ measure() calls with
// getBoundingClientRect in a 3s drag). We pin the editor root to an explicit
// pixel size and then step-update that pin at most every TRACK_EVERY_FRAMES
// animation frames (~30fps), which keeps the editor visually following the
// divider while cutting the relayout frequency (and the layout thrash from
// interleaved reads/writes) by half or more. The step interval is tunable via
// ~/.dbx/ui-tuning.json (panelResizeTrackEveryFrames). The pin is released
// once on pointer-up for one final exact layout.
let frozenEditorBox: { width: number; height: number } | null = null;
let editorTrackFrameId = 0;
let editorTrackFrameCount = 0;
function editorAvailableSize(): { width: number; height: number } | null {
const host = editorRef.value?.parentElement;
if (!host) return null;
const width = host.clientWidth;
const height = host.clientHeight;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function pinEditorBox(size: { width: number; height: number }) {
const root = editorRef.value;
if (!root) return;
frozenEditorBox = size;
root.style.width = `${Math.round(size.width)}px`;
root.style.height = `${Math.round(size.height)}px`;
root.style.maxWidth = "none";
root.style.minHeight = "0";
}
function releaseEditorBox() {
const root = editorRef.value;
frozenEditorBox = null;
if (!root) return;
root.style.width = "";
root.style.height = "";
root.style.maxWidth = "";
root.style.minHeight = "";
}
function stopEditorBoxTracking() {
if (editorTrackFrameId) {
cancelAnimationFrame(editorTrackFrameId);
editorTrackFrameId = 0;
}
editorTrackFrameCount = 0;
}
function editorBoxTrackStep() {
editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep);
if (++editorTrackFrameCount < uiTuning.value.panelResizeTrackEveryFrames) return;
editorTrackFrameCount = 0;
const next = editorAvailableSize();
if (!next || !frozenEditorBox) return;
if (Math.round(next.width) === Math.round(frozenEditorBox.width) && Math.round(next.height) === Math.round(frozenEditorBox.height)) return;
// Writing the pin keeps the editor a fixed-size box: the parent's layout is
// reflowed (cheap, the editor subtree is `contain`ed and skipped) but
// CodeMirror only observes one discrete size change per step.
pinEditorBox(next);
}
watch(
isPanelResizing,
(resizing) => {
const root = editorRef.value;
if (!root) return;
if (resizing) {
if (frozenEditorBox) return;
const size = editorAvailableSize();
if (!size) return;
pinEditorBox(size);
editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep);
} else {
stopEditorBoxTracking();
if (frozenEditorBox) releaseEditorBox();
}
},
{ flush: "sync" },
);
onBeforeUnmount(stopEditorBoxTracking);
const view = shallowRef<EditorViewType | null>(null);
const contextMenuOpen = ref(false);
let contextMenuPointerCleanup: (() => void) | null = null;
let viewportOwnerTabId = props.tabId;
const viewportEmitTask = createDeferredEditorTask(() => {
if (latestViewport) emitEditorViewport(latestViewport);
}, 150);
let viewportRestoreFrame: number | null = null;
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
let tabSwitchStateCaptured = false;
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();
function sqlStatementParameterOptions() {
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, props.databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
return {
databaseType: props.databaseType,
compatibilityMode: props.databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(props.connectionId, props.database) : undefined,
enabledSyntaxes: enabledSqlParameterSyntaxes(toggles),
};
}
const { isDark, themePalette, activeCustomUiColors } = useTheme();
const { t } = useI18n();
const { toast } = useToast();
const snippetDatabaseType = computed(() => {
const connection = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined;
return sqlSnippetDatabaseTypeForConnection(connection) ?? props.databaseType;
});
const sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined));
const SQL_FUNCTION_NAMES = [
"COUNT",
"SUM",
"AVG",
"MIN",
"MAX",
"GROUP_CONCAT",
"STRING_AGG",
"CONCAT",
"CONCAT_WS",
"SUBSTRING",
"REPLACE",
"TRIM",
"UPPER",
"LOWER",
"LENGTH",
"REGEXP_REPLACE",
"DATE_FORMAT",
"DATEDIFF",
"DATE_ADD",
"DATE_SUB",
"EXTRACT",
"NOW",
"CURRENT_DATE",
"CURRENT_TIME",
"CURRENT_TIMESTAMP",
"CURDATE",
"CURTIME",
"LOCALTIME",
"LOCALTIMESTAMP",
"UTC_DATE",
"UTC_TIME",
"UTC_TIMESTAMP",
"SYSDATE",
"DATE",
"TIME",
"TIMESTAMPDIFF",
"YEAR",
"MONTH",
"DAY",
"HOUR",
"MINUTE",
"SECOND",
"DAYOFWEEK",
"DAYOFYEAR",
"LAST_DAY",
"STR_TO_DATE",
"IF",
"LEFT",
"RIGHT",
"SUBSTRING_INDEX",
"CHAR_LENGTH",
"INSTR",
"LOCATE",
"LPAD",
"RPAD",
"FIND_IN_SET",
"RAND",
"MD5",
"SHA1",
"SHA2",
"ROUND",
"FLOOR",
"CEIL",
"ABS",
"MOD",
"COALESCE",
"IFNULL",
"NULLIF",
"CAST",
"JSON_EXTRACT",
"JSON_VALUE",
"JSON_OBJECT",
"JSON_ARRAY",
] as const;
const completionTranslations = computed(() => ({
nullValue: t("editor.completion.nullValue"),
isNull: t("editor.completion.isNull"),
isNotNull: t("editor.completion.isNotNull"),
stringLiteral: t("editor.completion.stringLiteral"),
numericLiteral: t("editor.completion.numericLiteral"),
booleanValue: t("editor.completion.booleanValue"),
starExpansionColumns: t("editor.completion.starExpansionColumns"),
tableAlias: t("editor.completion.tableAlias"),
functionDescriptions: Object.fromEntries(SQL_FUNCTION_NAMES.map((name) => [name, t(`editor.completion.functionDescriptions.${name}`)])) as Record<string, string>,
}));
const MAX_COMPLETION_TABLES = 200;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20;
const MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES = 4;
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
const isGestureZooming = ref(false);
const searchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const selectedSql = ref("");
const executableSql = ref("");
const previewContextSql = ref("");
const contextObjectTarget = ref<SqlObjectNavigationTarget | null>(null);
interface SelectStarExpansionTarget {
from: number;
to: number;
references: SqlCompletionReferencedTable[];
context: SqlCompletionContext;
qualifierSql?: string;
statementSql: string;
allowResultColumnsFallback: boolean;
}
const selectStarExpansionTarget = ref<SelectStarExpansionTarget | null>(null);
const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0);
const canCopySelectedSql = computed(() => selectedSql.value.length > 0);
const canExecuteContextSql = computed(() => executableSql.value.trim().length > 0);
// Execution target picker state
const pickerVisible = ref(false);
const pickerCandidates = ref<SqlExecutionCandidate[]>([]);
const pickerActiveIndex = ref(0);
const pickerAnchor = ref<{ left: number; top: number }>();
// Delimited list dialog state
const delimitedListOpen = ref(false);
const delimitedListSelectedText = ref("");
const codeSnapshotOpen = ref(false);
const codeSnapshotSource = ref<CodeSnapshotSource | null>(null);
function openDelimitedListDialog() {
if (props.readOnly) return;
if (!selectedSql.value.trim()) {
toast(t("editor.delimitedList.selectFirst"), 3000);
return;
}
delimitedListSelectedText.value = selectedSql.value;
delimitedListOpen.value = true;
focusEditor();
}
function applyDelimitedListResult(result: string) {
const currentView = view.value;
if (!currentView || props.readOnly) return;
if (!replaceSelectedEditorText(currentView, result)) return;
focusEditor();
}
// ==================== Intention Popup ====================
interface IntentionPopupState {
visible: boolean;
// 直接复用 IntentionAction 类型,避免手动重声明导致 replacements 等字段丢失(TS2551)
actions: IntentionAction[];
position: { x: number; y: number };
selectedIndex: number;
}
const intentionPopup = ref<IntentionPopupState | null>(null);
function getIntentionActionLabel(kind: string): string {
switch (kind) {
case "expand_wildcard":
return t("intentionExpandWildcard");
case "qualify_identifier":
return t("intentionQualifyIdentifier");
case "unqualify_identifier":
return t("intentionUnqualifyIdentifier");
case "batch_qualify_identifiers":
return t("intentionBatchQualifyIdentifiers");
default:
return kind;
}
}
function closeIntentionPopup() {
document.removeEventListener("keydown", onIntentionPopupKey);
intentionPopup.value = null;
focusEditor();
}
function onIntentionPopupKey(e: KeyboardEvent) {
if (!intentionPopup.value?.visible) return;
switch (e.key) {
case "Escape":
e.preventDefault();
closeIntentionPopup();
break;
case "ArrowDown":
e.preventDefault();
intentionPopup.value.selectedIndex = Math.min(intentionPopup.value.selectedIndex + 1, intentionPopup.value.actions.length - 1);
break;
case "ArrowUp":
e.preventDefault();
intentionPopup.value.selectedIndex = Math.max(intentionPopup.value.selectedIndex - 1, 0);
break;
case "Enter":
e.preventDefault();
executeIntentionAction(intentionPopup.value.actions[intentionPopup.value.selectedIndex]);
break;
}
}
function executeIntentionAction(action: IntentionPopupState["actions"][number]) {
if (!intentionPopup.value) return;
closeIntentionPopup();
const currentView = view.value;
if (!currentView) return;
switch (action.kind) {
case "expand_wildcard": {
const sql = currentView.state.doc.toString();
const cursor = currentView.state.selection.main.head;
void (async () => {
try {
const ctx = prepareExpandWildcardContext(sql, cursor, props.databaseType, sqlBehaviorDialect());
if (!ctx) return;
const replacement = await buildExpandWildcardReplacement(props.databaseType, ctx.rowSources, async (source) => {
const schema = source.metadataTarget?.schema;
const tableName = source.metadataTarget?.table ?? source.name;
if (!tableName) return [];
const result = await loadTableMetadata({
connectionId: props.connectionId ?? "",
database: props.database ?? "",
schema,
tableName,
databaseType: props.databaseType ?? "mysql",
force: false,
});
return result.metadata.columns.map((c) => c.name);
});
const v = view.value;
if (!v || v.state.doc.toString() !== sql) return;
v.dispatch({ changes: { from: ctx.starSpan.start, to: ctx.starSpan.end, insert: replacement } });
} catch {
// metadata load failed
}
})();
break;
}
case "qualify_identifier":
case "unqualify_identifier":
currentView.dispatch({
changes: { from: action.span.start, to: action.span.end, insert: action.replacement },
});
break;
case "batch_qualify_identifiers": {
// 按从后往前的顺序逐一替换,避免 offset 漂移
const reps = action.replacements ?? [];
for (let i = reps.length - 1; i >= 0; i--) {
const r = reps[i];
currentView.dispatch({
changes: { from: r.span.start, to: r.span.end, insert: r.replacement },
});
}
break;
}
}
}
const executeContextMenuLabel = computed(() => t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"));
interface EditorGestureEvent extends Event {
scale?: number;
}
let editorViewModule: typeof import("@codemirror/view") | null = null;
let codeMirrorLineNumbers: typeof import("@codemirror/view").lineNumbers | null = null;
let codeMirrorPrec: typeof import("@codemirror/state").Prec | null = null;
let codeMirrorEditorSelection: typeof import("@codemirror/state").EditorSelection | null = null;
let hoverCloseEffect: StateEffect<unknown> | null = null;
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
let lineNumbersComp: import("@codemirror/state").Compartment | null = null;
let vimModeComp: import("@codemirror/state").Compartment | null = null;
let closeBracketsComp: import("@codemirror/state").Compartment | null = null;
let sqlLanguageComp: import("@codemirror/state").Compartment | null = null;
let sqlSemanticHighlightComp: import("@codemirror/state").Compartment | null = null;
let sqlSignatureComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorCloseBrackets: typeof import("@codemirror/autocomplete").closeBrackets | null = null;
let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null;
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;
let codeMirrorVim: typeof import("@replit/codemirror-vim").vim | null = null;
let codeMirrorVimApi: typeof import("@replit/codemirror-vim").Vim | null = null;
let codeMirrorGetVimCm: typeof import("@replit/codemirror-vim").getCM | null = null;
let codeMirrorVimImportPromise: Promise<typeof import("@replit/codemirror-vim")> | null = null;
let dbxVimCommandsConfigured = false;
let buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlCompletionExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlLanguageExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildSqlSemanticHighlightExtension: (() => import("@codemirror/state").Extension) | null = null;
let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion;
let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null;
let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null;
let codeMirrorCurrentCompletions: typeof import("@codemirror/autocomplete").currentCompletions | null = null;
let codeMirrorSelectedCompletionIndex: typeof import("@codemirror/autocomplete").selectedCompletionIndex | null = null;
let codeMirrorSelectedCompletion: typeof import("@codemirror/autocomplete").selectedCompletion | null = null;
let codeMirrorSetSelectedCompletion: typeof import("@codemirror/autocomplete").setSelectedCompletion | null = null;
let codeMirrorMoveCompletionSelection: typeof import("@codemirror/autocomplete").moveCompletionSelection | null = null;
let codeMirrorSelectFirstCompletion: import("@codemirror/view").Command | null = null;
let codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null;
let codeMirrorCloseCompletion: typeof import("@codemirror/autocomplete").closeCompletion | null = null;
let codeMirrorInsertCompletionText: typeof import("@codemirror/autocomplete").insertCompletionText | null = null;
let codeMirrorNextSnippetField: typeof import("@codemirror/autocomplete").nextSnippetField | null = null;
let codeMirrorIndentMore: typeof import("@codemirror/commands").indentMore | null = null;
let codeMirrorIndentLess: typeof import("@codemirror/commands").indentLess | null = null;
let codeMirrorCopyLineDown: typeof import("@codemirror/commands").copyLineDown | null = null;
let codeMirrorCopyLineUp: typeof import("@codemirror/commands").copyLineUp | null = null;
let codeMirrorDeleteLine: typeof import("@codemirror/commands").deleteLine | null = null;
let codeMirrorMoveLineUp: typeof import("@codemirror/commands").moveLineUp | null = null;
let codeMirrorMoveLineDown: typeof import("@codemirror/commands").moveLineDown | null = null;
let codeMirrorUndo: typeof import("@codemirror/commands").undo | null = null;
let codeMirrorRedo: typeof import("@codemirror/commands").redo | null = null;
let codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null = null;
let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null;
let codeMirrorToggleBlockComment: typeof import("@codemirror/commands").toggleBlockComment | null = null;
let codeMirrorDefaultKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null;
let codeMirrorToggleFold: typeof import("@codemirror/language").toggleFold | null = null;
let pendingCompletionTabTimer: ReturnType<typeof setTimeout> | null = null;
let cancelPendingCompletionEnter: (() => void) | null = null;
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
let setPreviewRangeEffect:
| import("@codemirror/state").StateEffectType<{
from: number;
to: number;
} | null>
| null = null;
let setResultSourceRangeEffect:
| import("@codemirror/state").StateEffectType<{
from: number;
to: number;
} | null>
| null = null;
let setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectType<StatementExecutionMarker[]> | null = null;
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildResultSourceRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildRunStatementGutterExtension: (() => import("@codemirror/state").Extension) | null = null;
let indentComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorIndentUnit: typeof import("@codemirror/language").indentUnit | null = null;
let semanticDiagnostics: SqlSemanticDiagnostic[] = [];
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;
let pendingImeModelEmit = false;
const postCompositionKeyGuard = createQueryEditorPostCompositionKeyGuard();
let postCompositionKeyGuardCleanup: (() => void) | null = null;
function runStatementGutterExtension(): import("@codemirror/state").Extension {
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
return shouldShowStatementGutter(showRunButtons) ? (buildRunStatementGutterExtension?.() ?? []) : [];
}
let executableStatementRangeCache: ExecutableStatementRangeCache | null = null;
// Lenient statement-boundary view shared by the run-statement gutter and the
// current-statement frame. Re-parsing the whole document on every keystroke is
// the dominant typing cost on large scripts, so while typing we only shift the
// known start positions / frame range through the ChangeSet (O(statements)) and
// rebuild exactly once after typing pauses. Paths that must stay exact (gutter
// click-to-execute, execution picker) keep using the full on-demand parse.
const STATEMENT_BOUNDARIES_REFRESH_MS = 150;
let statementBoundariesView: {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
} | null = null;
let statementBoundariesGeneration = 0;
let statementBoundariesRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let statementBoundariesRefreshEffect: import("@codemirror/state").StateEffectType<null> | null = null;
function refreshStatementBoundaries(state: import("@codemirror/state").EditorState) {
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, state.doc, props.databaseType, sqlStatementParameterOptions());
statementBoundariesView = {
doc: state.doc,
startsIndex: statementGutterStartIndexForCache(executableStatementRangeCache),
frameRange: null,
fresh: true,
generation: statementBoundariesGeneration,
};
}
interface StatementBoundariesView {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
}
// Returns the view matching `state`, rebuilding it synchronously when the
// tracked doc fell out of sync (first use, tab switch via setState) or the
// dialect generation moved. While typing, the tracking plugin keeps the doc
// reference current through ChangeSet mapping, so this stays cheap.
function statementBoundariesForState(state: import("@codemirror/state").EditorState): StatementBoundariesView {
if (statementBoundariesView && statementBoundariesView.doc === state.doc && statementBoundariesView.generation === statementBoundariesGeneration) return statementBoundariesView;
refreshStatementBoundaries(state);
return statementBoundariesView!;
}
function scheduleStatementBoundariesRefresh(currentView: EditorViewType) {
// True debounce: continuous typing must never pay a full-document parse —
// mapped positions serve the gutter/frame, and one rebuild lands only after
// the pause.
if (statementBoundariesRefreshTimer !== null) clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = setTimeout(() => {
statementBoundariesRefreshTimer = null;
if (view.value !== currentView || !currentView.dom.isConnected) return;
refreshStatementBoundaries(currentView.state);
if (statementBoundariesRefreshEffect) {
currentView.dispatch({ effects: statementBoundariesRefreshEffect.of(null) });
}
}, STATEMENT_BOUNDARIES_REFRESH_MS);
}
let editorScrollbarPointerCleanup: (() => void) | null = null;
let editorSelectionDragCleanup: (() => void) | null = null;
let editorSelectionDropCursorEl: HTMLDivElement | null = null;
const EDITOR_SCROLLBAR_POINTER_GUTTER_PX = 18;
const EDITOR_SELECTION_DRAG_THRESHOLD_PX = 6;
const tableNavigationHoverClass = "query-editor--table-navigation-hover";
const DBX_VIM_SAVE_EVENT = "dbx-vim-save";
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
function editorThemeAppearance() {
return editorThemeAppearanceFor(isDark.value ? "dark" : "light", themePalette.value, themePalette.value === "custom" ? activeCustomUiColors.value : undefined);
}
// Completion cache
let cachedTables: SqlCompletionTable[] = [];
const cachedCompletionObjectsByScope = new Map<string, SqlCompletionObject[]>();
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
const cachedPrefixColumnsByTable = new Map<string, SqlCompletionColumn[]>();
const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
// Hover tooltip shares the persisted object cache with the DDL and structure views.
let hoverSqlHighlighter: SqlHighlighter | null = null;
function sqlCompletionDialectOptions() {
return {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
editorState: view.value?.state,
};
}
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,
selectedSchema: props.schema,
referenceSchema: schema,
clientSessionId: props.clientSessionId,
});
}
function completionColumnRequestContext(reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
return {
clientSessionId: props.clientSessionId,
version: props.completionContextVersion,
tableQuoted: reference?.nameQuoted,
schemaQuoted: reference?.schemaQuoted,
};
}
async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">, prefix?: string) {
const requestedVersion = props.completionContextVersion;
const sessionScoped = usesOracleSessionCompletionColumns(schema);
let columns: SqlCompletionColumn[];
if (prefix && prefix.length >= 2 && (props.databaseType === "postgres" || props.databaseType === "mysql")) {
try {
columns = await connectionStore.listCompletionColumnsByPrefix(connectionId, database, table, schema, prefix, catalog, completionColumnRequestContext(reference));
} catch {
columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog);
}
} else {
columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog);
}
if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context");
return columns;
}
async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
const requestedVersion = props.completionContextVersion;
const sessionScoped = usesOracleSessionCompletionColumns(schema);
const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog);
if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context");
return columns;
}
const zoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => {
if (settingsStore.editorSettings.fontSize === fontSize) return;
settingsStore.updateEditorSettings({ fontSize });
});
const wheelZoomGestureGuard = createEditorWheelZoomGestureGuard();
const queryEditorAppearanceSettings = computed(() => {
const settings = settingsStore.editorSettings;
return {
fontFamily: settings.fontFamily,
fontSize: settings.fontSize,
theme: settings.theme,
customThemeColors: settings.customThemeColors,
customThemes: settings.customThemes,
activeCustomThemeId: settings.activeCustomThemeId,
wordWrap: settings.wordWrap,
vimModeEnabled: settings.vimModeEnabled,
autoCloseBrackets: settings.autoCloseBrackets,
showLineNumbers: settings.showLineNumbers,
shortcuts: settings.shortcuts,
showStatementRunButtons: settings.showStatementRunButtons,
};
});
function syncEditorFontCssVars(fontSize = liveFontSize.value, fontFamily = settingsStore.editorSettings.fontFamily) {
if (!editorRef.value) return;
editorRef.value.style.setProperty(EDITOR_FONT_SIZE_CSS_VAR, `${clampEditorFontSize(fontSize)}px`);
editorRef.value.style.setProperty(EDITOR_FONT_FAMILY_CSS_VAR, fontFamily);
}
// Diagnostics render on the editor surface, so their marker colors follow the
// resolved editor appearance (which already adapts to custom backgrounds) via
// editor-scoped variables instead of the app-level warning/destructive tokens.
function syncEditorDiagnosticCssVars() {
if (!editorRef.value) return;
const colors = editorDiagnosticColors(editorThemeAppearance());
editorRef.value.style.setProperty("--dbx-editor-diagnostic-error", colors.error);
editorRef.value.style.setProperty("--dbx-editor-diagnostic-warning", colors.warning);
}
let pendingFontReconfig: { size: number; family: string } | null = null;
let fontReconfigScheduled = false;
function reconfigureFontTheme(size: number, family: string) {
if (!fontThemeComp || !editorViewModule || !view.value) return;
view.value.dispatch({
effects: fontThemeComp.reconfigure(
editorFontTheme(editorViewModule.EditorView, size, family, {
fixedHeight: true,
scrollable: true,
}),
),
});
}
function scheduleFontThemeReconfig(size: number, family: string) {
pendingFontReconfig = { size, family };
if (fontReconfigScheduled) return;
fontReconfigScheduled = true;
requestAnimationFrame(() => {
fontReconfigScheduled = false;
const p = pendingFontReconfig;
if (p) {
pendingFontReconfig = null;
reconfigureFontTheme(p.size, p.family);
}
});
}
function applyLiveFontSize(size: number) {
const next = clampEditorFontSize(size);
if (liveFontSize.value === next) return;
liveFontSize.value = next;
syncEditorFontCssVars(next);
// Throttle compartment reconfiguration to at most once per animation
// frame so that CSS variable changes remain smooth on every wheel tick,
// while the CodeMirror measure → syncGutters path keeps gutters aligned.
scheduleFontThemeReconfig(next, settingsStore.editorSettings.fontFamily);
}
function scheduleFontSizeCommit(size: number) {
zoomCommitScheduler.schedule(size);
}
function onEditorGestureStart(event: EditorGestureEvent) {
event.preventDefault();
isGestureZooming.value = true;
gestureStartFontSize.value = liveFontSize.value;
}
function onEditorGestureChange(event: EditorGestureEvent) {
if (typeof event.scale !== "number") return;
event.preventDefault();
applyLiveFontSize(fontSizeFromGestureScale(gestureStartFontSize.value, event.scale));
}
function onEditorGestureEnd(event: Event) {
event.preventDefault();
isGestureZooming.value = false;
zoomCommitScheduler.flush(liveFontSize.value);
}
// Resolve the indent unit (one Tab worth) from the SQL formatter settings so
// the Tab key, multi-line indent and auto-indent all honor the configured width.
function editorIndentUnit(): string {
const { useTabs, tabWidth } = settingsStore.editorSettings.sqlFormatter;
return useTabs ? "\t" : " ".repeat(tabWidth);
}
function handleTab(view: EditorViewType): boolean {
if (isEditorComposing(view)) return false;
if (view.state.selection.ranges.some((range) => !range.empty)) {
// Snippet navigation selects the whole field, including after Shift+Tab.
// Give that active session priority over ordinary selected-text indentation.
return (codeMirrorNextSnippetField?.(view) ?? false) || (codeMirrorIndentMore?.(view) ?? false);
}
if (tabKeyAcceptsCompletion()) {
return acceptCompletionOrNextSnippetField(view) || performNormalTab(view);
}
return handleTabWithoutAcceptingCompletion(view) || performNormalTab(view);
}
// The Tab key is always wired up for indentation and snippet-field navigation,
// but it must only accept an open completion popup when the user's configured
// "accept completion" shortcut is actually Tab — otherwise a user who remapped
// that shortcut (e.g. to Enter) would find Tab silently accepting completions
// anyway, ignoring their setting (dbx#6236).
function tabKeyAcceptsCompletion(): boolean {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
return shortcutToCodeMirrorKey(shortcuts.acceptCompletion) === "Tab";
}
function handleTabWithoutAcceptingCompletion(view: EditorViewType): boolean {
if (codeMirrorCompletionStatus?.(view.state)) return false;
return codeMirrorNextSnippetField?.(view) ?? false;
}
function performNormalTab(view: EditorViewType): boolean {
const { state, dispatch } = view;
if (state.selection.ranges.some((range) => !range.empty)) return codeMirrorIndentMore?.(view) ?? false;
const sel = state.selection.main;
const line = state.doc.lineAt(sel.from);
const before = line.text.slice(0, sel.from - line.from);
if (/^\s*$/.test(before)) return codeMirrorIndentMore?.(view) ?? false;
dispatch(
state.update(state.replaceSelection(editorIndentUnit()), {
userEvent: "input.type",
}),
);
return true;
}
interface RequestExecuteOptions {
ignoreSelection?: boolean;
bypassPicker?: boolean;
openInNewResultTab?: boolean;
}
function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab = false) {
if (typeof source === "string" || source.editorViewportRequestId === undefined) {
executionViewportOwnership.cancelPendingRequest();
}
if (openInNewResultTab) {
emit("executeInNewResultTab", source);
} else {
emit("execute", source);
}
}
/**
* Captures a manual selection before a toolbar click can cause a platform
* focus transition. The snapshot is immutable, so downstream execution keeps
* the exact SQL and source offsets that were visible when the button was
* pressed.
*/
function captureExecutionSnapshot(): SqlExecutionSnapshot | undefined {
const currentView = view.value;
if (!currentView || currentView.state.selection.main.empty) return undefined;
return sqlExecutionSnapshotFromView(currentView);
}
function requestExecute(options: RequestExecuteOptions = {}) {
executionViewportOwnership.cancelPendingRequest();
const currentView = view.value;
if (!currentView) return false;
currentView.focus();
return requestExecuteFromView(currentView, currentView.state.selection.main.head, options);
}
function requestExecuteInNewResultTab() {
return requestExecute({ bypassPicker: true, openInNewResultTab: true });
}
function requestExecuteFromView(currentView: EditorViewType, cursorPos: number, options: RequestExecuteOptions = {}) {
const selection = currentView.state.selection.main;
if (!options.ignoreSelection && !selection.empty) {
// Has manual selection → execute directly, skip picker.
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
if (!supportsExecutionTargetPicker(props.databaseType)) {
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
// No selection → resolve the execution target, optionally via the picker.
const doc = currentView.state.doc.toString();
const parameterOptions = sqlStatementParameterOptions();
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType, parameterOptions);
const executeMode = settingsStore.editorSettings.executeMode;
if (candidates.length === 0) {
if (executeMode === "current") toast(t("editor.noExecutableStatementAtCursor"), 3000);
return true;
}
const candidate = executionCandidateForMode(candidates, executeMode, {
executeAllOnBlankLine: settingsStore.editorSettings.executeAllOnBlankLine,
});
if (!candidate) {
toast(t("editor.noExecutableStatementAtCursor"), 3000);
return true;
}
// The execution shortcut keeps executing the configured target (cursor/all) directly:
// it stays keyboard-driven and never pops the picker, which is reserved for click entry points.
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType, parameterOptions)) {
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab);
return true;
}
closePicker();
pickerCandidates.value = candidates;
pickerActiveIndex.value = 0;
pickerAnchor.value = executionPickerAnchor(currentView, cursorPos, candidates.length);
pickerVisible.value = true;
setPreviewRange({ from: candidates[0].from, to: candidates[0].to });
return true;
}
function sqlSingleQuoteKeyActionAt(state: EditorViewType["state"], position: number) {
return resolveSqlSingleQuoteKeyAction({
previousChar: position > 0 ? state.doc.sliceString(position - 1, position) : "",
nextChar: position < state.doc.length ? state.doc.sliceString(position, position + 1) : "",
autoCloseBrackets: settingsStore.editorSettings.autoCloseBrackets,
});
}
function handleSqlSingleQuote(view: EditorViewType): boolean {
const { state } = view;
const EditorSelection = codeMirrorEditorSelection;
if (state.readOnly || !EditorSelection) return false;
if (state.selection.ranges.some((range) => !range.empty)) return false;
if (state.selection.ranges.some((range) => sqlSingleQuoteKeyActionAt(state, range.from) === "pass")) return false;
const transaction = state.changeByRange((range) => {
const nextRange = EditorSelection.cursor(range.from + 1);
if (sqlSingleQuoteKeyActionAt(state, range.from) !== "insertEscapedQuote") return { range: nextRange };
return {
changes: { from: range.from, insert: "'" },
range: nextRange,
};
});
view.dispatch(transaction, { userEvent: "input.type" });
return true;
}
function executionPickerAnchor(currentView: EditorViewType, cursorPos: number, candidateCount: number): { left: number; top: number } | undefined {
const cursorRect = currentView.coordsAtPos(cursorPos);
const rootRect = editorRef.value?.getBoundingClientRect();
if (!cursorRect || !rootRect) return undefined;
const verticalGap = 8;
const pickerHeight = 40 + Math.max(1, candidateCount) * 36;
const verticalMargin = 12;
const left = rootRect.width / 2;
const cursorBottom = cursorRect.bottom - rootRect.top;
const maxTop = Math.max(verticalMargin, rootRect.height - pickerHeight - verticalMargin);
const top = Math.min(cursorBottom + verticalGap, maxTop);
return { left, top };
}
function setPreviewRange(range: { from: number; to: number } | null) {
if (!view.value || !setPreviewRangeEffect) return;
view.value.dispatch({
effects: setPreviewRangeEffect.of(range),
});
}
function setResultSourceRange(range: { from: number; to: number } | null) {
if (!view.value || !setResultSourceRangeEffect) return;
view.value.dispatch({
effects: setResultSourceRangeEffect.of(range),
});
}
function previewStatementRange(range: { from: number; to: number } | null) {
const currentView = view.value;
if (!range || !currentView || !editorViewModule || !setResultSourceRangeEffect) {
setResultSourceRange(null);
return;
}
const from = Math.max(0, Math.min(range.from, currentView.state.doc.length));
const to = Math.max(from, Math.min(range.to, currentView.state.doc.length));
if (from === to) {
setResultSourceRange(null);
return;
}
currentView.dispatch({
selection: { anchor: from },
effects: [setResultSourceRangeEffect.of({ from, to }), editorViewModule.EditorView.scrollIntoView(from, { y: "center" })],
});
}
function focusStatementRange(range: { from: number; to: number } | null) {
const currentView = view.value;
if (!range || !currentView || !editorViewModule || !setResultSourceRangeEffect) {
setResultSourceRange(null);
return;
}
const from = Math.max(0, Math.min(range.from, currentView.state.doc.length));
const to = Math.max(from, Math.min(range.to, currentView.state.doc.length));
if (from === to) return;
currentView.dispatch({
selection: { anchor: from, head: to },
effects: [setResultSourceRangeEffect.of({ from, to }), editorViewModule.EditorView.scrollIntoView(from, { y: "center" })],
});
currentView.focus();
}
function focusErrorPosition(offset: number) {
const currentView = view.value;
if (!currentView || !editorViewModule) return;
const errorPos = Math.max(0, Math.min(offset, currentView.state.doc.length));
currentView.dispatch({
selection: { anchor: errorPos },
effects: [editorViewModule.EditorView.scrollIntoView(errorPos, { y: "center" })],
});
currentView.focus();
}
function onPickerActiveIndexChange(index: number) {
pickerActiveIndex.value = index;
const candidate = pickerCandidates.value[index];
if (candidate) {
setPreviewRange({ from: candidate.from, to: candidate.to });
}
}
function onPickerConfirm(candidate: SqlExecutionCandidate) {
const currentView = view.value;
closePicker();
emit("execute", currentView ? sqlExecutionSnapshotForRange(currentView, candidate) : candidate.sql);
}
function closePicker() {
pickerVisible.value = false;
pickerAnchor.value = undefined;
setPreviewRange(null);
// Restore focus to the CodeMirror editor.
view.value?.focus();
}
function insertLineBelow(currentView: EditorViewType): boolean {
if (props.readOnly) return false;
const line = currentView.state.doc.lineAt(currentView.state.selection.main.head);
const indentation = line.text.match(/^\s*/)?.[0] ?? "";
const insertion = `\n${indentation}`;
const cursor = line.to + insertion.length;
currentView.dispatch({
changes: { from: line.to, to: line.to, insert: insertion },
selection: { anchor: cursor },
userEvent: "input.insertLineBelow",
});
return true;
}
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 = 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 = currentEditorDocText(currentView);
const selection = currentView.state.selection.main;
let cursor: number;
if (position != null) {
if (sql[position] === "*") {
cursor = position + 1;
} else if (sql[position - 1] === "*") {
cursor = position;
} else {
return null;
}
} else if (!selection.empty) {
if (currentView.state.sliceDoc(selection.from, selection.to) !== "*") return null;
cursor = selection.to;
} else if (sql[selection.head] === "*") {
cursor = selection.head + 1;
} else if (sql[selection.head - 1] === "*") {
cursor = selection.head;
} else {
return null;
}
const model = buildSqlSemanticModel(sql, cursor, sqlCompletionDialectOptions());
const intent = model.cursorIntent;
if (intent.kind !== "star" || intent.confidence !== "high" || intent.replacementRange.end - intent.replacementRange.start !== 1 || sql.slice(intent.replacementRange.start, intent.replacementRange.end) !== "*") return null;
if (position == null && !selection.empty && (selection.from !== intent.replacementRange.start || selection.to !== intent.replacementRange.end)) return null;
const starToken = model.tokens.find((token) => token.span.start === intent.replacementRange.start && token.span.end === intent.replacementRange.end && token.text === "*");
if (!starToken) return null;
let isSelectProjection = false;
for (let index = model.tokens.length - 1; index >= 0; index -= 1) {
const token = model.tokens[index];
if (!token || token.span.end > starToken.span.start || token.depth !== starToken.depth || token.kind !== "word") continue;
if (token.normalized === "from") return null;
if (token.normalized === "select") {
isSelectProjection = true;
break;
}
}
if (!isSelectProjection) return null;
const sources = sqlSemanticSelectStarTableSources(model);
if (sources.length === 0) return null;
const references = sources.map((source): SqlCompletionReferencedTable => {
const identifierParts = source.qualifiedName?.parts ?? [];
return {
// Use the semantic metadata target instead of reparsing the table token at
// its source span. The latter can resolve the alias token in aliased
// sources, causing column metadata requests for `tv` instead of
// `tVillage`.
name: source.metadataTarget?.table ?? source.name,
nameQuoted: !!identifierParts[identifierParts.length - 1]?.quote,
database: source.metadataTarget?.database,
schema: source.metadataTarget?.schema ?? source.qualifierParts[source.qualifierParts.length - 1],
schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,
alias: source.alias,
aliasSql: source.aliasSpan ? sql.slice(source.aliasSpan.start, source.aliasSpan.end) : source.alias,
};
});
const legacyContext = getEditorSqlCompletionContext(sql, cursor);
const context = sqlCompletionContextFromSemantic(model, legacyContext);
if (context.statementKind !== "select" || !context.onStar) return null;
return {
from: intent.replacementRange.start,
to: intent.replacementRange.end,
references,
context: { ...context, referencedTables: references },
qualifierSql: sqlSemanticSelectStarQualifierSql(model),
statementSql: model.statement.text,
allowResultColumnsFallback: references.length === 1 && model.rowSources.length === 1 && sqlSemanticSelectStarIsOnlyProjection(model),
};
}
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) {
contextObjectTarget.value = null;
return;
}
const sql = currentEditorDocText(currentView);
if (!props.connectionId || props.database == null) {
const candidate = queryTableCandidateAtSqlPosition({
connectionId: "",
database: props.database ?? "",
schema: props.schema,
databaseType: props.databaseType,
sql,
position: pos,
});
contextObjectTarget.value = candidate
? {
name: candidate.tableName,
database: candidate.database,
schema: candidate.schema,
}
: null;
return;
}
const parsedCandidate = queryTableCandidateAtSqlPosition({
connectionId: props.connectionId,
database: props.database,
schema: props.schema,
databaseType: props.databaseType,
sql,
position: pos,
});
if (!parsedCandidate) {
contextObjectTarget.value = null;
return;
}
// Right-click must stay instant: resolve from completion/tree caches and keep the legacy table fallback when metadata is unavailable.
const candidate = resolveQueryContextCandidateDatabase(parsedCandidate, connectionStore.lookupLocalCompletionDatabases(parsedCandidate.connectionId, parsedCandidate.database, MAX_COMPLETION_TABLES));
const tables = connectionStore.lookupLocalCompletionTables(candidate.connectionId, candidate.database, candidate.tableName, MAX_COMPLETION_TABLES, candidate.schema, props.catalog);
contextObjectTarget.value = resolveQueryContextObjectTarget(candidate, tables);
}
function focusEditor() {
view.value?.focus();
}
function clearTableNavigationHover() {
editorRef.value?.classList.remove(tableNavigationHoverClass);
}
function tableNavigationIdentifierAt(currentView: EditorViewType, event: MouseEvent): string | null {
if (!props.connectionId || props.database == null) return null;
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null) return null;
// Identifier extraction only looks around `pos`; slice a small window so
// modifier-held mouse moves never materialize the whole document string.
const windowFrom = Math.max(0, pos - 1024);
const doc = currentView.state.doc;
const extracted = extractIdentifierDetailsAt(doc.sliceString(windowFrom, Math.min(doc.length, pos + 1024)), pos - windowFrom);
if (!extracted || (!extracted.quoted && isSqlKeyword(extracted.identifier))) return null;
return extracted.identifier;
}
function updateTableNavigationHover(currentView: EditorViewType, event: MouseEvent) {
if (!usesQueryEditorObjectNavigationModifier(event)) {
clearTableNavigationHover();
return false;
}
const identifier = tableNavigationIdentifierAt(currentView, event);
editorRef.value?.classList.toggle(tableNavigationHoverClass, !!identifier);
return !!identifier;
}
function clearTableNavigationHoverOnModifierRelease(event: KeyboardEvent) {
if (!usesQueryEditorObjectNavigationModifier(event)) clearTableNavigationHover();
}
function isEditorScrollbarPointerEvent(currentView: EditorViewType, event: MouseEvent) {
if (event.button !== 0) return false;
const scrollDOM = currentView.scrollDOM;
const rect = scrollDOM.getBoundingClientRect();
const hasVerticalScrollbar = scrollDOM.scrollHeight > scrollDOM.clientHeight + 1;
const hasHorizontalScrollbar = scrollDOM.scrollWidth > scrollDOM.clientWidth + 1;
const verticalGutter = Math.max(scrollDOM.offsetWidth - scrollDOM.clientWidth, EDITOR_SCROLLBAR_POINTER_GUTTER_PX);
const horizontalGutter = Math.max(scrollDOM.offsetHeight - scrollDOM.clientHeight, EDITOR_SCROLLBAR_POINTER_GUTTER_PX);
const inVerticalScrollbar = hasVerticalScrollbar && event.clientX >= rect.right - verticalGutter && event.clientX <= rect.right;
const inHorizontalScrollbar = hasHorizontalScrollbar && event.clientY >= rect.bottom - horizontalGutter && event.clientY <= rect.bottom;
return inVerticalScrollbar || inHorizontalScrollbar;
}
function registerEditorScrollbarPointerGuard(currentView: EditorViewType) {
editorScrollbarPointerCleanup?.();
const onPointerDown = (event: MouseEvent) => {
if (!isEditorScrollbarPointerEvent(currentView, event)) return;
clearTableNavigationHover();
event.stopPropagation();
if (isTauriRuntime() && isMacOS() && !currentView.contentDOM.contains(event.target as Node | null)) {
event.preventDefault();
}
};
currentView.scrollDOM.addEventListener("mousedown", onPointerDown, true);
editorScrollbarPointerCleanup = () => {
currentView.scrollDOM.removeEventListener("mousedown", onPointerDown, true);
editorScrollbarPointerCleanup = null;
};
}
function selectedRangeAtPointer(currentView: EditorViewType, event: MouseEvent) {
if (props.readOnly || event.button !== 0) return null;
if (!currentView.contentDOM.contains(event.target as Node | null)) return null;
const range = currentView.state.selection.main;
if (range.empty) return null;
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (pos == null || pos < range.from || pos > range.to) return null;
return {
from: range.from,
to: range.to,
text: currentView.state.sliceDoc(range.from, range.to),
};
}
function moveOrCopySelectionToPointer(currentView: EditorViewType, selection: { from: number; to: number; text: string }, event: MouseEvent) {
const dropPos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (dropPos == null) return false;
const copy = event.ctrlKey || event.metaKey;
if (!copy && dropPos >= selection.from && dropPos <= selection.to) return true;
const insert = { from: dropPos, insert: selection.text };
const changes = copy ? currentView.state.changes(insert) : currentView.state.changes([{ from: selection.from, to: selection.to }, insert]);
currentView.dispatch({
changes,
selection: {
anchor: changes.mapPos(dropPos, -1),
head: changes.mapPos(dropPos, 1),
},
scrollIntoView: true,
userEvent: copy ? "input.drop" : "move.drop",
});
currentView.focus();
return true;
}
function hideEditorSelectionDropCursor() {
editorSelectionDropCursorEl?.remove();
editorSelectionDropCursorEl = null;
}
function updateEditorSelectionDropCursor(currentView: EditorViewType, event: MouseEvent) {
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (pos == null) {
hideEditorSelectionDropCursor();
return;
}
const coords = currentView.coordsAtPos(pos);
if (!coords) {
hideEditorSelectionDropCursor();
return;
}
const ownerDocument = currentView.dom.ownerDocument;
const cursor = editorSelectionDropCursorEl ?? ownerDocument.createElement("div");
if (!editorSelectionDropCursorEl) {
cursor.setAttribute("aria-hidden", "true");
cursor.className = "dbx-editor-selection-drop-cursor";
// Use a fixed overlay instead of CodeMirror's internal drop cursor layer so
// the marker stays visible above selection layers, themes, and scrollers.
cursor.style.position = "fixed";
cursor.style.zIndex = "2147483647";
cursor.style.width = "2px";
cursor.style.pointerEvents = "none";
cursor.style.backgroundImage = "repeating-linear-gradient(to bottom, #e879f9 0 4px, transparent 4px 7px)";
cursor.style.filter = "drop-shadow(0 0 1px rgba(0, 0, 0, 0.7))";
ownerDocument.body.appendChild(cursor);
editorSelectionDropCursorEl = cursor;
}
cursor.style.left = `${Math.round(coords.left) - 1}px`;
cursor.style.top = `${Math.round(coords.top)}px`;
cursor.style.height = `${Math.max(16, Math.round(coords.bottom - coords.top))}px`;
}
function startEditorSelectionDrag(currentView: EditorViewType, event: MouseEvent): boolean {
if (!startsQueryEditorSelectionDrag(event)) return false;
const selection = selectedRangeAtPointer(currentView, event);
if (!selection) return false;
event.preventDefault();
event.stopPropagation();
if (!event.ctrlKey && !event.metaKey) {
emit("closeColumnPanel");
}
editorSelectionDragCleanup?.();
const startX = event.clientX;
const startY = event.clientY;
let dragging = false;
const cleanup = () => {
currentView.contentDOM.ownerDocument.removeEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.removeEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.removeEventListener("keydown", onKeyDown, true);
hideEditorSelectionDropCursor();
editorSelectionDragCleanup = null;
};
const onMove = (moveEvent: MouseEvent) => {
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY);
if (!dragging && distance < EDITOR_SELECTION_DRAG_THRESHOLD_PX) return;
dragging = true;
if (moveEvent.ctrlKey || moveEvent.metaKey) {
currentView.contentDOM.style.cursor = "copy";
} else {
currentView.contentDOM.style.cursor = "move";
}
updateEditorSelectionDropCursor(currentView, moveEvent);
moveEvent.preventDefault();
moveEvent.stopImmediatePropagation();
};
const onUp = (upEvent: MouseEvent) => {
cleanup();
currentView.contentDOM.style.cursor = "";
upEvent.preventDefault();
upEvent.stopImmediatePropagation();
if (dragging) {
moveOrCopySelectionToPointer(currentView, selection, upEvent);
return;
}
const pos = currentView.posAtCoords({
x: upEvent.clientX,
y: upEvent.clientY,
});
if (pos != null) {
currentView.dispatch({
selection: { anchor: pos },
userEvent: "select.pointer",
});
currentView.focus();
}
};
const onKeyDown = (keyEvent: KeyboardEvent) => {
if (keyEvent.key !== "Escape") return;
cleanup();
currentView.contentDOM.style.cursor = "";
keyEvent.preventDefault();
keyEvent.stopImmediatePropagation();
};
currentView.contentDOM.ownerDocument.addEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.addEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.addEventListener("keydown", onKeyDown, true);
editorSelectionDragCleanup = () => {
cleanup();
currentView.contentDOM.style.cursor = "";
};
return true;
}
function executeFromContextMenu() {
if (!canExecuteContextSql.value) return;
requestExecute();
focusEditor();
}
function executeInNewResultTabFromContextMenu() {
if (!canExecuteContextSql.value) return;
requestExecuteInNewResultTab();
focusEditor();
}
function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
const sql = executableSql.value;
if (!sql.trim()) return;
emit("exportQuery", { sql, format, columnComments: undefined });
}
// 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。
// 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。
function resolvePreviewDmlCandidate(position?: number): string {
const currentView = view.value;
if (!currentView) return "";
const selection = currentView.state.selection.main;
if (!selection.empty) {
const text = currentView.state.sliceDoc(selection.from, selection.to);
return looksLikeDmlStatement(text) ? text : "";
}
const cursorPos = position ?? selection.head;
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,作为新结果标签执行(干跑,不写库)。
async function requestPreviewChanges(stackSql?: string) {
let sql = (stackSql ?? "").trim();
// 永远只预览“单条语句”:禁用整篇文档回退(那会包含多条语句)。
if (!sql) sql = resolvePreviewDmlCandidate();
if (!sql) {
toast(t("editor.previewChangesNoStatement"), 3000);
return false;
}
try {
const identifierQuote = props.connectionId ? connectionStore.connectionIdentifierQuote?.(props.connectionId) : undefined;
// 第一次:生成基础预览 SELECT,并拿到目标表引用。
let preview = await api.buildDmlChangePreviewSql({ sql, databaseType: props.databaseType, identifierQuote });
// 单表 UPDATE:拉取目标表列元数据,让「新值」列紧跟其原值列(交错展开)。
if (preview.tables.length === 1 && props.connectionId && props.database) {
const tableRef = preview.tables[0];
if (tableRef.table) {
const columns = await api
.getColumns(props.connectionId, props.database, tableRef.schema ?? "", tableRef.table, tableRef.catalog)
.then((infos) => infos.map((column) => column.name))
.catch(() => undefined);
if (columns?.length) {
preview = await api.buildDmlChangePreviewSql({ sql, databaseType: props.databaseType, identifierQuote, columns });
}
}
}
// 前置注释标注干跑预览(引擎会忽略注释),并在新结果标签中展示受影响行 + 新值列。
emit("executeInNewResultTab", `/* ${t("editor.previewChangesComment", { operation: preview.operation })} */\n${preview.sql}`);
return true;
} catch (error: any) {
// http 层抛 BackendErrorException(Error),tauri 层拒绝时是 String。
const message = error instanceof Error ? error.message : typeof error === "string" ? error : t("editor.previewChangesFailed");
toast(message, 4000);
return false;
}
}
async function copySelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
await copyToClipboard(selectedSql.value);
toast(t("grid.copied"));
focusEditor();
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
// 富文本复制:写入 text/html + text/plain,粘贴到邮件/Word/IM 时保留语法高亮。
async function copySelectedSqlAsRichTextFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
await copySqlAsRichText(selectedSql.value);
toast(t("grid.copied"));
focusEditor();
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function cutSelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
const currentView = view.value;
if (!currentView) return;
try {
await copyToClipboard(selectedSql.value);
// 剪切:复制后删除选中内容
const selection = currentView.state.selection.main;
if (!selection.empty) {
currentView.dispatch({
changes: { from: selection.from, to: selection.to },
selection: { anchor: selection.from, head: selection.from },
scrollIntoView: true,
userEvent: "input.cut",
});
}
toast(t("grid.cut"));
focusEditor();
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function pasteClipboardSqlFromContextMenu() {
if (props.readOnly) return;
const currentView = view.value;
if (!currentView) return;
try {
const text = await readTextFromClipboard();
if (!text) return;
const selection = currentView.state.selection.main;
// 粘贴:替换选中内容或在光标处插入
currentView.dispatch({
changes: { from: selection.from, to: selection.to, insert: text },
selection: { anchor: selection.from + text.length, head: selection.from + text.length },
scrollIntoView: true,
userEvent: "input.paste",
});
focusEditor();
} catch (e: any) {
toast(t("editor.contextMenu.pasteClipboardReadFailed", { message: e?.message || String(e) }), 5000);
}
}
function toggleCommentFromContextMenu() {
const currentView = view.value;
if (!currentView || props.readOnly) return;
codeMirrorToggleLineComment?.(currentView);
focusEditor();
}
function toggleBlockCommentFromContextMenu() {
const currentView = view.value;
if (!currentView || props.readOnly || !supportsQueryEditorBlockComments(props.databaseType)) return;
codeMirrorToggleBlockComment?.(currentView);
focusEditor();
}
function selectAllSqlFromContextMenu() {
const currentView = view.value;
if (!currentView) return;
currentView.dispatch({
selection: { anchor: 0, head: currentView.state.doc.length },
scrollIntoView: true,
});
focusEditor();
}
function convertSelectedSqlCase(mode: SqlSelectionCaseMode): boolean {
const currentView = view.value;
const EditorSelection = codeMirrorEditorSelection;
if (!currentView || !EditorSelection) return false;
const state = currentView.state;
const documentText = state.doc.toString();
const transaction = state.changeByRange((range) => {
if (range.empty) return { range };
const convertedText = convertSqlSelectionCase(documentText, { from: range.from, to: range.to }, mode, sqlBehaviorDialect());
return {
changes: { from: range.from, to: range.to, insert: convertedText },
range: EditorSelection.range(range.from, range.from + convertedText.length),
};
});
if (!transaction.changes.empty) {
currentView.dispatch({
...transaction,
scrollIntoView: true,
userEvent: "input",
});
focusEditor();
return true;
}
return false;
}
function convertSelectedNamingStyle(): boolean {
const currentView = view.value;
const EditorSelection = codeMirrorEditorSelection;
if (!currentView || !EditorSelection) return false;
const state = currentView.state;
const transaction = state.changeByRange((range) => {
if (range.empty) return { range };
const selectedText = state.doc.sliceString(range.from, range.to);
const result = convertToNextNamingStyle(selectedText);
return {
changes: { from: range.from, to: range.to, insert: result.text },
range: EditorSelection.range(range.from, range.from + result.text.length),
};
});
if (!transaction.changes.empty) {
currentView.dispatch({
...transaction,
scrollIntoView: true,
userEvent: "input",
});
focusEditor();
return true;
}
return false;
}
async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
if (!supportsSqlInListPaste(props.databaseType)) return false;
if (props.readOnly) return false;
const currentView = view.value;
if (!currentView) return false;
const selection = currentView.state.selection.main;
const selectedSource = selection.empty ? "" : currentView.state.sliceDoc(selection.from, selection.to);
let source = selectedSource;
if (!source) {
try {
source = await readTextFromClipboard();
} catch (e: any) {
toast(
t("editor.exPasteClipboardReadFailed", {
message: e?.message || String(e),
}),
5000,
);
focusEditor();
return false;
}
}
const result = buildSqlInConditionFromPasteSource(source, settingsStore.editorSettings.sqlFormatter.keywordCase);
if (!result.ok) {
const key = result.reason === "too-large" ? "editor.exPasteTooLarge" : result.reason === "too-many-values" ? "editor.exPasteTooManyValues" : result.reason === "not-list" ? "editor.exPasteNotList" : "editor.exPasteNoValues";
toast(t(key, { limit: result.limit ?? 0 }), 5000);
focusEditor();
return false;
}
if (view.value !== currentView || props.readOnly) return false;
const state = currentView.state;
const line = state.doc.lineAt(selection.from);
const prefix = state.sliceDoc(line.from, selection.from);
const insertText = insertTextForSqlInCondition(result.sql, prefix);
currentView.dispatch({
changes: { from: selection.from, to: selection.to, insert: insertText },
selection: { anchor: selection.from + insertText.length },
scrollIntoView: true,
userEvent: "input.paste",
});
currentView.focus();
toast(t("editor.exPastePasted", { count: result.valueCount }), 2000);
return true;
}
// See queryEditorPasteCaretResync.ts for why this nudge is needed (WebKit-only caret bug).
function resyncCaretAfterPaste(view: EditorViewType) {
const EditorSelection = codeMirrorEditorSelection;
if (!EditorSelection) return;
const selection = view.state.selection;
const pos = selection.main.head;
const nudged = computePasteCaretResyncTarget(selection, view.state.doc.length);
if (nudged === null) return;
requestAnimationFrame(() => {
if (!view.dom.isConnected || view.state.selection.ranges.length !== 1 || view.state.selection.main.head !== pos || !view.state.selection.main.empty) return;
view.dispatch({ selection: EditorSelection.cursor(nudged) });
view.dispatch({ selection: EditorSelection.cursor(pos) });
});
}
function recoverLargeTauriPaste(event: ClipboardEvent, currentView: EditorViewType): boolean {
const eventText = event.clipboardData?.getData("text/plain") ?? "";
if (props.readOnly || currentView.state.selection.ranges.length !== 1 || !shouldRecoverLargeTauriPaste(eventText, isTauriRuntime())) return false;
event.preventDefault();
const selection = currentView.state.selection.main;
const insertedText = normalizeQueryEditorPasteText(eventText);
const insertedFrom = selection.from;
const insertedTo = insertedFrom + insertedText.length;
const pasteStartedAt = Date.now();
currentView.dispatch({
changes: { from: selection.from, to: selection.to, insert: insertedText },
selection: { anchor: insertedTo },
scrollIntoView: true,
// CodeMirror only joins input.type history events; keep one timestamp so delayed recovery is one undo step.
annotations: Transaction.time.of(pasteStartedAt),
userEvent: LARGE_PASTE_HISTORY_USER_EVENT,
});
void readTextFromClipboard()
.then((nativeText) => {
const suffix = recoverableNativePasteSuffix(eventText, nativeText);
if (!suffix || props.readOnly || view.value !== currentView) return;
if (currentView.state.doc.sliceString(insertedFrom, insertedTo) !== insertedText) return;
const currentSelection = currentView.state.selection.main;
const selectionRemainedAtPasteEnd = currentSelection.empty && currentSelection.head === insertedTo;
currentView.dispatch({
changes: { from: insertedTo, insert: suffix },
...(selectionRemainedAtPasteEnd ? { selection: { anchor: insertedTo + suffix.length } } : {}),
scrollIntoView: selectionRemainedAtPasteEnd,
annotations: Transaction.time.of(pasteStartedAt),
userEvent: LARGE_PASTE_HISTORY_USER_EVENT,
});
})
.catch(() => {});
return true;
}
function deleteEmptyLines() {
const currentView = view.value;
if (!currentView || props.readOnly) return;
const state = currentView.state;
const selection = state.selection.main;
const changes = blankLineDeletionChanges(state.doc, selection);
if (changes.length === 0) return;
currentView.dispatch({
changes,
scrollIntoView: true,
});
focusEditor();
}
function openFindReplaceFromContextMenu() {
openSearch();
}
function emitContextObjectAction(action: QueryContextObjectAction) {
if (!contextObjectTarget.value) return;
const route = queryContextObjectRoute(action, contextObjectTarget.value);
switch (route.event) {
case "viewTableData":
emit("viewTableData", route.payload[0]);
break;
case "editTableStructure":
emit("editTableStructure", route.payload[0]);
break;
case "openObjectSource":
emit("openObjectSource", route.payload[0], route.payload[1]);
break;
case "viewTableDdl":
emit("viewTableDdl", route.payload[0]);
break;
}
focusEditor();
}
function contextObjectMenuItem(action: QueryContextObjectAction): ContextMenuItem {
const disabled = !contextObjectTarget.value;
switch (action) {
case "view-data":
return {
label: t("contextMenu.viewData"),
action: () => emitContextObjectAction(action),
disabled,
icon: Table2,
};
case "edit-table-structure":
return {
label: t("contextMenu.editStructure"),
action: () => emitContextObjectAction(action),
disabled,
icon: PencilRuler,
};
case "edit-view":
return {
label: t("contextMenu.editView"),
action: () => emitContextObjectAction(action),
disabled,
icon: Pencil,
};
case "view-source":
return {
label: t("contextMenu.viewSource"),
action: () => emitContextObjectAction(action),
disabled,
icon: Code2,
};
case "view-ddl":
return {
label: t("contextMenu.viewDdl"),
action: () => emitContextObjectAction(action),
disabled,
icon: FileCode,
};
}
}
function executableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) {
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
return executableStatementRangeStartingAtLine(executableStatementRangeCache, lineFrom);
}
function currentExecutableStatementRange(currentView: EditorViewType): SqlTextRange | null {
if (!supportsExecutionTargetPicker(props.databaseType) && props.databaseType !== "mongodb") return null;
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
return executableStatementRangeAtCursor(executableStatementRangeCache, currentView.state.selection.main.head);
}
function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean {
if (!(event instanceof MouseEvent) || event.button !== 0) return false;
const statementRange = executableStatementRangeStartingAt(currentView, line.from);
if (!statementRange) return false;
event.preventDefault();
event.stopPropagation();
// Gutter play is always scoped to the statement/command for that line, even
// when the main editor execute action would run the full document.
const editorViewportRequestId = executionViewportOwnership.beginRequest();
emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId });
// 不主动聚焦编辑器,否则 CodeMirror 会把屏幕滚回之前的光标位置。
// currentView.focus();
return true;
}
function selectSqlLineFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean {
if (!(event instanceof MouseEvent) || event.button !== 0) return false;
event.preventDefault();
currentView.dispatch({
selection: { anchor: line.from, head: line.to },
scrollIntoView: true,
userEvent: "select.pointer",
});
currentView.focus();
return true;
}
const contextMenuItems = computed<ContextMenuItem[]>(() => {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
// The menu closes before running its action, so retain this right-click's
// resolved target instead of reading state after the close handler runs.
const starExpansionTarget = selectStarExpansionTarget.value;
return [
...(props.hideExecutionControls
? []
: [
{
label: executeContextMenuLabel.value,
action: executeFromContextMenu,
disabled: !canExecuteContextSql.value,
icon: Play,
shortcut: shortcuts.executeSql,
},
{
label: t("settings.shortcutExecuteSqlInNewResultTab"),
action: executeInNewResultTabFromContextMenu,
disabled: !canExecuteContextSql.value,
icon: Play,
shortcut: shortcuts.executeSqlInNewResultTab,
},
{
label: t("editor.previewChanges"),
action: () => void requestPreviewChanges(previewContextSql.value),
disabled: !previewContextSql.value,
icon: Eye,
},
{
label: t("editor.contextMenu.export"),
icon: Download,
disabled: !canExecuteContextSql.value,
children: [
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "CSV" }), action: () => exportQueryFromContextMenu("csv") },
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "XLSX" }), action: () => exportQueryFromContextMenu("xlsx") },
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "TXT" }), action: () => exportQueryFromContextMenu("txt") },
],
},
]),
...queryContextObjectActions(contextObjectTarget.value?.type).map(contextObjectMenuItem),
{
label: t("editor.contextMenu.expandSelectStar"),
action: () => void expandSelectStar(starExpansionTarget),
disabled: !starExpansionTarget,
icon: Table2,
shortcut: shortcuts.expandSelectStar,
},
{ label: "", separator: true },
{
label: t("editor.contextMenu.commentSelection"),
action: toggleCommentFromContextMenu,
disabled: props.readOnly || !canCopySelectedSql.value,
icon: MessageSquareText,
shortcut: shortcuts.toggleLineComment,
},
{
label: t("editor.contextMenu.blockCommentSelection"),
action: toggleBlockCommentFromContextMenu,
disabled: props.readOnly || !canCopySelectedSql.value || !supportsQueryEditorBlockComments(props.databaseType),
icon: MessageSquareText,
shortcut: shortcuts.toggleBlockComment,
},
{
label: t("editor.contextMenu.formatSelectionSql"),
action: () => void formatCurrentSql(),
disabled: props.readOnly || !canCopySelectedSql.value || !canFormatSqlForDatabaseType(props.databaseType),
icon: AlignLeft,
shortcut: shortcuts.formatSql,
},
{
label: t("editor.contextMenu.compressSelectionSql"),
action: compressCurrentSql,
disabled: props.readOnly || !canCopySelectedSql.value,
icon: Minimize2,
},
{
label: t("editor.contextMenu.copySelection"),
action: copySelectedSqlFromContextMenu,
disabled: !canCopySelectedSql.value,
icon: Copy,
shortcut: "Mod+C",
},
{
label: t("editor.contextMenu.copySelectionAsRichText"),
action: copySelectedSqlAsRichTextFromContextMenu,
disabled: !canCopySelectedSql.value,
icon: Highlighter,
},
{
label: t("editor.contextMenu.screenshotSelection"),
action: () => {
if (selectedSql.value.trim()) {
codeSnapshotSource.value = { code: selectedSql.value, lang: "sql" };
codeSnapshotOpen.value = true;
}
},
disabled: !canCopySelectedSql.value,
icon: Camera,
},
{
label: t("editor.contextMenu.cutSelection"),
action: cutSelectedSqlFromContextMenu,
disabled: !canCopySelectedSql.value || props.readOnly,
icon: Scissors,
shortcut: "Mod+X",
},
{
label: t("editor.contextMenu.pasteFromClipboard"),
action: pasteClipboardSqlFromContextMenu,
disabled: props.readOnly,
icon: ClipboardPaste,
shortcut: "Mod+V",
},
{
label: t("editor.contextMenu.sendToAi"),
action: () => {
if (selectedSql.value.trim()) emit("sendSelectionToAi", selectedSql.value);
},
disabled: !canCopySelectedSql.value,
icon: Sparkles,
shortcut: shortcuts.sendSelectionToAi,
},
{
label: t("editor.contextMenu.uppercaseSelection"),
action: () => convertSelectedSqlCase("upper"),
disabled: !canCopySelectedSql.value,
icon: CaseUpper,
shortcut: shortcuts.uppercaseSelection,
},
{
label: t("editor.contextMenu.lowercaseSelection"),
action: () => convertSelectedSqlCase("lower"),
disabled: !canCopySelectedSql.value,
icon: CaseLower,
shortcut: shortcuts.lowercaseSelection,
},
{
label: t("editor.contextMenu.convertNamingStyle"),
action: convertSelectedNamingStyle,
disabled: !canCopySelectedSql.value,
icon: CaseSensitive,
shortcut: shortcuts.convertNamingStyle,
},
{
label: t("editor.contextMenu.delimitedList"),
action: openDelimitedListDialog,
disabled: props.readOnly || !canCopySelectedSql.value,
icon: List,
},
{
label: t("editor.contextMenu.addNextSelectionOccurrence"),
action: addNextSelectionOccurrenceFromContextMenu,
icon: TextSelect,
shortcut: shortcuts.addNextSelectionOccurrence,
},
{
label: t("editor.contextMenu.selectAllSelectionOccurrences"),
action: selectAllSelectionOccurrencesFromContextMenu,
icon: TextSelect,
shortcut: shortcuts.selectAllSelectionOccurrences,
},
{ label: "", separator: true },
{
label: t("editor.contextMenu.findReplace"),
action: openFindReplaceFromContextMenu,
icon: Search,
shortcut: shortcuts.find,
},
{
label: t("editor.contextMenu.deleteEmptyLines"),
action: deleteEmptyLines,
disabled: props.readOnly,
icon: Trash2,
},
{ label: "", separator: true },
{
label: t("editor.contextMenu.selectAll"),
action: selectAllSqlFromContextMenu,
icon: TextSelect,
shortcut: shortcuts.selectAll,
},
];
});
function currentContextMenuItems(): ContextMenuItem[] {
return contextMenuItems.value;
}
function handleSqlIntentionActions(currentView: EditorViewType): boolean {
if (props.readOnly) return false;
try {
const sql = currentView.state.doc.toString();
const sel = currentView.state.selection.main;
const actions = analyzeIntentionActions({
sql,
cursor: sel.head,
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
selection: sel.from !== sel.to ? { from: sel.from, to: sel.to } : undefined,
});
if (actions.length === 0) return false;
// 计算光标视口坐标,用于定位弹出菜单
const coords = currentView.coordsAtPos(sel.head);
if (!coords) return false;
// 显示弹出菜单(参考 DataGrip Alt+Enter 意图操作弹出菜单)
intentionPopup.value = {
visible: true,
actions,
position: { x: coords.right + 8, y: coords.bottom + 4 },
selectedIndex: 0,
};
document.addEventListener("keydown", onIntentionPopupKey);
return true;
} catch (err) {
console.error("[SQL Intention] error:", err);
return false;
}
}
function runSqlShortcutAction(action: ReturnType<typeof enabledSqlShortcutActions>[number], currentView: EditorViewType, event?: KeyboardEvent): boolean {
// Non-SQL editors (Redis / Mongo / ES / …) keep their own command languages; do not inject SELECT templates.
if (queryEditorSelectionLanguage() !== "sql") return false;
if (shouldBlockExecutionShortcut(event, currentView)) return true;
if (props.readOnly) return true;
const { from, to, empty, head } = currentView.state.selection.main;
let selected: string | null = null;
if (!empty) {
selected = currentView.state.sliceDoc(from, to).trim() || null;
} else {
const line = currentView.state.doc.lineAt(head);
const localHead = Math.min(Math.max(0, head - line.from), line.text.length);
selected = resolveSqlShortcutTableToken(line.text, { from: localHead, to: localHead, empty: true, head: localHead });
}
if (!selected) return false;
const sql = buildSqlShortcutExecutionSql(action, selected, props.databaseType);
emitExecutionRequest(sql);
return true;
}
function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
const Prec = codeMirrorPrec;
const binding = (shortcut: string, run: (view: EditorViewType) => boolean) => (shortcut ? [{ key: shortcutToCodeMirrorKey(shortcut), preventDefault: true, run }] : []);
// Keep the shortcut on the shared execution-mode path (selection priority + configured cursor/all target),
// but bypass the picker so the keyboard shortcut always executes directly instead of popping a dialog.
const executeBindings = props.hideExecutionControls
? []
: createQueryEditorExecutionShortcutBindings(
shortcuts.executeSql,
() => requestExecute({ bypassPicker: true }),
(currentView) => shouldBlockExecutionShortcut(undefined, currentView),
);
const executeInNewResultTabBindings = props.hideExecutionControls ? [] : createQueryEditorExecutionShortcutBindings(shortcuts.executeSqlInNewResultTab, requestExecuteInNewResultTab, (currentView) => shouldBlockExecutionShortcut(undefined, currentView));
const explainBindings = props.enableExplainShortcut
? createQueryEditorExecutionShortcutBindings(
shortcuts.explainSql,
() => {
emit("explain");
return true;
},
(currentView) => shouldBlockExecutionShortcut(undefined, currentView),
() => !!props.canExplain,
)
: [];
const replaceShortcutBindings = createQueryEditorReplaceShortcutBindings(shortcuts.replace, openReplace);
const replaceShortcutHandler = createQueryEditorReplaceShortcutHandler({
shortcut: shortcuts.replace,
openReplace,
isReadOnly: () => !!props.readOnly,
});
const sqlShortcutActions = enabledSqlShortcutActions(settingsStore.editorSettings.sqlShortcuts);
const sqlShortcutKeymapBindings = uniqueSqlShortcutBindings(sqlShortcutActions).filter((shortcut) => !isCharacterProducingShortcut(shortcut));
// Do not set preventDefault: true — when run returns false (wrong DB scope / no table token),
// CodeMirror must not swallow the browser default. Returning true still prevents default.
const sqlShortcutBindings = sqlShortcutKeymapBindings.flatMap((shortcut) =>
shortcut
? [
{
key: shortcutToCodeMirrorKey(shortcut),
run: (currentView: EditorViewType) => {
const action = resolveSqlShortcutForDatabase(settingsStore.editorSettings.sqlShortcuts, shortcut, props.databaseType);
if (!action) return false;
return runSqlShortcutAction(action, currentView);
},
},
]
: [],
);
const sqlShortcutDomHandler = createQueryEditorSqlShortcutDomHandler(
() => settingsStore.editorSettings.sqlShortcuts,
(action, currentView, event) => runSqlShortcutAction(action, currentView, event),
() => props.databaseType,
);
const combinedDomKeydownHandler = (event: KeyboardEvent, view: EditorViewType) => {
if (replaceShortcutHandler(event)) return true;
return sqlShortcutDomHandler(event, view);
};
const moveCompletion = (view: EditorViewType, forward: boolean, by?: "page") => codeMirrorMoveCompletionSelection?.(forward, by)?.(view) ?? false;
return [
editorViewModule
? (Prec?.high(
editorViewModule.EditorView.domEventHandlers({
keydown: combinedDomKeydownHandler,
}),
) ?? [])
: [],
Prec?.highest(
codeMirrorKeymap.of([
{ key: "ArrowDown", run: (view) => moveCompletion(view, true) },
{ key: "ArrowUp", run: (view) => moveCompletion(view, false) },
{ key: "PageDown", run: (view) => moveCompletion(view, true, "page") },
{ key: "PageUp", run: (view) => moveCompletion(view, false, "page") },
]),
) ?? [],
Prec?.high(
codeMirrorKeymap.of([
{
key: "Enter",
run: handleEnter,
},
{
key: "Space",
run: toggleSelectedBatchColumnSelection,
},
...binding(shortcuts.find, openSearch),
...replaceShortcutBindings,
...executeInNewResultTabBindings,
...executeBindings,
...explainBindings,
...binding(shortcuts.saveSql, () => {
emit("save");
return true;
}),
...binding(shortcuts.formatSql, () => {
void formatCurrentSql();
return true;
}),
...binding(shortcuts.expandSelectStar, (currentView) => {
const target = selectStarExpansionTargetForView(currentView);
if (!target) return false;
void expandSelectStar(target);
return true;
}),
...binding(shortcuts.indentMore, (view) => codeMirrorIndentMore?.(view) ?? false),
...binding(shortcuts.indentLess, (view) => codeMirrorIndentLess?.(view) ?? false),
...binding(shortcuts.insertLineBelow, insertLineBelow),
...binding(shortcuts.joinLines, joinQueryEditorLines),
...binding(shortcuts.duplicateLine, (view) => codeMirrorCopyLineDown?.(view) ?? false),
...binding(shortcuts.deleteLine, (view) => codeMirrorDeleteLine?.(view) ?? false),
...binding(shortcuts.moveLineUp, (view) => codeMirrorMoveLineUp?.(view) ?? false),
...binding(shortcuts.moveLineDown, (view) => codeMirrorMoveLineDown?.(view) ?? false),
...binding(shortcuts.copyLineUp, (view) => codeMirrorCopyLineUp?.(view) ?? false),
...binding(shortcuts.copyLineDown, (view) => codeMirrorCopyLineDown?.(view) ?? false),
...binding(shortcuts.undo, (view) => codeMirrorUndo?.(view) ?? false),
...binding(shortcuts.redo, (view) => codeMirrorRedo?.(view) ?? false),
...binding(shortcuts.selectAll, (view) => codeMirrorSelectAll?.(view) ?? false),
...binding(shortcuts.extendSelection, extendQueryEditorSelectionForView),
...binding(shortcuts.addNextSelectionOccurrence, addNextQueryEditorSelectionOccurrence),
...binding(shortcuts.selectAllSelectionOccurrences, selectAllQueryEditorSelectionOccurrences),
...createQueryEditorSelectionCaseShortcutBindings(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")),
...createQueryEditorSelectionCaseShortcutBindings(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")),
...createQueryEditorSelectionCaseShortcutBindings(shortcuts.convertNamingStyle, () => convertSelectedNamingStyle()),
...binding(shortcuts.toggleLineComment, (view) => codeMirrorToggleLineComment?.(view) ?? false),
...binding(shortcuts.toggleBlockComment, (view) => {
if (!supportsQueryEditorBlockComments(props.databaseType)) return false;
return codeMirrorToggleBlockComment?.(view) ?? false;
}),
...binding(shortcuts.toggleFold, (view) => codeMirrorToggleFold?.(view) ?? false),
...binding(shortcuts.exPasteSqlInCondition, () => {
if (!supportsSqlInListPaste(props.databaseType)) return false;
void pasteClipboardAsSqlInCondition();
return true;
}),
...binding(shortcuts.sendSelectionToAi, (currentView) => {
const sql = selectedSqlFromView(currentView);
if (sql.trim()) emit("sendSelectionToAi", sql);
return true;
}),
...binding(shortcuts.sqlIntentionActions, handleSqlIntentionActions),
...createQueryEditorCompletionShortcutBindings(shortcuts.triggerCompletion, triggerSqlCompletion),
...createQueryEditorSearchKeymap({
openSearch,
openReplace,
isReadOnly: () => !!props.readOnly,
}),
...sqlShortcutBindings,
]),
) ?? [],
codeMirrorKeymap.of(
binding(shortcuts.acceptCompletion, acceptCompletionOrNextSnippetField).map((item) => ({
...item,
preventDefault: false,
})),
),
];
}
function handleEnter(view: EditorViewType): boolean {
// While an IME composition is active, Enter confirms the composition (the
// candidate list); intercepting it here would accept a completion popup on
// top of the composition instead of committing the typed text (issue #8029).
if (isEditorComposing(view)) return false;
clearPendingCompletionEnter();
if (isBatchColumnSelectionCompletionActive(codeMirrorCompletionStatus?.(view.state) ?? null) && applySelectedBatchColumnSelection(view)) return true;
// CodeMirror's default completion keymap is disabled so batch selection can
// take precedence. Preserve its normal single-item acceptance here.
if (codeMirrorAcceptCompletion?.(view)) return true;
if (settingsStore.editorSettings.selectFirstCompletionOnOpen && codeMirrorCompletionStatus) {
let cancelRetry: (() => void) | null = null;
const result = acceptSelectedCompletionWithRetry(view, {
completionStatus: codeMirrorCompletionStatus,
acceptCompletion: codeMirrorAcceptCompletion,
selectedCompletionIndex: codeMirrorSelectedCompletionIndex,
selectFirstCompletion: codeMirrorSelectFirstCompletion,
retryDelayMs: COMPLETION_TAB_RETRY_DELAY_MS,
maxWaitMs: COMPLETION_ENTER_MAX_WAIT_MS,
isComposing: () => isEditorComposing(view),
onUnavailable: () => insertNewlineWithoutCompletion(view),
onSettled: () => {
if (cancelPendingCompletionEnter === cancelRetry) cancelPendingCompletionEnter = null;
},
});
if (result.handled) {
cancelRetry = result.cancel ?? null;
cancelPendingCompletionEnter = cancelRetry;
return true;
}
}
return insertNewlineWithoutCompletion(view);
}
function clearPendingCompletionEnter() {
cancelPendingCompletionEnter?.();
cancelPendingCompletionEnter = null;
}
function insertNewlineWithoutCompletion(view: EditorViewType): boolean {
codeMirrorCloseCompletion?.(view);
suppressNextSqlCompletionAutoStartUntil = Date.now() + 750;
const handled = insertQueryEditorNewline(view, codeMirrorInsertNewlineKeepIndent, props.databaseType);
if (!handled) suppressNextSqlCompletionAutoStartUntil = 0;
return handled;
}
function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean {
const databaseType = props.databaseType;
return extendQueryEditorSelection(currentView, {
databaseType,
dialect: sqlBehaviorDialect(),
language: queryEditorSelectionLanguage(),
});
}
function addNextSelectionOccurrenceFromContextMenu() {
if (!view.value) return;
addNextQueryEditorSelectionOccurrence(view.value);
focusEditor();
}
function selectAllSelectionOccurrencesFromContextMenu() {
if (!view.value) return;
selectAllQueryEditorSelectionOccurrences(view.value);
focusEditor();
}
function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean {
// A non-empty selection belongs to snippet navigation or block indentation,
// not word completion. A popup opened by an indent edit must not hijack Tab.
if (isEditorComposing(view)) return false;
if (view.state.selection.ranges.every((range) => range.empty)) {
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return true;
if (completionStatus) return waitForCompletionTab(view);
}
return codeMirrorNextSnippetField?.(view) ?? false;
}
function clearPendingCompletionTab() {
if (pendingCompletionTabTimer === null) return;
clearTimeout(pendingCompletionTabTimer);
pendingCompletionTabTimer = null;
}
function waitForCompletionTab(view: EditorViewType): boolean {
clearPendingCompletionTab();
const initialDoc = view.state.doc;
const initialSelectionRanges = view.state.selection.ranges.map((range) => ({ anchor: range.anchor, head: range.head }));
const startedAt = Date.now();
const retry = () => {
pendingCompletionTabTimer = null;
// The user started an IME composition while waiting for the pending
// completion; Tab now belongs to the candidate list, so drop the queued
// acceptance (and the normal-Tab fallback) instead of fighting the IME.
if (isEditorComposing(view)) return;
const selectionRanges = view.state.selection.ranges;
if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => !range.empty || range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) return;
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return;
if (completionStatus && Date.now() - startedAt < COMPLETION_TAB_MAX_WAIT_MS) {
pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS);
return;
}
// A pending completion may resolve without any applicable option. Preserve
// snippet navigation first, then fall back to the editor's normal Tab action.
if (codeMirrorNextSnippetField?.(view)) return;
performNormalTab(view);
};
pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS);
return true;
}
function wordWrapExtension() {
if (!editorViewModule) return [];
return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? editorViewModule.EditorView.lineWrapping : [];
}
function lineNumbersExtension(enabled = settingsStore.editorSettings.showLineNumbers) {
return buildQueryEditorLineNumbersExtension(codeMirrorLineNumbers, enabled, {
domEventHandlers: {
mousedown: selectSqlLineFromGutter,
},
});
}
function closeBracketsExtension(enabled = settingsStore.editorSettings.autoCloseBrackets) {
if (!enabled || !codeMirrorCloseBrackets) return [];
const exts: import("@codemirror/state").Extension[] = [codeMirrorCloseBrackets()];
if (codeMirrorCloseBracketsKeymap?.length && codeMirrorPrec && editorViewModule) {
exts.push(codeMirrorPrec.highest(editorViewModule.keymap.of([...codeMirrorCloseBracketsKeymap])));
}
return exts;
}
function vimModeExtension(enabled = settingsStore.editorSettings.vimModeEnabled) {
if (!codeMirrorVim || !enabled) return [];
const vimExtension = codeMirrorVim({ status: true });
if (!codeMirrorPrec || !editorViewModule || !codeMirrorGetVimCm || !codeMirrorVimApi) return vimExtension;
// Beekeeper treats Vim as a first-class editor keymap. Keep it above DBX's
// normal shortcuts so regular normal-mode keys are not stolen by other maps.
return codeMirrorPrec.highest([
editorViewModule.keymap.of([
{
key: "Ctrl-[",
mac: "Ctrl-[",
linux: "Ctrl-[",
win: "Ctrl-[",
run(currentView) {
const cm = codeMirrorGetVimCm?.(currentView);
if (cm?.state.vim?.insertMode) {
codeMirrorVimApi?.exitInsertMode(cm as any, true);
return true;
}
return false;
},
},
]),
vimExtension,
]);
}
function configureDbxVimCommands(vimApi: typeof import("@replit/codemirror-vim").Vim) {
if (dbxVimCommandsConfigured) return;
dbxVimCommandsConfigured = true;
vimApi.defineEx("write", "w", (cm) => {
cm.cm6?.contentDOM.dispatchEvent(new CustomEvent(DBX_VIM_SAVE_EVENT, { bubbles: true }));
});
}
async function ensureCodeMirrorVim() {
if (codeMirrorVim && codeMirrorVimApi && codeMirrorGetVimCm) return true;
codeMirrorVimImportPromise ??= import("@replit/codemirror-vim");
const { vim, Vim, getCM } = await codeMirrorVimImportPromise;
codeMirrorVim = vim;
codeMirrorVimApi = Vim;
codeMirrorGetVimCm = getCM;
configureDbxVimCommands(Vim);
return true;
}
function indentExtension() {
if (!codeMirrorIndentUnit) return [];
return codeMirrorIndentUnit.of(editorIndentUnit());
}
function selectedSqlFromView(currentView: EditorViewType): string {
const selection = currentView.state.selection.main;
return currentView.state.sliceDoc(selection.from, selection.to);
}
function sqlExecutionSnapshotFromView(currentView: EditorViewType): SqlExecutionSnapshot {
const selection = currentView.state.selection.main;
return {
fullSql: currentView.state.doc.toString(),
selectedSql: selectedSqlFromView(currentView),
cursorPos: selection.head,
selectionFrom: selection.from,
selectionTo: selection.to,
};
}
function sqlExecutionSnapshotForRange(currentView: EditorViewType, range: Pick<SqlExecutionCandidate, "sql" | "from" | "to">): SqlExecutionSnapshot {
return {
fullSql: currentView.state.doc.toString(),
selectedSql: range.sql,
cursorPos: currentView.state.selection.main.head,
selectionFrom: range.from,
selectionTo: range.to,
};
}
/**
* Locate the qualified identifier at `pos`, delegating to the same quote-aware
* parser used by Ctrl+click navigation. A plain word-character scan (the
* previous approach here) breaks on quoted identifiers containing characters
* outside `[\w$]` (hyphens, spaces, ...), e.g. `schema."my-table"`.
*
* Every part is re-quoted in the returned text (regardless of whether it was
* originally quoted) so downstream re-parsing via `splitQualifiedIdentifier`
* round-trips correctly even when a part's raw value isn't a bare word.
*/
function identifierRangeAt(sql: string, pos: number): { from: number; to: number; text: string } | null {
const located = extractQualifiedIdentifierAt(sql, pos);
if (!located) return null;
if (located.parts.length === 1 && !located.parts[0].quoted && isSqlKeyword(located.parts[0].value)) return null;
const text = located.parts.map((part) => quoteIdentifier(part.value)).join(".");
if (!text) return null;
return { from: located.start, to: located.end, text };
}
type CompletionMetadataScope = Pick<SqlCompletionScope, "database" | "schema">;
function completionCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope?: CompletionMetadataScope) {
const schema = table.schema ?? scope?.schema ?? props.schema;
const scopedDatabase = scope && scope.database !== props.database ? scope.database : undefined;
const database = supportsDatabaseSchemaQualifierCompletion() ? (table.database ?? scopedDatabase) : undefined;
const baseKey = schema ? `${database ? `${database}.` : ""}${schema}.${table.name}` : table.name;
if (props.databaseType !== "postgres" || (!table.nameQuoted && !table.schemaQuoted)) return baseKey;
return `${baseKey}:quoted:s=${table.schemaQuoted ? "1" : "0"}:t=${table.nameQuoted ? "1" : "0"}`;
}
function completionPrefixCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope: CompletionMetadataScope | undefined, prefix: string) {
return `${completionCacheKey(table, scope)}:prefix:${prefix.trim().toLowerCase()}`;
}
function lookupCachedPrefixColumns(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope: CompletionMetadataScope | undefined, prefix: string): SqlCompletionColumn[] | undefined {
const normalizedPrefix = prefix.trim().toLowerCase();
const exactKey = completionPrefixCacheKey(table, scope, normalizedPrefix);
const exact = cachedPrefixColumnsByTable.get(exactKey);
if (exact) return exact;
const marker = `${completionCacheKey(table, scope)}:prefix:`;
let best: { prefix: string; columns: SqlCompletionColumn[] } | undefined;
for (const [key, columns] of cachedPrefixColumnsByTable) {
if (!key.startsWith(marker)) continue;
const cachedPrefix = key.slice(marker.length);
if (!normalizedPrefix.startsWith(cachedPrefix) || (best && cachedPrefix.length <= best.prefix.length)) continue;
best = { prefix: cachedPrefix, columns };
}
if (!best) return undefined;
return best.columns.filter((column) => column.name.toLowerCase().startsWith(normalizedPrefix));
}
const pendingInsertValueHintColumnLoads = new Set<string>();
function insertHintCacheKey(table: { name: string; schema?: string | null; database?: string | null }) {
if (table.database) {
return table.schema ? `${table.database}.${table.schema}.${table.name}` : `${table.database}.${table.name}`;
}
return completionCacheKey(table);
}
function insertHintMetadataTarget(table: { name: string; schema?: string | null; database?: string | null }): { database: string; schema?: string; catalog?: string } | null {
if (props.database == null) return null;
if (table.database) {
return { database: table.database, schema: table.schema ?? undefined, catalog: props.catalog };
}
return completionMetadataTarget(table);
}
function getInsertValueHintTableColumns(table: string, schema?: string, database?: string): string[] | undefined {
const cacheKey = insertHintCacheKey({ name: table, schema, database });
if (props.databaseType === "sqlserver") return cachedInsertValueHintColumnsByTable.get(cacheKey);
const cached = cachedColumnsByTable.get(cacheKey);
if (!cached) return undefined;
return cached.map((column) => column.name);
}
function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) {
if (!props.connectionId || props.database == null) return;
if (!supportsInsertValueHints(props.databaseType)) return;
const cacheKey = insertHintCacheKey({ name: table, schema, database });
const hasCachedColumns = props.databaseType === "sqlserver" ? cachedInsertValueHintColumnsByTable.has(cacheKey) : cachedColumnsByTable.has(cacheKey);
if (hasCachedColumns || pendingInsertValueHintColumnLoads.has(cacheKey)) return;
const target = insertHintMetadataTarget({ name: table, schema, database });
if (!target) return;
pendingInsertValueHintColumnLoads.add(cacheKey);
const connectionId = props.connectionId;
const databaseType = props.databaseType;
const loadColumns = async () => {
if (databaseType === "sqlserver") {
const querySchema = metadataSchemaForConnection(connectionStore.getConfig(connectionId), target.database, target.schema);
const columns = await api.getSqlServerColumnMetadata(connectionId, target.database, querySchema, table);
cachedInsertValueHintColumnsByTable.set(cacheKey, insertValueHintColumnNames(databaseType, columns));
return;
}
const columns = await listCompletionColumnsForEditor(connectionId, target.database, table, target.schema, target.catalog);
cachedColumnsByTable.set(cacheKey, columns);
};
void loadColumns()
.then(() => {
loadedColumnsByTable.add(cacheKey.toLowerCase());
if (view.value) requestInsertValueHintsRefresh(view.value);
})
.catch(() => {})
.finally(() => {
pendingInsertValueHintColumnLoads.delete(cacheKey);
});
}
function supportsDatabaseQualifierCompletion(): boolean {
return !!props.databaseType && !isSchemaAware(props.databaseType) && !isSingleDatabase(props.databaseType);
}
function supportsDatabaseSchemaQualifierCompletion(): boolean {
return supportsDatabaseSchemaQualifier(props.databaseType);
}
function usesLocalOnlyCompletionMetadata(): boolean {
return usesLocalOnlyEditorCompletionMetadata(props.databaseType);
}
function usesOnDemandOnlyCompletionColumns(): boolean {
return usesOnDemandOnlyEditorColumnMetadata(props.databaseType);
}
function allowsOnDemandQualifiedTableCompletion(prefix: string): boolean {
if (!usesLocalOnlyCompletionMetadata()) return false;
if (props.databaseType !== "prestosql" && props.databaseType !== "trino") return false;
return prefix.trim().length >= PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX;
}
function completionMetadataTarget(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }, scope?: CompletionMetadataScope): { database: string; schema?: string; catalog?: string } | null {
const currentDatabase = scope?.database ?? props.database;
if (currentDatabase == null) return null;
// SQL Server metadata queries require a schema even when the SQL uses an
// unqualified table name. The query editor commonly has no schema selected
// when the user is working from a database-level tab, so use the same
// default as the table/DDL metadata paths instead of returning no columns.
const selectedSchema = table.schema ?? scope?.schema ?? props.schema;
const effectiveSchema = selectedSchema ?? (props.databaseType === "sqlserver" ? metadataSchemaForConnection(connectionStore.getConfig(props.connectionId ?? ""), currentDatabase, undefined) : undefined);
if (supportsDatabaseSchemaQualifierCompletion() && table.database) {
return { database: table.database, schema: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
if (supportsDatabaseQualifierCompletion() && effectiveSchema) {
return { database: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
return { database: currentDatabase, schema: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
function isVirtualCompletionTableReference(table: { name: string; database?: string | null; schema?: string | null }): boolean {
return isSqlVirtualTableReference(table, props.databaseType);
}
function completionQualifiedTableTarget(completionContext: ReturnType<typeof getSqlCompletionContext>): { name: string; database?: string; schema: string } | null {
if (!completionContext.suggestColumns) return null;
const parts = completionContext.qualifierParts ?? completionContext.qualifier?.split(".").filter(Boolean) ?? [];
if (parts.length < 2) return null;
const name = parts[parts.length - 1];
const schema = parts[parts.length - 2];
if (!name || !schema) return null;
const database = supportsDatabaseSchemaQualifierCompletion() && parts.length >= 3 ? parts[parts.length - 3] : undefined;
return { name, database, schema };
}
function completionTablesMatch(left: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }, right: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }) {
if (left.name.toLowerCase() !== right.name.toLowerCase()) return false;
if (left.catalog && right.catalog && left.catalog.toLowerCase() !== right.catalog.toLowerCase()) return false;
if (left.database && right.database && left.database.toLowerCase() !== right.database.toLowerCase()) return false;
if (!left.schema || !right.schema) return true;
return left.schema.toLowerCase() === right.schema.toLowerCase();
}
async function findExactSemanticDiagnosticTable(table: SqlTableReference, scope?: CompletionMetadataScope): Promise<SqlCompletionTable | null> {
if (!props.connectionId || props.database == null) return null;
const target = completionMetadataTarget(table, scope);
if (!target) return null;
const localMatches = connectionStore.lookupLocalCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, target.catalog);
const localExact = localMatches.find((item) => completionTablesMatch(item, table));
if (localExact) {
cachedTables = mergeCompletionTables(cachedTables, [localExact]);
return localExact;
}
const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, false, scope?.schema ?? props.schema, target.catalog, {
verifySchemaMetadata: !!target.schema,
});
cachedTables = mergeCompletionTables(cachedTables, remoteMatches);
return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null;
}
async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null }, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">, scope?: CompletionMetadataScope): Promise<boolean> {
if (isVirtualCompletionTableReference(table)) return false;
const cacheKey = completionCacheKey(table, scope);
if (cachedColumnsByTable.has(cacheKey)) return true;
if (!props.connectionId || props.database == null) return false;
const target = completionMetadataTarget(table, scope);
if (!target) return false;
const localColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, table.name, target.schema, target.catalog, completionColumnRequestContext(reference));
if (localColumns.length > 0) {
cachedColumnsByTable.set(cacheKey, localColumns);
loadedColumnsByTable.add(cacheKey.toLowerCase());
return true;
}
let columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema, target.catalog, reference);
// A schema-aware connection can legitimately return an empty result when
// the editor has no selected schema. Resolve the physical table from the
// local/remote table cache and retry with its schema before reporting that
// star expansion is unavailable. This is especially important for aliased
// sources because the alias itself must never be sent as the table name.
if (columns.length === 0 && !table.schema && !target.schema && !supportsDatabaseQualifierCompletion()) {
const schemaCandidates: string[] = [];
const seenSchemas = new Set<string>();
const addSchema = (schema?: string | null) => {
const normalized = schema?.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seenSchemas.has(key)) return;
seenSchemas.add(key);
schemaCandidates.push(normalized);
};
if (props.databaseType === "sqlserver") {
addSchema(metadataSchemaForConnection(connectionStore.getConfig(props.connectionId), target.database, undefined));
}
const localTables = connectionStore.lookupLocalCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, undefined, target.catalog);
localTables.forEach((candidate) => {
if (candidate.name.toLowerCase() === table.name.toLowerCase()) addSchema(candidate.schema);
});
if (schemaCandidates.length === 0 && !usesLocalOnlyCompletionMetadata()) {
const remoteTables = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, undefined, false, undefined, target.catalog);
remoteTables.forEach((candidate) => {
if (candidate.name.toLowerCase() === table.name.toLowerCase()) addSchema(candidate.schema);
});
}
for (const schema of schemaCandidates) {
const schemaTarget = completionMetadataTarget({ ...table, schema }, scope);
if (!schemaTarget) continue;
const retryColumns = await listCompletionColumnsForEditor(props.connectionId, schemaTarget.database, table.name, schemaTarget.schema, schemaTarget.catalog, reference);
if (retryColumns.length > 0) {
columns = retryColumns;
break;
}
}
}
// Do not memoize an empty response as a successful load. Empty results are
// commonly caused by a temporarily unresolved schema; keeping that value
// would prevent the next expansion attempt from retrying after metadata has
// become available.
if (columns.length > 0) {
cachedColumnsByTable.set(cacheKey, columns);
loadedColumnsByTable.add(cacheKey.toLowerCase());
} else {
cachedColumnsByTable.delete(cacheKey);
loadedColumnsByTable.delete(cacheKey.toLowerCase());
}
return true;
}
function resultColumnsForSelectStar(target: SelectStarExpansionTarget, sql: string): SqlCompletionColumn[] {
if (
!target.allowResultColumnsFallback ||
target.references.length !== 1 ||
!selectStarResultColumnsMatch({
currentSql: sql,
targetFrom: target.from,
targetTo: target.to,
statementSql: target.statementSql,
sourceStatement: props.resultSourceStatement,
sourceFrom: props.resultSourceFrom,
sourceTo: props.resultSourceTo,
})
)
return [];
return (props.resultColumns ?? [])
.map((name) => name.trim())
.filter(Boolean)
.map((name) => ({ name, table: target.references[0]!.name, schema: target.references[0]!.schema }));
}
async function expandSelectStar(target = selectStarExpansionTarget.value) {
const currentView = view.value;
if (!currentView || props.readOnly) return;
if (!target) return;
const originalDocument = currentView.state.doc.toString();
try {
await Promise.all(target.references.map((reference) => ensureColumnsForTable(reference, reference)));
} catch (error) {
console.warn("expandSelectStar: failed to load columns", error);
toast(t("editor.contextMenu.expandSelectStarUnavailable"), 3000);
return;
}
if (view.value !== currentView || currentView.state.doc.toString() !== originalDocument || currentView.state.sliceDoc(target.from, target.to) !== "*") return;
const columnsByReference = new Map<string, SqlCompletionColumn[]>();
for (const reference of target.references) {
const columns = cachedColumnsByTable.get(completionCacheKey(reference));
const expansionColumns = columns?.length ? columns : target.references.length === 1 ? resultColumnsForSelectStar(target, originalDocument) : [];
if (expansionColumns.length === 0) {
toast(t("editor.contextMenu.expandSelectStarUnavailable"), 3000);
return;
}
columnsByReference.set(completionCacheKey(reference), expansionColumns);
}
const expansion = buildSelectStarExpansion(target.context, columnsByReference, props.dialect, target.qualifierSql, props.databaseType);
if (!expansion) {
toast(t("editor.contextMenu.expandSelectStarUnavailable"), 3000);
return;
}
currentView.dispatch({
changes: { from: target.from, to: target.to, insert: expansion },
selection: { anchor: target.from + expansion.length },
scrollIntoView: true,
userEvent: "input.expandSelectStar",
});
currentView.focus();
}
function isMissingTableMetadataError(error: unknown) {
const message = String(error instanceof Error ? error.message : error).toLowerCase();
return message.includes("42s02") || message.includes("1146") || message.includes("doesn't exist") || message.includes("does not exist") || message.includes("unknown table");
}
async function ensureForeignKeysForTable(table: { name: string; database?: string | null; schema?: string | null }) {
if (isVirtualCompletionTableReference(table)) return;
const cacheKey = completionCacheKey(table);
if (cachedForeignKeysByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
const target = completionMetadataTarget(table);
if (!target) return;
try {
const foreignKeys = await connectionStore.listCompletionForeignKeys(props.connectionId, target.database, table.name, target.schema);
cachedForeignKeysByTable.set(cacheKey, foreignKeys);
} catch (e) {
console.warn(`[DBX] Failed to load foreign keys for ${cacheKey}:`, e);
cachedForeignKeysByTable.set(cacheKey, []);
}
}
async function ensureForeignKeysForTables(tables: Array<{ name: string; database?: string | null; schema?: string | null }>) {
const seen = new Set<string>();
const uniqueTables = tables.filter((table) => {
const key = completionCacheKey(table).toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
for (let index = 0; index < uniqueTables.length; index += COMPLETION_METADATA_CONCURRENCY) {
await Promise.all(uniqueTables.slice(index, index + COMPLETION_METADATA_CONCURRENCY).map((table) => ensureForeignKeysForTable(table)));
}
}
function createHoverDom(title: string, detail: string, sqlContent?: string, rows: string[] = []): { dom: HTMLElement; mount?: () => void; destroy?: () => void } {
const dom = document.createElement("div");
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
const heading = document.createElement("div");
heading.className = "font-medium";
heading.textContent = title;
dom.appendChild(heading);
const detailNode = document.createElement("div");
detailNode.className = "mt-1 text-muted-foreground";
detailNode.textContent = detail;
dom.appendChild(detailNode);
let layoutController: ReturnType<typeof constrainSqlHoverLayout> | null = null;
let handleCopy: ((event: ClipboardEvent) => void) | null = null;
let searchController: HoverSearchController | null = null;
if (sqlContent) {
heading.className = "flex items-center justify-between gap-3 font-medium";
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.className = "rounded border border-border/60 px-1.5 py-0.5 text-[11px] leading-none text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
copyButton.textContent = t("grid.copyDdl");
copyButton.title = t("grid.copyDdl");
copyButton.setAttribute("aria-label", t("grid.copyDdl"));
copyButton.addEventListener("pointerdown", (event) => {
// Keep CodeMirror's editor gestures from dismissing the tooltip before
// the click can reach the copy action.
event.preventDefault();
event.stopPropagation();
});
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void (async () => {
try {
await copyToClipboard(normalizeAlignedSqlWhitespace(sqlContent));
toast(t("contextMenu.ddlCopied"), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
}
})();
});
heading.appendChild(copyButton);
const separator = document.createElement("div");
separator.className = "mt-2 border-t border-border/60";
dom.appendChild(separator);
const sqlContainer = document.createElement("div");
sqlContainer.className = "mt-1.5 text-[11px] leading-5 whitespace-pre font-mono";
if (hoverSqlHighlighter) {
sqlContainer.innerHTML = hoverSqlHighlighter(sqlContent, isDark.value ? "dark" : "light");
} else {
sqlContainer.className += " text-muted-foreground";
sqlContainer.textContent = sqlContent;
}
// 纯前端搜索:在已生成的 DDL 内容上做大小写不敏感匹配并高亮,不重新请求元数据/DDL。
// originalHtml 必须在挂到文档前、内容渲染后捕获,作为每次搜索的还原基线。
searchController = createHoverSearch({
target: sqlContainer,
originalHtml: sqlContainer.innerHTML,
placeholder: t("grid.hoverSearchPlaceholder"),
noResultLabel: t("grid.hoverSearchNoResult"),
});
dom.appendChild(searchController.element);
dom.appendChild(sqlContainer);
dom.appendChild(searchController.status);
// 返回 mount/destroy 给 CodeMirror TooltipView 生命周期钩子,
// 避免 MutationObserver 监听 body 全子树来兜底清理。
layoutController = constrainSqlHoverLayout(dom, sqlContainer);
// The tooltip pads column names/types with literal spaces so they line up
// visually (see alignColumnRows). Selecting that text and copying it via
// the native OS/browser copy carries those spaces verbatim, which shows
// up as long literal space runs when pasted into a plain-text editor.
// Normalize just the clipboard payload so the on-screen alignment is
// untouched but paste targets get clean single-spaced SQL.
handleCopy = (event: ClipboardEvent) => {
const selection = document.getSelection();
if (!selection || selection.isCollapsed) return;
if (!dom.contains(selection.anchorNode) && !dom.contains(selection.focusNode)) return;
const text = selection.toString();
if (text !== sqlContent) return;
const normalized = normalizeAlignedSqlWhitespace(text);
if (normalized === text) return;
event.clipboardData?.setData("text/plain", normalized);
event.preventDefault();
};
}
for (const row of rows) {
const rowNode = document.createElement("div");
rowNode.className = "mt-1 font-mono text-muted-foreground";
rowNode.textContent = row;
dom.appendChild(rowNode);
}
return {
dom,
mount:
layoutController || handleCopy || searchController
? () => {
layoutController?.mount();
if (handleCopy) document.addEventListener("copy", handleCopy);
}
: undefined,
destroy:
layoutController || handleCopy || searchController
? () => {
layoutController?.destroy();
searchController?.destroy();
if (handleCopy) document.removeEventListener("copy", handleCopy);
}
: undefined,
};
}
async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) {
if (!props.connectionId || props.database == null || contextMenuOpen.value) return null;
const sql = currentView.state.doc.toString();
const range = identifierRangeAt(sql, pos);
if (!range) return null;
const identifier = range.text;
const parts = splitQualifiedIdentifier(identifier);
const name = parts[parts.length - 1] ?? identifier;
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
let semanticModel: ReturnType<typeof buildSqlSemanticModel> | null = null;
if (SEMANTIC_SQL_COMPLETION_ENABLED) {
try {
semanticModel = buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions());
} catch (error) {
semanticModel = null;
console.warn(`[DBX] Failed to build semantic model for hover tooltip:`, error);
}
}
const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier;
const lookup = resolveHoverTableLookupTarget({
database: props.database,
schema: props.schema,
catalog: props.catalog,
databaseType: props.databaseType,
tableName: tableLookupName,
// Alias.column must not be treated as schema.table; keep the bare column/table token.
identifierParts: semanticQualifierIsRowSource ? [tableLookupName] : parts,
semanticDatabase: semanticQualifierIsRowSource ? undefined : semanticTarget?.database,
semanticSchema: semanticQualifierIsRowSource ? undefined : semanticTarget?.schema,
mode: settingsStore.editorSettings.tableHoverLookupMode,
});
const hoverTarget = completionMetadataTarget({
name: lookup.tableName,
catalog: props.catalog,
database: lookup.database !== props.database ? lookup.database : undefined,
schema: lookup.schema,
});
if (!hoverTarget) return null;
const hoverScope: HoverTableScope = {
catalog: hoverTarget.catalog,
database: hoverTarget.database,
schema: hoverTarget.schema,
};
const preferredSchema = props.schema;
const matchHoverTable = (tables: typeof cachedTables) =>
matchHoverTableCandidates(tables, {
lookups: [qualifiedTableLookup, identifier],
tableName: tableLookupName,
preferredSchema,
});
try {
const searchLocal = (schema?: string) => {
const scopeForFilter: HoverTableScope = { ...hoverScope, schema };
const localTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, schema, hoverScope.catalog);
const localHoverTables = schema ? scopeHoverTables(localTables, scopeForFilter) : scopeHoverTables(localTables, { ...hoverScope, schema: undefined });
return localHoverTables;
};
const searchRemote = async (schema: string | undefined, globalSearch: boolean) => {
const loadedTables = await connectionStore.listCompletionTables(props.connectionId!, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, schema, globalSearch, preferredSchema, hoverScope.catalog);
return schema ? scopeHoverTables(loadedTables, { ...hoverScope, schema }) : loadedTables;
};
let hoverTables = cachedTables.filter((table) => (lookup.preferGlobalFirst || !hoverScope.schema ? hoverTableMatchesScope(table, { ...hoverScope, schema: undefined }) : hoverTableMatchesScope(table, hoverScope)));
let table = matchHoverTable(hoverTables);
if (!table && !lookup.preferGlobalFirst) {
const localHoverTables = searchLocal(hoverScope.schema);
hoverTables = mergeCompletionTables(localHoverTables, hoverTables);
cachedTables = mergeCompletionTables(localHoverTables, cachedTables);
table = matchHoverTable(hoverTables);
}
if (!table && lookup.preferGlobalFirst) {
const localHoverTables = searchLocal(undefined);
hoverTables = mergeCompletionTables(localHoverTables, hoverTables);
cachedTables = mergeCompletionTables(localHoverTables, cachedTables);
table = matchHoverTable(hoverTables);
}
if (!table && !usesLocalOnlyCompletionMetadata() && !lookup.preferGlobalFirst) {
const remoteHoverTables = await searchRemote(hoverScope.schema, false);
hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables);
cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables);
table = matchHoverTable(hoverTables);
}
if (!table && !usesLocalOnlyCompletionMetadata() && lookup.preferGlobalFirst) {
const remoteHoverTables = await searchRemote(undefined, true);
hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables);
cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables);
table = matchHoverTable(hoverTables);
}
if (!table && lookup.allowGlobalFallback && !lookup.preferGlobalFirst) {
const localHoverTables = searchLocal(undefined);
hoverTables = mergeCompletionTables(localHoverTables, hoverTables);
cachedTables = mergeCompletionTables(localHoverTables, cachedTables);
table = matchHoverTable(hoverTables);
if (!table && !usesLocalOnlyCompletionMetadata()) {
const remoteHoverTables = await searchRemote(undefined, true);
hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables);
cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables);
table = matchHoverTable(hoverTables);
}
}
if (table && settingsStore.editorSettings.showTableDdlHoverPreview && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
const hoverDatabase = table.database ?? hoverScope.database;
const hoverSchema = table.schema ?? hoverScope.schema ?? "";
const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join(".");
const objectMetadataRequest = {
connectionId: props.connectionId,
database: hoverDatabase,
schema: hoverSchema,
tableName: table.name,
catalog: hoverScope.catalog,
objectType: sqlObjectNavigationSourceKind(table),
};
let sqlContent: string | undefined;
const formatDialect = props.formatDialect ?? sqlFormatDialectForDbType(props.databaseType);
let metadataLoadFailed = false;
// The persisted display DDL is canonical across the full-page and hover
// views. Hover only removes PostgreSQL's appended access-control tail.
try {
const { ddl } = await loadObjectDdl(objectMetadataRequest);
const rawDdl = ddlForHoverPreview(ddl);
if (rawDdl && rawDdl.trim()) {
// A view's display DDL wraps the raw (often single-line) view source
// in `CREATE ... VIEW ... AS`; the table-oriented reformatter cannot
// lay out a SELECT body, so views reuse the shared display formatter
// (the same one the sidebar/object-source viewers use). Tables keep
// the aligned column layout from reformatHoverDdl.
const isViewObject = objectMetadataRequest.objectType === "VIEW" || objectMetadataRequest.objectType === "MATERIALIZED_VIEW";
const formatted = isViewObject ? await formatSqlForDisplay(rawDdl, formatDialect, settingsStore.editorSettings.sqlFormatter) : reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName));
sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect);
}
} catch (error) {
console.warn(`[DBX] Failed to load table DDL for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
}
// Fallback path: rebuild the DDL from cached table metadata when the
// backend DDL is unavailable (empty result or request failure).
if (!sqlContent) {
let fullColumns: ColumnInfo[] = [];
let fullIndexes: IndexInfo[] = [];
let tableComment: string | undefined;
try {
const [columnsResult, indexesResult] = await Promise.all([
loadObjectMetadataFacet(objectMetadataRequest, "columns", () => api.getColumns(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)),
loadObjectMetadataFacet(objectMetadataRequest, "indexes", () => api.listIndexes(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)).catch(() => ({ value: [] as IndexInfo[], cacheStatus: "remote" as const })),
]);
fullColumns = columnsResult.value;
fullIndexes = indexesResult.value;
} catch (error) {
metadataLoadFailed = true;
console.warn(`[DBX] Failed to load table metadata for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
}
if (!metadataLoadFailed) {
try {
const commentResult = await loadObjectMetadataFacet(objectMetadataRequest, "comment", () => api.getTableComment(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog));
if (commentResult.value) tableComment = commentResult.value;
} catch (error) {
console.warn(`[DBX] Failed to load table comment for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
}
}
if (fullColumns.length > 0) {
sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment);
if (!settingsStore.editorSettings.generateSqlQuoteIdentifiers) sqlContent = omitDdlIdentifierQuotes(sqlContent, formatDialect);
metadataLoadFailed = false;
}
}
// Re-check after async metadata load — the context menu may have opened
// while the DDL request was in flight, and we must not display a hover
// tooltip on top of an open context menu.
if (contextMenuOpen.value) return null;
return {
pos: range.from,
end: range.to,
create: () => createHoverDom(table.name, sqlObjectHoverDetail(table), sqlContent, metadataLoadFailed ? ["[DBX] Failed to load table structure — check connection"] : undefined),
};
}
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;
for (const refTable of candidates) {
const columns: SqlCompletionColumn[] =
refTable.columns?.map((columnName) => ({
name: columnName,
table: refTable.name,
...(refTable.schema ? { schema: refTable.schema } : {}),
})) ?? [];
if (columns.length === 0) {
await ensureColumnsForTable(refTable);
columns.push(...(cachedColumnsByTable.get(completionCacheKey(refTable)) ?? []));
}
const column = columns.find((col) => col.name.toLowerCase() === name.toLowerCase());
if (!column) continue;
return {
pos: range.from,
end: range.to,
create: () => createHoverDom(column.name, column.dataType || "column", undefined, [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]),
};
}
} catch {
return null;
}
return null;
}
function sqlErrorDecorationRange(currentState: import("@codemirror/state").EditorState) {
if (!props.executionError) return [];
if (!props.executionErrorSql || !sqlErrorSqlMatchesEditor(currentState.doc.toString(), props.executionErrorSql)) return [];
const range = resolveSqlErrorDecorationRange(currentState.doc.toString(), props.executionError);
if (!range) return [];
return [
{
...range,
message: props.executionError,
},
];
}
function sqlTextSpanToRange(sql: string, span: SqlTextSpan): { from: number; to: number } | null {
if (!span.start_line || !span.start_column) return null;
const from = lineColumnToOffset(sql, {
line: span.start_line - 1,
column: span.start_column - 1,
});
const to = lineColumnToOffset(sql, {
line: Math.max(span.end_line - 1, span.start_line - 1),
column: Math.max(span.end_column, span.start_column),
});
if (from == null || to == null || to <= from) return null;
return { from, to };
}
function sqlSemanticDecorationRanges(currentState: import("@codemirror/state").EditorState) {
const sql = currentState.doc.toString();
return semanticDiagnostics
.map((diagnostic) => {
const range = sqlTextSpanToRange(sql, diagnostic.span);
return range
? {
...range,
message: diagnostic.message,
severity: diagnostic.severity,
}
: null;
})
.filter(
(
range,
): range is {
from: number;
to: number;
message: string;
severity: "error" | "warning";
} => !!range,
);
}
function reconfigureDiagnostics() {
if (!view.value) return;
if (setSqlDiagnosticsEffect) {
view.value.dispatch({
effects: setSqlDiagnosticsEffect.of(semanticDiagnostics),
});
return;
}
if (!diagnosticComp || !buildSqlDiagnosticExtension) return;
view.value.dispatch({
effects: diagnosticComp.reconfigure(buildSqlDiagnosticExtension()),
});
}
function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) {
if (areSqlSemanticDiagnosticsEqual(semanticDiagnostics, next)) return;
semanticDiagnostics = next;
reconfigureDiagnostics();
}
function clearScheduledSemanticDiagnostics() {
semanticDiagnosticRunId++;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = null;
pendingSemanticDiagnosticPreserveOutsideRanges = false;
}
function invalidateSemanticDiagnosticsForDocumentChange() {
semanticDiagnosticRunId++;
semanticDiagnostics = [];
}
function shouldSkipSqlSemanticDiagnostics() {
return props.databaseType === "victoriametrics" || (props.databaseType !== "redis" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
}
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
return left.from < right.to && right.from < left.to;
}
function sqlLineColumnAtOffset(sql: string, offset: number): { line: number; column: number } {
const safeOffset = Math.max(0, Math.min(offset, sql.length));
let line = 1;
let lineStart = 0;
for (let index = 0; index < safeOffset; index += 1) {
if (sql[index] === "\n") {
line += 1;
lineStart = index + 1;
}
}
return { line, column: safeOffset - lineStart + 1 };
}
function offsetSqlTextSpan(span: SqlTextSpan, rangeStart: { line: number; column: number }): SqlTextSpan {
const offsetLine = (line: number) => rangeStart.line + line - 1;
const offsetColumn = (line: number, column: number) => (line === 1 ? rangeStart.column + column - 1 : column);
return {
start_line: offsetLine(span.start_line),
start_column: offsetColumn(span.start_line, span.start_column),
end_line: offsetLine(span.end_line),
end_column: offsetColumn(span.end_line, span.end_column),
};
}
function offsetSqlSemanticDiagnostics(diagnostics: readonly SqlSemanticDiagnostic[], range: SqlTextRange, fullSql: string): SqlSemanticDiagnostic[] {
const rangeStart = sqlLineColumnAtOffset(fullSql, range.from);
return diagnostics.map((diagnostic) => ({
...diagnostic,
span: offsetSqlTextSpan(diagnostic.span, rangeStart),
}));
}
function replaceSemanticDiagnosticsInRanges(next: SqlSemanticDiagnostic[], ranges: readonly SqlTextRange[], fullSql: string) {
const retained = semanticDiagnostics.filter((diagnostic) => {
const diagnosticRange = sqlTextSpanToRange(fullSql, diagnostic.span);
return !diagnosticRange || !ranges.some((range) => rangesOverlap(diagnosticRange, range));
});
setSemanticDiagnostics([...retained, ...next].sort(compareSqlSemanticDiagnostics));
}
function compareSqlSemanticDiagnostics(left: SqlSemanticDiagnostic, right: SqlSemanticDiagnostic): number {
return left.span.start_line - right.span.start_line || left.span.start_column - right.span.start_column || left.span.end_line - right.span.end_line || left.span.end_column - right.span.end_column || left.message.localeCompare(right.message);
}
function semanticDiagnosticMetadataScope(sql: string, range: SqlTextRange): CompletionMetadataScope {
const selectedDatabase = props.database!;
const parsedDatabase = props.databaseType === "sqlserver" ? sqlServerUseDatabaseBeforeCursor(sql, range.from) : undefined;
if (!parsedDatabase || !props.connectionId) return { database: selectedDatabase, schema: props.schema };
const database = connectionStore.lookupLocalCompletionDatabases(props.connectionId, parsedDatabase, MAX_COMPLETION_TABLES).find((candidate) => candidate.toLowerCase() === parsedDatabase.toLowerCase()) ?? parsedDatabase;
return {
database,
schema: metadataSchemaForConnection(connectionStore.getConfig(props.connectionId), database, undefined),
};
}
function semanticDiagnosticTablesForScope(tables: SqlTableReference[], scope: CompletionMetadataScope): SqlTableReference[] {
if (props.databaseType !== "sqlserver" || scope.database === props.database) return tables;
return tables.map((table) => (table.database ? table : { ...table, database: scope.database, schema: table.schema ?? scope.schema }));
}
async function enrichSemanticDiagnosticTables(tables: SqlTableReference[], scope?: CompletionMetadataScope): Promise<{ tables: SqlTableReference[]; missingTables: Set<string> }> {
if (!props.connectionId || props.database == null) return { tables, missingTables: new Set() };
const enriched: SqlTableReference[] = [];
const missingTables = new Set<string>();
for (const table of tables) {
if (isStatementLocalSemanticTable(table) || isSqlVirtualTableReference(table, props.databaseType)) {
enriched.push(table);
continue;
}
if (usesOracleSessionCompletionColumns(table.schema)) {
enriched.push(table);
continue;
}
try {
const match = await findExactSemanticDiagnosticTable(table, scope);
if (!match) missingTables.add(tableReferenceKey(table));
enriched.push(match?.schema ? { ...table, schema: match.schema } : table);
} catch {
enriched.push(table);
}
}
return { tables: enriched, missingTables };
}
async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope): Promise<Set<string>> {
const missingTables = new Set<string>();
const seen = new Set<string>();
const targets: SqlTableReference[] = [];
for (const table of tables) {
if (isStatementLocalSemanticTable(table) || isSqlVirtualTableReference(table, props.databaseType)) continue;
const tableWithInlineColumns = table as SqlTableReference & {
columns?: string[];
};
if (tableWithInlineColumns.columns && tableWithInlineColumns.columns.length > 0) continue;
const cacheKey = completionCacheKey(table, scope);
if (cachedColumnsByTable.has(cacheKey)) continue;
const normalizedKey = cacheKey.toLowerCase();
if (seen.has(normalizedKey)) continue;
seen.add(normalizedKey);
targets.push(table);
if (targets.length >= MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES) break;
}
await Promise.all(
targets.map(async (table) => {
try {
await ensureColumnsForTable(table, undefined, scope);
} catch (error) {
if (isMissingTableMetadataError(error)) {
missingTables.add(tableReferenceKey(table));
}
}
}),
);
return missingTables;
}
function isStatementLocalSemanticTable(table: SqlTableReference): boolean {
const kind = (table as SqlTableReference & { semanticSourceKind?: string }).semanticSourceKind;
return kind === "cte" || kind === "subquery" || kind === "table_function";
}
async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boolean } = {}) {
const currentView = view.value;
const runId = ++semanticDiagnosticRunId;
if (!currentView || !props.connectionId || props.database == null) {
setSemanticDiagnostics([]);
return;
}
const sql = currentView.state.doc.toString();
if (!sql.trim()) {
setSemanticDiagnostics([]);
return;
}
if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "victoriametrics") {
setSemanticDiagnostics([]);
return;
}
if (props.databaseType === "redis") {
// Redis has no SQL semantics; run command-name / arity / quote / danger checks instead.
if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) {
scheduleSemanticDiagnostics(900, {
preserveOutsideRanges: options.preserveOutsideRanges,
});
return;
}
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
return;
}
if (shouldSkipSqlSemanticDiagnostics()) {
setSemanticDiagnostics([]);
return;
}
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
scheduleSemanticDiagnostics(1200, {
preserveOutsideRanges: options.preserveOutsideRanges,
});
return;
}
if (codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
scheduleSemanticDiagnostics(900, {
preserveOutsideRanges: options.preserveOutsideRanges,
});
return;
}
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
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, sqlStatementParameterOptions());
if (diagnosticRanges.length === 0) {
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
return;
}
const nextDiagnostics: SqlSemanticDiagnostic[] = [];
const oracleSyntaxDiagnostics = buildOracleSyntaxDiagnostics(sql, props.databaseType);
nextDiagnostics.push(
...oracleSyntaxDiagnostics.filter((diagnostic) => {
const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span);
return !!diagnosticRange && diagnosticRanges.some((range) => rangesOverlap(diagnosticRange, range));
}),
);
const mysqlRoutineAnalysis = props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null;
if (mysqlRoutineAnalysis) {
nextDiagnostics.push(
...mysqlRoutineAnalysis.diagnostics.filter((diagnostic) => {
const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span);
return !!diagnosticRange && diagnosticRanges.some((range) => rangesOverlap(diagnosticRange, range));
}),
);
}
for (const range of diagnosticRanges) {
if (mysqlRoutineAnalysis?.routineRanges.some((routineRange) => rangesOverlap(routineRange, range))) continue;
try {
const analysis = await api.analyzeSqlReferences(
range.sql,
sqlReferenceAnalysisDialectFor({
databaseType: props.databaseType,
identifierQuote: connectionStore.connectionIdentifierQuote(props.connectionId),
fallbackDialect: props.formatDialect ?? props.dialect ?? "generic",
}),
);
if (runId !== semanticDiagnosticRunId) return;
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
? buildSqlSemanticModel(range.sql, semanticCursor, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
})
: null;
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
const scopedAnalysis = {
...semanticAnalysis,
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope),
};
const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope);
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables, metadataScope);
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
if (runId !== semanticDiagnosticRunId) return;
const enrichedAnalysis: SqlReferenceAnalysis = {
...scopedAnalysis,
tables,
};
nextDiagnostics.push(
...offsetSqlSemanticDiagnostics(
buildSqlSemanticDiagnostics(enrichedAnalysis, {
tables: cachedTables,
columnsByTable: cachedColumnsByTable,
missingTables,
loadedColumnTables: loadedColumnsByTable,
sql: range.sql,
databaseType: props.databaseType,
}),
range,
sql,
),
);
} catch (error) {
if (runId !== semanticDiagnosticRunId) return;
const diagnostic = buildSqlParserErrorDiagnostic(error, range.sql);
if (diagnostic) nextDiagnostics.push(...offsetSqlSemanticDiagnostics([diagnostic], range, sql));
}
}
if (options.preserveOutsideRanges) {
replaceSemanticDiagnosticsInRanges(nextDiagnostics, diagnosticRanges, sql);
} else {
setSemanticDiagnostics(nextDiagnostics.sort(compareSqlSemanticDiagnostics));
}
}
function scheduleSemanticDiagnostics(delay = 500, options: { preserveOutsideRanges?: boolean } = {}) {
if (!editorIsActive) return;
if (shouldSkipSqlSemanticDiagnostics()) {
clearScheduledSemanticDiagnostics();
setSemanticDiagnostics([]);
return;
}
pendingSemanticDiagnosticPreserveOutsideRanges = !!options.preserveOutsideRanges;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = setTimeout(() => {
const preserveOutsideRanges = pendingSemanticDiagnosticPreserveOutsideRanges;
pendingSemanticDiagnosticPreserveOutsideRanges = false;
semanticDiagnosticTimer = null;
void refreshSemanticDiagnostics({ preserveOutsideRanges });
}, delay);
}
async function formatCurrentSql() {
if (props.readOnly) return;
if (!canFormatSqlForDatabaseType(props.databaseType)) return;
const currentView = view.value;
if (!currentView) return;
const originalState = currentView.state;
const selection = originalState.selection.main;
const formatsSelection = !selection.empty;
const from = formatsSelection ? selection.from : 0;
const to = formatsSelection ? selection.to : originalState.doc.length;
const source = originalState.sliceDoc(from, to);
if (!source.trim()) return;
try {
let formatted: string;
if (props.databaseType === "mongodb") {
formatted = formatMongoShellText(source, settingsStore.editorSettings.sqlFormatter);
} else {
const esRequest = detectAndFormatElasticsearchRequests(source, props.databaseType, settingsStore.editorSettings.sqlFormatter.tabWidth);
if (esRequest.kind === "elasticsearch") {
formatted = esRequest.formatted;
} else if (esRequest.kind === "unsupported") {
toast(t("toolbar.formatAutoDetectFailed"), 3000);
return;
} else {
const structured = detectAndFormatStructured(source, {
indentSize: settingsStore.editorSettings.sqlFormatter.tabWidth,
useTabs: settingsStore.editorSettings.sqlFormatter.useTabs,
});
if (structured.kind === "json" || structured.kind === "xml") {
formatted = structured.formatted;
} else if (structured.kind === "unsupported") {
// Keep invalid structured text untouched — the SQL formatter would
// silently corrupt XML-looking content.
toast(t("toolbar.formatAutoDetectFailed"), 3000);
return;
} else {
formatted = await formatSqlForEditing(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter);
}
}
}
if (view.value !== currentView || currentView.state !== originalState || currentView.state.sliceDoc(from, to) !== source) {
return;
}
if (formatted === source) return;
currentView.dispatch({
changes: { from, to, insert: formatted },
selection: formatsSelection ? { anchor: from, head: from + formatted.length } : { anchor: from + formatted.length },
});
} catch (e: any) {
emit("formatError", String(e?.message || e));
}
}
function compressCurrentSql() {
if (props.readOnly) return;
const currentView = view.value;
if (!currentView) return;
const originalState = currentView.state;
const selection = originalState.selection.main;
const compressesSelection = !selection.empty;
const from = compressesSelection ? selection.from : 0;
const to = compressesSelection ? selection.to : originalState.doc.length;
const source = originalState.sliceDoc(from, to);
if (!source.trim()) return;
const compressed = compressSqlText(source, props.formatDialect ?? props.dialect ?? "generic");
if (currentView !== view.value || currentView.state !== originalState || currentView.state.sliceDoc(from, to) !== source) {
return;
}
if (compressed === source) return;
currentView.dispatch({
changes: { from, to, insert: compressed },
selection: compressesSelection ? { anchor: from, head: from + compressed.length } : { anchor: from + compressed.length },
});
}
function droppedTableReference(event: DragEvent) {
return activeTableReferencePayloadValue() ?? parseTableReferencePayload(event.dataTransfer?.getData(DBX_TABLE_REFERENCE_MIME));
}
function hasDroppedTableReference(event: DragEvent) {
return !!activeTableReferencePayloadValue() || hasTableReferencePayloadType(event.dataTransfer?.types);
}
function insertTableReferencePayload(currentView: EditorViewType, payload: QueryEditorTableReferencePayload, coords?: { clientX: number; clientY: number }): boolean {
if (props.readOnly) return false;
const insertText = tableReferenceInsertText(payload, props.databaseType, {
tableNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
includeTableSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema,
});
const dropPos = coords ? currentView.posAtCoords({ x: coords.clientX, y: coords.clientY }) : null;
const selection = currentView.state.selection.main;
const from = dropPos ?? selection.from;
const to = dropPos == null && !selection.empty ? selection.to : from;
currentView.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: from + insertText.length },
scrollIntoView: true,
userEvent: "input.drop",
});
clearActiveTableReferencePayload(payload);
hideQueryEditorDropCaret();
currentView.focus();
return true;
}
function insertDroppedTableReference(currentView: EditorViewType, event: DragEvent): boolean {
const payload = droppedTableReference(event);
if (!payload) return false;
event.preventDefault();
event.stopPropagation();
return insertTableReferencePayload(currentView, payload, {
clientX: event.clientX,
clientY: event.clientY,
});
}
function onTableReferenceDropEvent(event: Event) {
const currentView = view.value;
if (!currentView || props.readOnly || !(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceDropDetail | undefined;
if (!detail?.payload) return;
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
insertTableReferencePayload(currentView, detail.payload, detail);
}
}
// --- 表引用拖拽悬停时的插入光标线(指针模拟拖拽经 window 事件驱动) ---
const queryEditorDropCaret = ref<{ left: number; top: number; height: number } | null>(null);
const queryEditorDropCaretStyle = computed(() => {
const caret = queryEditorDropCaret.value;
return caret ? { left: `${caret.left}px`, top: `${caret.top}px`, height: `${caret.height}px` } : {};
});
function showQueryEditorDropCaretAt(clientX: number, clientY: number) {
const currentView = view.value;
if (!currentView || props.readOnly || !editorRef.value) {
hideQueryEditorDropCaret();
return;
}
let dropPos: number | null = null;
try {
dropPos = currentView.posAtCoords({ x: clientX, y: clientY });
} catch {
dropPos = null;
}
if (dropPos == null) {
hideQueryEditorDropCaret();
return;
}
const coords = currentView.coordsAtPos(dropPos);
if (!coords) {
hideQueryEditorDropCaret();
return;
}
const rect = editorRef.value.getBoundingClientRect();
queryEditorDropCaret.value = { left: coords.left - rect.left, top: coords.top - rect.top, height: Math.max(coords.bottom - coords.top, 0) };
}
function hideQueryEditorDropCaret() {
queryEditorDropCaret.value = null;
}
function onTableReferenceHoverEvent(event: Event) {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceHoverDetail | undefined;
if (!detail) return;
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (!isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
hideQueryEditorDropCaret();
return;
}
showQueryEditorDropCaretAt(detail.clientX, detail.clientY);
}
function onTableReferenceDragEndEvent() {
hideQueryEditorDropCaret();
}
function registerTableReferenceDropListener() {
if (tableReferenceDropListenerRegistered) return;
window.addEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.addEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.addEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = true;
}
function unregisterTableReferenceDropListener() {
if (!tableReferenceDropListenerRegistered) return;
window.removeEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = false;
}
let completionEpoch = 0;
let tableCompletionRefreshActive = false;
let latestTableCompletionRefresh: (() => Promise<void>) | null = null;
function queueTableCompletionRefresh(task: () => Promise<void>): void {
latestTableCompletionRefresh = task;
if (tableCompletionRefreshActive) return;
tableCompletionRefreshActive = true;
void (async () => {
while (latestTableCompletionRefresh) {
const next = latestTableCompletionRefresh;
latestTableCompletionRefresh = null;
await next();
}
})().finally(() => {
tableCompletionRefreshActive = false;
});
}
let completionDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let typedCompletionActivationUntil = 0;
let suppressNextSqlCompletionAutoStartUntil = 0;
let activeCompletionOrigin: SqlCompletionTriggerOrigin | null = null;
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem;
interface BatchColumnSelectionCandidate {
key: string;
apply: string;
}
interface BatchColumnSelectionSession {
key: string;
mode: "select" | "insert";
qualifier?: string;
document: string;
from: number;
to: number;
replaceClosingQuote?: SqlCompletionItem["replaceClosingQuote"];
candidates: BatchColumnSelectionCandidate[];
selectedKeys: Set<string>;
completionOptions: Map<string, QueryCompletionOption>;
}
interface BatchColumnSelectionActionItem {
label: string;
filterText?: string;
type: "text";
detail: string;
boost: number;
batchColumnSelectionAction: true;
sessionKey: string;
}
type QueryCompletionOption = Completion & {
dbxBatchColumnSelection?: { sessionKey: string; candidateKey: string };
dbxBatchColumnSelectionAction?: { sessionKey: string };
};
let batchColumnSelectionSession: BatchColumnSelectionSession | null = null;
type BatchColumnSelectionCheckboxMarker = NonNullable<QueryCompletionOption["dbxBatchColumnSelection"]>;
interface BatchColumnSelectionDragState {
view: EditorViewType;
sessionKey: string;
pointerId: number;
anchorCandidateKey: string;
baseSelectedKeys: Set<string>;
selected: boolean;
focusCandidateKey: string;
previousUserSelect: string;
scrollElement: HTMLElement | null;
pointerClientX: number;
pointerClientY: number;
autoScrollFrame: number;
}
const batchColumnSelectionCheckboxMarkers = new WeakMap<HTMLElement, BatchColumnSelectionCheckboxMarker>();
const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, string>();
const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();
let batchColumnSelectionDragState: BatchColumnSelectionDragState | null = null;
let batchColumnSelectionRefreshCleanup: (() => void) | null = null;
let batchColumnSelectionExpandedRendering = false;
let batchColumnSelectionRenderLimitTimer: number | null = null;
function cancelBatchColumnSelectionRefresh() {
batchColumnSelectionRefreshCleanup?.();
batchColumnSelectionRefreshCleanup = null;
}
function setBatchColumnSelectionExpandedRendering(expanded: boolean) {
if (batchColumnSelectionExpandedRendering === expanded) return;
batchColumnSelectionExpandedRendering = expanded;
if (batchColumnSelectionRenderLimitTimer !== null) window.clearTimeout(batchColumnSelectionRenderLimitTimer);
const currentView = view.value;
if (!currentView || !completionComp || !buildSqlCompletionExtension) return;
batchColumnSelectionRenderLimitTimer = window.setTimeout(() => {
batchColumnSelectionRenderLimitTimer = null;
if (view.value !== currentView || !completionComp || !buildSqlCompletionExtension) return;
currentView.dispatch({ effects: completionComp.reconfigure(buildSqlCompletionExtension()) });
if (expanded && codeMirrorCompletionStatus?.(currentView.state) === "active") codeMirrorStartCompletion?.(currentView);
}, 0);
}
function setBatchColumnSelectionValue(sessionKey: string, candidateKey: string, selected: boolean, checkbox?: HTMLInputElement) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey || !session.candidates.some((candidate) => candidate.key === candidateKey)) return;
if (selected) session.selectedKeys.add(candidateKey);
else session.selectedKeys.delete(candidateKey);
if (checkbox) checkbox.checked = selected;
}
function updateBatchColumnSelectionActionLabel(view: EditorViewType, sessionKey: string) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey) return;
const label = t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size });
const tooltipParent = batchColumnSelectionTooltipParents.get(view) ?? view.dom;
tooltipParent.querySelectorAll<HTMLElement>(".cm-batch-column-selection-action-marker").forEach((marker) => {
if (batchColumnSelectionActionMarkers.get(marker) !== sessionKey) return;
const element = marker.closest("li")?.querySelector<HTMLElement>(".cm-completionLabel");
if (!element) return;
if (element.textContent !== label) element.textContent = label;
});
}
function scheduleBatchColumnSelectionRefresh(view: EditorViewType, sessionKey: string, focusCandidateKey: string, scrollState?: { element: HTMLElement | null; top: number; left: number }) {
cancelBatchColumnSelectionRefresh();
const preservedScroll =
scrollState ??
(() => {
const element =
visibleBatchColumnSelectionCheckboxes(sessionKey)
.map(({ checkbox }) => batchColumnSelectionScrollElement(checkbox))
.find((candidate): candidate is HTMLElement => !!candidate) ?? null;
return { element, top: element?.scrollTop ?? 0, left: element?.scrollLeft ?? 0 };
})();
const restoreScroll = () => {
const currentElement =
visibleBatchColumnSelectionCheckboxes(sessionKey)
.map(({ checkbox }) => batchColumnSelectionScrollElement(checkbox))
.find((candidate): candidate is HTMLElement => !!candidate) ?? preservedScroll.element;
if (!currentElement) return;
currentElement.scrollTop = Math.min(preservedScroll.top, Math.max(0, currentElement.scrollHeight - currentElement.clientHeight));
currentElement.scrollLeft = Math.min(preservedScroll.left, Math.max(0, currentElement.scrollWidth - currentElement.clientWidth));
};
let restoreAttempts = 0;
let restoreFrame = 0;
let restoreTimer = 0;
let restoreStartTimer = 0;
let stopped = false;
const restoreObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(restoreScroll);
const stopRestore = () => {
if (stopped) return;
stopped = true;
restoreObserver?.disconnect();
if (restoreFrame) window.cancelAnimationFrame(restoreFrame);
if (restoreTimer) window.clearTimeout(restoreTimer);
if (restoreStartTimer) window.clearTimeout(restoreStartTimer);
if (batchColumnSelectionRefreshCleanup === stopRestore) batchColumnSelectionRefreshCleanup = null;
};
batchColumnSelectionRefreshCleanup = stopRestore;
const restoreNextFrame = () => {
if (batchColumnSelectionSession?.key !== sessionKey) {
stopRestore();
return;
}
restoreScroll();
restoreAttempts += 1;
if (restoreAttempts < 60) restoreFrame = window.requestAnimationFrame(restoreNextFrame);
else stopRestore();
};
restoreStartTimer = window.setTimeout(() => {
if (stopped || batchColumnSelectionSession?.key !== sessionKey) {
stopRestore();
return;
}
restoreObserver?.observe(document.body, { childList: true, subtree: true });
// Keep CodeMirror's virtualized range anchored to the item where the drag ended.
const focusIndex =
codeMirrorCurrentCompletions?.(view.state).findIndex((completion) => {
const marker = (completion as QueryCompletionOption).dbxBatchColumnSelection;
return marker?.sessionKey === sessionKey && marker.candidateKey === focusCandidateKey;
}) ?? -1;
if (focusIndex >= 0 && codeMirrorSetSelectedCompletion) view.dispatch({ effects: codeMirrorSetSelectedCompletion(focusIndex) });
codeMirrorStartCompletion?.(view);
restoreNextFrame();
restoreTimer = window.setTimeout(stopRestore, 1500);
}, 0);
}
function finishBatchColumnSelectionDrag(refresh = true) {
const state = batchColumnSelectionDragState;
if (!state) return;
const scrollState = state.scrollElement ? { element: state.scrollElement, top: state.scrollElement.scrollTop, left: state.scrollElement.scrollLeft } : undefined;
batchColumnSelectionDragState = null;
if (state.autoScrollFrame) window.cancelAnimationFrame(state.autoScrollFrame);
window.removeEventListener("pointermove", onBatchColumnSelectionPointerMove, true);
window.removeEventListener("pointerup", onBatchColumnSelectionPointerUp, true);
window.removeEventListener("pointercancel", onBatchColumnSelectionPointerCancel, true);
window.removeEventListener("blur", onBatchColumnSelectionPointerCancel, true);
document.body.style.userSelect = state.previousUserSelect;
if (refresh) scheduleBatchColumnSelectionRefresh(state.view, state.sessionKey, state.focusCandidateKey, scrollState);
}
function clearBatchColumnSelectionSession() {
finishBatchColumnSelectionDrag(false);
cancelBatchColumnSelectionRefresh();
setBatchColumnSelectionExpandedRendering(false);
batchColumnSelectionSession = null;
}
function isBatchColumnSelectionAction(item: QueryCompletionItem | BatchColumnSelectionActionItem): item is BatchColumnSelectionActionItem {
return "batchColumnSelectionAction" in item && item.batchColumnSelectionAction === true;
}
function batchColumnSelectionCandidateKey(item: SqlCompletionItem): string {
return `${item.label}\u0000${item.apply ?? item.label}`;
}
function prepareBatchColumnSelectionSession(items: SqlCompletionItem[], document: string, from: number, to: number): BatchColumnSelectionSession | null {
const selectableItems = items.filter((item) => item.type === "column" && item.batchSelectionMode && item.apply);
if (selectableItems.length === 0) {
clearBatchColumnSelectionSession();
return null;
}
const mode = selectableItems[0]!.batchSelectionMode!;
const candidates = selectableItems.filter((item) => item.batchSelectionMode === mode).map((item) => ({ key: batchColumnSelectionCandidateKey(item), apply: item.apply! }));
const key = `${mode}\u0000${from}\u0000${to}\u0000${document}`;
if (!batchColumnSelectionSession || batchColumnSelectionSession.key !== key) {
batchColumnSelectionSession = {
key,
mode,
qualifier: selectableItems[0]!.batchSelectionQualifier,
document,
from,
to,
replaceClosingQuote: selectableItems[0]!.replaceClosingQuote,
candidates,
selectedKeys: new Set(),
completionOptions: new Map(),
};
setBatchColumnSelectionExpandedRendering(true);
return batchColumnSelectionSession;
}
setBatchColumnSelectionExpandedRendering(true);
batchColumnSelectionSession.candidates = candidates;
batchColumnSelectionSession.qualifier = selectableItems[0]!.batchSelectionQualifier;
const candidateKeys = new Set(candidates.map((candidate) => candidate.key));
batchColumnSelectionSession.selectedKeys = new Set([...batchColumnSelectionSession.selectedKeys].filter((candidateKey) => candidateKeys.has(candidateKey)));
batchColumnSelectionSession.completionOptions = new Map([...batchColumnSelectionSession.completionOptions].filter(([candidateKey]) => candidateKeys.has(candidateKey)));
return batchColumnSelectionSession;
}
function batchColumnSelectionMarkerForItem(item: QueryCompletionItem): QueryCompletionOption["dbxBatchColumnSelection"] | undefined {
if (!("batchSelectionMode" in item) || item.type !== "column" || !item.batchSelectionMode || !item.apply) return undefined;
const session = batchColumnSelectionSession;
if (!session || session.mode !== item.batchSelectionMode) return undefined;
const candidateKey = batchColumnSelectionCandidateKey(item);
if (!session.candidates.some((candidate) => candidate.key === candidateKey)) return undefined;
return { sessionKey: session.key, candidateKey };
}
function cacheBatchColumnSelectionOption(marker: QueryCompletionOption["dbxBatchColumnSelection"], option: QueryCompletionOption): QueryCompletionOption {
if (!marker || !batchColumnSelectionSession || batchColumnSelectionSession.key !== marker.sessionKey) return option;
const cached = batchColumnSelectionSession.completionOptions.get(marker.candidateKey);
if (cached) return cached;
batchColumnSelectionSession.completionOptions.set(marker.candidateKey, option);
return option;
}
function toggleBatchColumnSelection(view: EditorViewType, sessionKey: string, candidateKey: string) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey || !session.candidates.some((candidate) => candidate.key === candidateKey)) return;
setBatchColumnSelectionValue(sessionKey, candidateKey, !session.selectedKeys.has(candidateKey));
updateBatchColumnSelectionActionLabel(view, sessionKey);
// Reopen the list so the action row and all virtualized checkboxes reflect the new state.
scheduleBatchColumnSelectionRefresh(view, sessionKey, candidateKey);
}
function toggleSelectedBatchColumnSelection(view: EditorViewType): boolean {
const completion = codeMirrorSelectedCompletion?.(view.state) as QueryCompletionOption | null | undefined;
const marker = completion?.dbxBatchColumnSelection;
if (!marker) return false;
toggleBatchColumnSelection(view, marker.sessionKey, marker.candidateKey);
return true;
}
function batchColumnSelectionMarkerAtPoint(clientX: number, clientY: number): { checkbox: HTMLInputElement; marker: BatchColumnSelectionCheckboxMarker } | null {
const target = document.elementFromPoint(clientX, clientY);
if (!(target instanceof HTMLElement)) return null;
const checkbox = target.closest<HTMLInputElement>("input.cm-batch-column-selection-checkbox") ?? target.closest<HTMLElement>("[role='option']")?.querySelector<HTMLInputElement>("input.cm-batch-column-selection-checkbox") ?? null;
if (!checkbox) return null;
const marker = batchColumnSelectionCheckboxMarkers.get(checkbox);
return marker ? { checkbox, marker } : null;
}
function batchColumnSelectionScrollElement(checkbox: HTMLElement): HTMLElement | null {
let current = checkbox.parentElement;
while (current && current !== document.body) {
const style = window.getComputedStyle(current);
if (current.scrollHeight > current.clientHeight && /(auto|scroll|overlay)/.test(style.overflowY)) return current;
current = current.parentElement;
}
return null;
}
function visibleBatchColumnSelectionCheckboxes(sessionKey: string): Array<{ checkbox: HTMLInputElement; marker: BatchColumnSelectionCheckboxMarker }> {
return Array.from(document.querySelectorAll<HTMLInputElement>("input.cm-batch-column-selection-checkbox")).flatMap((checkbox) => {
const marker = batchColumnSelectionCheckboxMarkers.get(checkbox);
return marker?.sessionKey === sessionKey ? [{ checkbox, marker }] : [];
});
}
function updateBatchColumnSelectionAtPoint(state: BatchColumnSelectionDragState, clientX: number, clientY: number) {
const hit = batchColumnSelectionMarkerAtPoint(clientX, clientY);
if (!hit || hit.marker.sessionKey !== state.sessionKey) return;
state.focusCandidateKey = hit.marker.candidateKey;
const session = batchColumnSelectionSession;
if (!session) return;
const visibleCheckboxes = visibleBatchColumnSelectionCheckboxes(state.sessionKey);
const anchorIndex = visibleCheckboxes.findIndex(({ marker }) => marker.candidateKey === state.anchorCandidateKey);
const focusIndex = visibleCheckboxes.findIndex(({ marker }) => marker.candidateKey === hit.marker.candidateKey);
if (anchorIndex < 0 || focusIndex < 0) return;
const nextSelectedKeys = new Set(state.baseSelectedKeys);
const rangeStart = Math.min(anchorIndex, focusIndex);
const rangeEnd = Math.max(anchorIndex, focusIndex);
for (let index = rangeStart; index <= rangeEnd; index++) {
const candidate = visibleCheckboxes[index]?.marker;
if (!candidate) continue;
if (state.selected) nextSelectedKeys.add(candidate.candidateKey);
else nextSelectedKeys.delete(candidate.candidateKey);
}
session.selectedKeys = nextSelectedKeys;
visibleCheckboxes.forEach(({ checkbox, marker }) => {
checkbox.checked = nextSelectedKeys.has(marker.candidateKey);
});
updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);
}
const BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX = 36;
const BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX = 20;
function runBatchColumnSelectionAutoScroll() {
const state = batchColumnSelectionDragState;
if (!state) return;
state.autoScrollFrame = 0;
const scroller = state.scrollElement;
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
if (state.pointerClientX < rect.left || state.pointerClientX > rect.right) return;
let delta = 0;
if (state.pointerClientY < rect.top + BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX) {
delta = -BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX * Math.min(1, (rect.top + BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX - state.pointerClientY) / BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX);
} else if (state.pointerClientY > rect.bottom - BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX) {
delta = BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX * Math.min(1, (state.pointerClientY - (rect.bottom - BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX)) / BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX);
}
if (!delta) return;
const previousScrollTop = scroller.scrollTop;
scroller.scrollTop = Math.max(0, Math.min(scroller.scrollHeight - scroller.clientHeight, scroller.scrollTop + delta));
if (scroller.scrollTop === previousScrollTop) return;
updateBatchColumnSelectionAtPoint(state, state.pointerClientX, state.pointerClientY);
state.autoScrollFrame = window.requestAnimationFrame(runBatchColumnSelectionAutoScroll);
}
function scheduleBatchColumnSelectionAutoScroll(state: BatchColumnSelectionDragState) {
if (!state.scrollElement || state.autoScrollFrame) return;
state.autoScrollFrame = window.requestAnimationFrame(runBatchColumnSelectionAutoScroll);
}
function onBatchColumnSelectionPointerMove(event: PointerEvent) {
const state = batchColumnSelectionDragState;
if (!state || event.pointerId !== state.pointerId) return;
if ((event.buttons & 1) === 0) {
finishBatchColumnSelectionDrag();
return;
}
state.pointerClientX = event.clientX;
state.pointerClientY = event.clientY;
updateBatchColumnSelectionAtPoint(state, event.clientX, event.clientY);
scheduleBatchColumnSelectionAutoScroll(state);
}
function onBatchColumnSelectionPointerUp(event: PointerEvent) {
const state = batchColumnSelectionDragState;
if (!state || event.pointerId !== state.pointerId) return;
finishBatchColumnSelectionDrag();
}
function onBatchColumnSelectionPointerCancel() {
finishBatchColumnSelectionDrag();
}
function startBatchColumnSelectionDrag(view: EditorViewType, marker: BatchColumnSelectionCheckboxMarker, checkbox: HTMLInputElement, event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return;
finishBatchColumnSelectionDrag(false);
cancelBatchColumnSelectionRefresh();
const session = batchColumnSelectionSession;
if (!session || session.key !== marker.sessionKey) return;
if (!session.candidates.some((candidate) => candidate.key === marker.candidateKey)) return;
const selected = !session.selectedKeys.has(marker.candidateKey);
const baseSelectedKeys = new Set(session.selectedKeys);
setBatchColumnSelectionValue(marker.sessionKey, marker.candidateKey, selected, checkbox);
batchColumnSelectionDragState = {
view,
sessionKey: marker.sessionKey,
pointerId: event.pointerId,
anchorCandidateKey: marker.candidateKey,
baseSelectedKeys,
selected,
focusCandidateKey: marker.candidateKey,
previousUserSelect: document.body.style.userSelect,
scrollElement: batchColumnSelectionScrollElement(checkbox),
pointerClientX: event.clientX,
pointerClientY: event.clientY,
autoScrollFrame: 0,
};
updateBatchColumnSelectionActionLabel(view, marker.sessionKey);
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onBatchColumnSelectionPointerMove, true);
window.addEventListener("pointerup", onBatchColumnSelectionPointerUp, true);
window.addEventListener("pointercancel", onBatchColumnSelectionPointerCancel, true);
window.addEventListener("blur", onBatchColumnSelectionPointerCancel, true);
}
function renderBatchColumnSelectionCheckbox(completion: Completion, _state: import("@codemirror/state").EditorState, currentView: EditorViewType): Node | null {
const marker = (completion as QueryCompletionOption).dbxBatchColumnSelection;
const session = batchColumnSelectionSession;
if (!marker || !session || session.key !== marker.sessionKey) return null;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "cm-batch-column-selection-checkbox";
checkbox.checked = session.selectedKeys.has(marker.candidateKey);
checkbox.tabIndex = -1;
checkbox.setAttribute("aria-label", completion.displayLabel ?? completion.label);
batchColumnSelectionCheckboxMarkers.set(checkbox, marker);
checkbox.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
startBatchColumnSelectionDrag(currentView, marker, checkbox, event);
});
checkbox.addEventListener("mousedown", (event) => {
// CodeMirror accepts the completion on a list-item mousedown. Keep this
// interaction local to the checkbox so a field can be toggled repeatedly.
event.preventDefault();
event.stopPropagation();
});
checkbox.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
return checkbox;
}
function renderBatchColumnSelectionActionMarker(completion: Completion): Node | null {
const action = (completion as QueryCompletionOption).dbxBatchColumnSelectionAction;
if (!action) return null;
const marker = document.createElement("span");
marker.className = "cm-batch-column-selection-action-marker";
marker.hidden = true;
batchColumnSelectionActionMarkers.set(marker, action.sessionKey);
return marker;
}
function applyBatchColumnSelection(view: EditorViewType, item: BatchColumnSelectionActionItem, from: number, to: number) {
const session = batchColumnSelectionSession;
if (!session || session.key !== item.sessionKey || session.document !== view.state.doc.toString()) return;
const selected = session.candidates.filter((candidate) => session.selectedKeys.has(candidate.key));
if (selected.length === 0) {
toast(t("editor.completion.selectColumnsBeforeInsert"), 3000);
return;
}
const columns = batchColumnSelectionColumnList(
selected.map((candidate) => candidate.apply),
session.mode,
session.qualifier,
);
let replaceTo = batchColumnSelectionReplaceTo({
to,
mode: session.mode,
nextCharacter: view.state.sliceDoc(to, to + 1),
replaceClosingQuote: session.replaceClosingQuote,
});
let insert = columns;
if (session.mode === "insert") {
const replacement = batchColumnSelectionInsertReplacement({
document: view.state.doc.toString(),
to,
columns,
valuesKeyword: settingsStore.editorSettings.sqlFormatter.keywordCase === "lower" ? "values" : "VALUES",
valueCount: selected.length,
});
replaceTo = replacement.replaceTo;
insert = replacement.insert;
}
if (session.mode === "insert" && !codeMirrorSnippetCompletion) {
clearBatchColumnSelectionSession();
return;
}
clearBatchColumnSelectionSession();
markCompletionAccepted(item);
if (session.mode === "insert") {
const snippet = codeMirrorSnippetCompletion(insert, { label: item.label });
if (typeof snippet.apply === "function") {
snippet.apply(view, snippet, from, replaceTo);
return;
}
}
view.dispatch({
changes: { from, to: replaceTo, insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
});
}
function applySelectedBatchColumnSelection(view: EditorViewType): boolean {
const session = batchColumnSelectionSession;
if (!session || !isBatchColumnSelectionCompletionActive(codeMirrorCompletionStatus?.(view.state) ?? null) || session.document !== view.state.doc.toString() || session.selectedKeys.size === 0) return false;
applyBatchColumnSelection(
view,
{
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
type: "text",
detail: t("editor.completion.insertSelectedColumnsDetail"),
boost: -1000,
batchColumnSelectionAction: true,
sessionKey: session.key,
},
session.from,
session.to,
);
return true;
}
function markTypedCompletionActivation() {
typedCompletionActivationUntil = Date.now() + 500;
}
function isTypedCompletionActivation(explicit: boolean) {
return explicit && typedCompletionActivationUntil >= Date.now();
}
function markCompletionAccepted(item: QueryCompletionItem | BatchColumnSelectionActionItem) {
clearBatchColumnSelectionSession();
const shouldContinueCompletion = shouldChainSqlCompletionAfterAccept(item) || (props.databaseType === "sqlserver" && item.type === "keyword" && item.label.toUpperCase() === "USE");
suppressNextSqlCompletionAutoStartUntil = shouldContinueCompletion ? 0 : Date.now() + 750;
completionEpoch++;
}
function consumeSqlCompletionAutoStartSuppression() {
if (suppressNextSqlCompletionAutoStartUntil < Date.now()) {
suppressNextSqlCompletionAutoStartUntil = 0;
return false;
}
suppressNextSqlCompletionAutoStartUntil = 0;
return true;
}
function buildCompletionResult(items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>, from: number, validFor?: RegExp, prefix?: string) {
if (items.length === 0) return null;
const bypassFilter = !!prefix && shouldBypassCompletionFilter(prefix, items);
const resultItems = bypassFilter && prefix ? completionItemsForBypassedFilter(prefix, items) : items;
return {
from,
// Keep CodeMirror's live filtering enabled so an already-open menu follows the typed prefix.
options: resultItems.map((item) => completionOptionForItem(item)),
validFor,
// When bypassing CodeMirror's matcher, supply our own highlight ranges so
// the matched characters (Han substring or pinyin initials) stay marked.
// Ranges must target the rendered text, which is displayLabel when set.
...(bypassFilter && prefix ? { filter: false as const, getMatch: (option: { label: string; displayLabel?: string }) => completionMatchRanges(option.displayLabel ?? option.label, prefix) } : {}),
};
}
function buildSqlCompletionResult(items: SqlCompletionItem[], completionContext: SqlCompletionContext, fullDoc: string, position: number) {
const replacement = prepareSqlCompletionReplacement(fullDoc, position, completionContext, items);
const session = prepareBatchColumnSelectionSession(replacement.items, fullDoc, replacement.from, position);
const action: BatchColumnSelectionActionItem | undefined = session
? {
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
filterText: completionContext.prefix,
type: "text",
detail: t("editor.completion.insertSelectedColumnsDetail"),
// Keep ordinary single-field completion as the default Enter target.
boost: -1000,
batchColumnSelectionAction: true,
sessionKey: session.key,
}
: undefined;
return buildCompletionResult(action ? [...replacement.items, action] : replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
}
// CodeMirror's built-in matcher only matches single-character queries against
// the label start, and cannot match pinyin initials against Han labels. Our
// provider already filters and ranks items itself (substring + pinyin), so
// skip the second-stage filter exactly in the cases it would break.
function shouldBypassCompletionFilter(prefix: string, items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>): boolean {
if (!prefix) return false;
if (/\p{Script=Han}/u.test(prefix)) return true;
return /^[a-z0-9]+$/i.test(prefix) && items.some((item) => /\p{Script=Han}/u.test(item.label));
}
function completionItemsForBypassedFilter(prefix: string, items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>): Array<QueryCompletionItem | BatchColumnSelectionActionItem> {
if ([...prefix].length !== 1 || !/^[a-z0-9]$/i.test(prefix)) return items;
const normalized = prefix.toLowerCase();
return items.filter((item) => {
// The confirmation row must remain available while typing a Han/pinyin
// prefix, otherwise checked columns cannot be applied from that menu.
if (isBatchColumnSelectionAction(item)) return true;
const renderedLabel = "displayLabel" in item && typeof item.displayLabel === "string" ? item.displayLabel : item.label;
return /\p{Script=Han}/u.test(item.label) || renderedLabel.toLowerCase().startsWith(normalized);
});
}
function localCompletionDatabaseNames(completionContext: ReturnType<typeof getSqlCompletionContext>): string[] {
if (!supportsDatabaseNameCompletion(props.databaseType) || !completionContext.suggestTables || completionContext.insertTable || !props.connectionId) return [];
return connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES);
}
function mayCompleteDatabaseSchemaQualifier(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
if (!supportsDatabaseNameCompletion(props.databaseType) || !supportsDatabaseSchemaQualifierCompletion() || !completionContext.suggestTables || completionContext.insertTable) return false;
return (completionContext.qualifierParts?.filter(Boolean).length ?? completionContext.qualifier?.split(".").filter(Boolean).length ?? 0) === 1;
}
function localCompletionSchemasForDatabaseDisambiguation(completionContext: ReturnType<typeof getSqlCompletionContext>, databaseNames: string[], scope?: CompletionMetadataScope): string[] {
const currentDatabase = scope?.database ?? props.database;
const currentSchema = scope?.schema ?? props.schema;
if (!props.connectionId || currentDatabase == null || !mayCompleteDatabaseSchemaQualifier(completionContext)) return [];
const database = resolveSqlCompletionSchemaLookupDatabase({
supportsDatabaseSchemaQualifier: true,
completionContext,
knownDatabases: databaseNames,
});
if (!database) return [];
return mergeSqlCompletionQualifierNames(currentSchema ? [currentSchema] : [], connectionStore.lookupLocalCompletionSchemas(props.connectionId, currentDatabase, completionContext.qualifier, MAX_COMPLETION_TABLES));
}
function shouldInsertSqlCompletionSpace(): boolean {
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "victoriametrics";
}
// Snippet expansion normally follows from the item type; a provider can also
// opt a differently-typed item in so its `${}` fields still expand on accept.
function shouldApplyCompletionAsSnippet(item: QueryCompletionItem): boolean {
if ("applyAsSnippet" in item && item.applyAsSnippet === true) return true;
return item.type === "snippet" || item.type === "function";
}
function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectionActionItem) {
const filterText = "filterText" in item && typeof item.filterText === "string" ? item.filterText : undefined;
const labelPresentation = completionLabelPresentation(item.label, filterText);
const sortText = "sortText" in item && typeof item.sortText === "string" ? item.sortText : labelPresentation.sortText;
if (isBatchColumnSelectionAction(item)) {
return {
...labelPresentation,
...(sortText ? { sortText } : {}),
dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey },
type: item.type,
detail: item.detail,
boost: item.boost,
apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) {
applyBatchColumnSelection(view, item, from, to);
},
};
}
const record = () => {
recordCompletionSelection(item.label, item.type);
};
const batchColumnSelection = batchColumnSelectionMarkerForItem(item);
if (shouldApplyCompletionAsSnippet(item) && item.apply) {
const completion = codeMirrorSnippetCompletion(item.apply, {
...labelPresentation,
...(sortText ? { sortText } : {}),
type: item.type,
detail: item.detail,
info: item.info,
boost: item.boost,
});
const originalApply = completion.apply;
return cacheBatchColumnSelectionOption(batchColumnSelection, {
...completion,
...(batchColumnSelection ? { dbxBatchColumnSelection: batchColumnSelection } : {}),
apply(view: EditorViewType, completionItem: unknown, from: number, to: number) {
record();
markCompletionAccepted(item);
const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to;
if (typeof originalApply === "function") {
originalApply(view, completionItem as never, from, replaceTo);
} else {
const insert = String(originalApply ?? item.label);
view.dispatch({
changes: { from, to: replaceTo, insert },
selection: { anchor: from + insert.length },
});
}
if (props.databaseType === "mongodb") {
const position = view.state.selection.main.head;
if (getMongoCompletionContext(view.state.doc.toString(), position).mode === "collectionRef") {
scheduleSqlCompletionStart(view, 50);
}
}
},
});
}
return cacheBatchColumnSelectionOption(batchColumnSelection, {
...labelPresentation,
...(sortText ? { sortText } : {}),
...(batchColumnSelection ? { dbxBatchColumnSelection: batchColumnSelection } : {}),
type: item.type,
detail: item.detail,
info: item.info,
boost: item.boost,
apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) {
record();
markCompletionAccepted(item);
const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to;
const insert = appendSqlCompletionSpace(item.apply ?? item.label, {
enabled: ("appendSpace" in item && item.appendSpace === true) || (shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion),
itemType: item.type,
nextCharacter: view.state.sliceDoc(replaceTo, replaceTo + 1),
});
if (codeMirrorInsertCompletionText) {
view.dispatch(codeMirrorInsertCompletionText(view.state, insert, from, replaceTo));
} else {
view.dispatch({
changes: { from, to: replaceTo, insert },
selection: { anchor: from + insert.length },
});
}
},
});
}
async function provideElasticsearchCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
if (!props.connectionId) return null;
const epoch = ++completionEpoch;
const fullDoc = currentState.doc.toString();
if (!explicit && !shouldAutoOpenElasticsearchCompletion(fullDoc, position)) return null;
const completionContext = getElasticsearchCompletionContext(fullDoc, position);
let indices: string[] = [];
let fields: ElasticsearchCompletionField[] = [];
if (props.database != null && completionContext.mode === "path") {
try {
indices = await connectionStore.listElasticsearchCompletionIndices(props.connectionId, props.database);
} catch {
indices = [];
}
}
if (elasticsearchCompletionNeedsFields(completionContext) && completionContext.index) {
try {
fields = await connectionStore.listElasticsearchCompletionFields(props.connectionId, completionContext.index);
} catch {
fields = [];
}
}
if (epoch !== completionEpoch) return null;
const items = buildElasticsearchCompletionItemsFromContext(completionContext, { indices, fields });
return buildCompletionResult(items, completionContext.from, getElasticsearchCompletionResultValidFor());
}
async function provideRedisCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
if (!props.connectionId) return null;
const epoch = ++completionEpoch;
const fullDoc = currentState.doc.toString();
if (!explicit && !shouldAutoOpenRedisCompletion(fullDoc, position)) return null;
let commands;
try {
commands = await connectionStore.listRedisCompletionCommandDocs(props.connectionId, props.database ?? "0");
} catch {
// Completion is deliberately instance-driven: do not substitute a bundled
// command list when the server does not expose command metadata.
return null;
}
if (epoch !== completionEpoch) return null;
const completionInput = { commands };
const completionContext = getRedisCompletionContext(fullDoc, position, completionInput);
// Key-name completion needs a reliable db index; props.database may briefly be "" on
// the New Query path before the active db resolves, and only key-argument commands warrant it.
let keys: string[] = [];
if (completionContext.mode === "argument" && props.database && takesKeyArgument(completionContext.commandName, completionInput, completionContext.argumentIndex, completionContext.argumentValues)) {
try {
keys = await connectionStore.listRedisCompletionKeys(props.connectionId, props.database);
} catch {
keys = [];
}
}
if (epoch !== completionEpoch) return null;
const items = buildRedisCompletionItemsFromContext(completionContext, {
keys,
commands,
});
if (items.length === 0) return null;
// Use the built-in filter (the default) so typing narrows the list and moves
// the selection synchronously. `filter: false` + `validFor` are mutually
// exclusive (the latter is ignored), which would leave the menu frozen while
// typing — hence we build the result here instead of via buildCompletionResult.
return {
from: completionContext.from,
options: items.map((item) => completionOptionForItem(item)),
validFor: getRedisCompletionResultValidFor(),
};
}
async function provideMongoCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
if (!props.connectionId) return null;
const epoch = ++completionEpoch;
const fullDoc = currentState.doc.toString();
if (!explicit && !shouldAutoOpenMongoCompletion(fullDoc, position)) return null;
const completionContext = getMongoCompletionContext(fullDoc, position);
let collections: string[] = [];
let fields: Awaited<ReturnType<typeof connectionStore.listMongoCompletionFields>> = [];
if (props.database && mongoCompletionNeedsCollections(completionContext.mode)) {
try {
collections = await connectionStore.listMongoCompletionCollections(props.connectionId, props.database);
} catch {
collections = [];
}
}
if (props.database && mongoCompletionNeedsFields(completionContext.mode) && completionContext.collection) {
try {
fields = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, completionContext.collection);
} catch {
fields = [];
}
}
if (epoch !== completionEpoch) return null;
const items = buildMongoCompletionItemsFromContext(completionContext, {
collections,
fields,
});
if (items.length === 0) return null;
return {
from: completionContext.from,
options: items.map((item) => completionOptionForItem(item)),
validFor: getMongoCompletionResultValidFor(completionContext),
};
}
async function provideSqlCompletions(context: CompletionContext) {
const currentState = context.state;
const position = context.pos;
const explicit = context.explicit;
const typedActivation = isTypedCompletionActivation(explicit);
if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null;
if (!props.connectionId) return null;
const fullDoc = currentState.doc.toString();
if (props.databaseType === "mongodb") {
return provideMongoCompletions(currentState, position, explicit);
}
if (props.databaseType === "meilisearch") return null;
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
if (!isSqlLikeCompletionStatement(fullDoc, position, sqlCompletionDialectOptions())) {
return provideElasticsearchCompletions(currentState, position, explicit);
}
}
if (props.databaseType === "redis") {
return provideRedisCompletions(currentState, position, explicit);
}
if (props.databaseType === "victoriametrics") return null;
const hasDatabase = props.database != null;
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
const epoch = ++completionEpoch;
try {
// 1. Suppressed context (comment / string literal) rejects everything, including explicit.
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: currentState }) && !sequenceLiteralContext) return null;
// 2. Determine completion origin (session-level marker).
activeCompletionOrigin = originForSqlCompletionProvider(activeCompletionOrigin, context.explicit);
const origin = activeCompletionOrigin;
// 3. Explicit (manual shortcut) -> always proceed. No mode gating.
// 4. For typing sessions, apply mode gating with lazy fact computation.
const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({
sql: fullDoc,
cursor: position,
databaseType: props.databaseType,
});
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
if (origin !== "explicit") {
const mode = settingsStore.editorSettings.completionTriggerMode;
// manual: never auto-open. Return before computing any context.
if (mode === "manual") return null;
// require-prefix: only compute local facts (no positionalEligible).
if (mode === "require-prefix") {
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin,
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
};
if (!shouldAllowSqlCompletionTrigger(mode, facts)) return null;
}
// positional: compute positionalEligible (lazy).
if (mode === "positional") {
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const facts: SqlCompletionTriggerFacts = {
origin,
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
positionalEligible,
};
if (!shouldAllowSqlCompletionTrigger(mode, facts)) return null;
}
}
if (useDatabaseCompletion) {
const currentDatabase = props.database ?? "";
if (!currentDatabase) return null;
let sqlServerContext: SqlServerCompletionContext;
try {
sqlServerContext = await connectionStore.getSqlServerCompletionContext(props.connectionId, currentDatabase);
} catch {
// Without a server-reported capability, do not suggest a USE target
// that the current SQL Server session may be unable to switch to.
return null;
}
if (sqlServerContext.supports_session_database_switch) {
try {
await connectionStore.listCompletionDatabases(props.connectionId);
} catch {
// Keep locally indexed database names available when metadata refresh fails.
}
}
if (epoch !== completionEpoch) return null;
const databaseNames = sqlServerUseCompletionDatabaseNames({
databaseNames: connectionStore.lookupLocalCompletionDatabases(props.connectionId, useDatabaseCompletion.prefix, MAX_COMPLETION_TABLES),
currentDatabase,
supportsSessionDatabaseSwitch: sqlServerContext.supports_session_database_switch,
});
const items = buildSqlServerUseDatabaseCompletionItems(databaseNames, useDatabaseCompletion);
return buildCompletionResult(items, useDatabaseCompletion.from, undefined, useDatabaseCompletion.prefix);
}
if (databaseLinkContext) {
if (!hasDatabase) return null;
const links = await connectionStore.listOracleDatabaseLinks(props.connectionId, props.database!);
if (epoch !== completionEpoch) return null;
return {
from: databaseLinkContext.from,
to: databaseLinkContext.to,
options: oracleDatabaseLinkCompletionItems(links, databaseLinkContext.prefix).map((item) => ({
...item,
apply(editor: EditorViewType, _completion: unknown, from: number, to: number) {
markCompletionAccepted({ label: item.label, type: "text", boost: 0 });
// Keep the entire link suffix intact and use the normal completion
// transaction so accepting a link does not immediately reopen the menu.
if (codeMirrorInsertCompletionText) editor.dispatch(codeMirrorInsertCompletionText(editor.state, item.apply, from, to));
else editor.dispatch({ changes: { from, to, insert: item.apply }, selection: { anchor: from + item.apply.length } });
},
})),
validFor: /^[A-Za-z0-9_$#.]*$/,
};
}
if (sequenceLiteralContext) {
if (!hasDatabase) return null;
const sequences = await connectionStore.listCompletionObjects(props.connectionId, props.database!, sequenceLiteralContext.prefix, MAX_COMPLETION_TABLES, sequenceLiteralContext.schema, undefined, false, undefined, ["sequence"], sequenceLiteralContext.nameQuoted);
if (epoch !== completionEpoch) return null;
return buildCompletionResult(buildPostgresSequenceLiteralCompletionItems(sequenceLiteralContext, sequences), sequenceLiteralContext.from, undefined, sequenceLiteralContext.prefix);
}
const legacyCompletionContext = getEditorSqlCompletionContext(fullDoc, position);
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, sqlCompletionDialectOptions()) : null;
let completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
if (!hasDatabase) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
tables: [],
objects: [],
columnsByTable: new Map(),
schemas: [],
translations: completionTranslations.value,
snippets: settingsStore.editorSettings.snippets,
dialect: props.dialect,
databaseType: snippetDatabaseType.value,
driverProfile: sqlDriverProfile.value,
currentSchema: props.schema,
keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase,
functionCase: settingsStore.editorSettings.sqlFormatter.functionCase,
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
});
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
const useDatabase = props.databaseType === "sqlserver" ? sqlServerUseDatabaseBeforeCursor(fullDoc, position) : undefined;
let knownUseDatabases: string[] | undefined;
let supportsSessionDatabaseSwitch: boolean | undefined;
let useDatabaseDefaultSchema: string | undefined;
if (useDatabase) {
try {
const currentContext = await connectionStore.getSqlServerCompletionContext(props.connectionId, props.database!);
supportsSessionDatabaseSwitch = currentContext.supports_session_database_switch;
knownUseDatabases = [props.database!];
if (supportsSessionDatabaseSwitch) {
knownUseDatabases = mergeSqlCompletionQualifierNames(knownUseDatabases, connectionStore.lookupLocalCompletionDatabases(props.connectionId, "", MAX_COMPLETION_TABLES));
if (!knownUseDatabases.some((database) => database.toLowerCase() === useDatabase.toLowerCase())) {
knownUseDatabases = mergeSqlCompletionQualifierNames(knownUseDatabases, await connectionStore.listCompletionDatabases(props.connectionId));
}
}
const targetDatabase = knownUseDatabases.find((database) => database.toLowerCase() === useDatabase.toLowerCase());
if (targetDatabase) {
const targetContext = targetDatabase.toLowerCase() === props.database!.toLowerCase() ? currentContext : await connectionStore.getSqlServerCompletionContext(props.connectionId, targetDatabase);
useDatabaseDefaultSchema = targetContext.default_schema;
}
} catch {
// An unverified USE target must not replace the selected database.
}
if (epoch !== completionEpoch) return null;
}
const completionScope = resolveSqlCompletionScope({
sql: fullDoc,
cursor: position,
databaseType: props.databaseType,
currentDatabase: props.database!,
currentSchema: props.schema,
knownDatabases: knownUseDatabases,
supportsSessionDatabaseSwitch,
useDatabaseDefaultSchema,
completionContext,
});
completionContext = completionScope.completionContext;
const needsAsyncData =
completionContext.suggestTables || completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || !!completionContext.qualifier || !!completionContext.insertTable || completionContext.exclusiveColumnSuggestions || completionContext.referencedTables.length > 0;
if (!needsAsyncData) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
tables: [],
objects: [],
columnsByTable: new Map(),
schemas: [],
translations: completionTranslations.value,
snippets: settingsStore.editorSettings.snippets,
dialect: props.dialect,
databaseType: snippetDatabaseType.value,
driverProfile: sqlDriverProfile.value,
currentSchema: props.schema,
keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase,
functionCase: settingsStore.editorSettings.sqlFormatter.functionCase,
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
});
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
const tableNameCompletion = isTableNameCompletionContext(completionContext);
const shouldResolveColumnCompletion = shouldResolveSqlColumnCompletion({
suggestColumns: completionContext.suggestColumns,
hasReferencedTables: completionContext.referencedTables.length > 0,
prefix: completionContext.prefix,
typedActivation,
selectListColumnContext: completionContext.selectListColumnContext,
});
const shouldResolveAsyncCompletion = tableNameCompletion || shouldResolveColumnCompletion;
const localResult = buildLocalSqlCompletionResult(completionContext, fullDoc, position, completionScope);
if (localResult) {
scheduleCompletionMetadataRefresh(completionContext, fullDoc, position, completionScope);
const hasLocalColumnResult = localResult.options.some((option) => option.type === "column");
if ((!explicit || typedActivation) && (!shouldResolveColumnCompletion || hasLocalColumnResult)) return localResult;
}
if ((!explicit || typedActivation) && !shouldResolveAsyncCompletion) {
scheduleCompletionMetadataRefresh(completionContext, fullDoc, position, completionScope);
return null;
}
// Cancel any pending debounced completion
if (completionDebounceTimer) {
clearTimeout(completionDebounceTimer);
completionDebounceTimer = null;
}
// Debounce the full async flow and return the promise to CodeMirror.
// This prevents wasted backend calls during rapid typing while still
// showing table/column names in the first popup.
return new Promise<ReturnType<typeof buildCompletionResult>>((resolve) => {
context.addEventListener("abort", () => {
if (epoch === completionEpoch) completionEpoch++;
});
completionDebounceTimer = setTimeout(async () => {
completionDebounceTimer = null;
if (epoch !== completionEpoch) {
resolve(null);
return;
}
try {
const result = await performAsyncCompletionWithResult(epoch, completionContext, fullDoc, position, completionScope);
resolve(result ?? localResult);
} catch {
resolve(localResult);
}
}, COMPLETION_DEBOUNCE_DELAY_MS);
});
} catch {
return null;
}
}
function isEditorComposing(currentView: EditorViewType): boolean {
return imeCompositionActive || currentView.compositionStarted || currentView.composing;
}
// Manual-trigger shortcut (default Alt+/). Opens the completion popup on the
// explicit path so auto-trigger mode gating is bypassed. Unlike
// scheduleSqlCompletionStart, it must NOT mark the activation as typed, or the
// session would be misclassified as typing and gated for 500ms.
function triggerSqlCompletion(currentView: EditorViewType): boolean {
if (!codeMirrorStartCompletion || isEditorComposing(currentView)) return false;
return codeMirrorStartCompletion(currentView);
}
function scheduleSqlCompletionStart(currentView: EditorViewType, delayMs = 0) {
window.setTimeout(() => {
if (!codeMirrorStartCompletion || isEditorComposing(currentView)) return;
markTypedCompletionActivation();
activeCompletionOrigin = originForTypedSqlCompletionStart(activeCompletionOrigin);
codeMirrorStartCompletion(currentView);
}, delayMs);
}
function clearDeferredCompletionTrigger() {
if (deferredCompletionTriggerTimer === null) return;
clearTimeout(deferredCompletionTriggerTimer);
deferredCompletionTriggerTimer = null;
}
function scheduleDeferredCompletionTrigger(currentView: EditorViewType, insertedText: string, removedText: string) {
clearDeferredCompletionTrigger();
const expectedDoc = currentView.state.doc;
const expectedPosition = currentView.state.selection.main.head;
deferredCompletionTriggerTimer = setTimeout(() => {
deferredCompletionTriggerTimer = null;
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main.head !== expectedPosition || isEditorComposing(currentView)) return;
if (shouldStartSqlCompletionAfterInput(insertedText, removedText, currentView)) {
scheduleSqlCompletionStart(currentView);
}
}, COMPLETION_TRIGGER_DEFER_DELAY_MS);
}
function flushImeComposition() {
const currentView = view.value;
if (!currentView || !pendingImeModelEmit) return;
pendingImeModelEmit = false;
emitModelValue(currentView);
invalidateSemanticDiagnosticsForDocumentChange();
scheduleSemanticDiagnostics();
syncEditorSelectionState(currentView);
schedulePreviewContextRefresh(currentView);
emit("selectionChange", selectedSqlFromView(currentView));
emit("cursorChange", currentView.state.selection.main.head);
latestSelection = readEditorSelection(currentView);
if (editorIsActive) emitEditorSelection(latestSelection);
const fullDoc = currentView.state.doc.toString();
const position = currentView.state.selection.main.head;
if (shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
scheduleSqlCompletionStart(currentView);
}
}
/**
* Returns true when the current SQL position should trigger completion under the active trigger mode.
* Used by flushImeComposition and shouldStartSqlCompletionAfterInput.
*/
function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): boolean {
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: view.value?.state }) && !sequenceLiteralContext) return false;
const mode = settingsStore.editorSettings.completionTriggerMode;
if (mode === "manual") return false;
const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({
sql: fullDoc,
cursor: position,
databaseType: props.databaseType,
});
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
if (mode === "require-prefix") {
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin: "typing",
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
};
return shouldAllowSqlCompletionTrigger(mode, facts);
}
// positional
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const facts: SqlCompletionTriggerFacts = {
origin: "typing",
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
positionalEligible,
};
return shouldAllowSqlCompletionTrigger(mode, facts);
}
function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): boolean {
const position = currentView.state.selection.main.head;
const fullDoc = currentView.state.doc.toString();
// Non-SQL providers: keep existing behavior (trigger mode policy does not apply).
if (props.databaseType === "mongodb") {
return !!(insertedText || removedText) && shouldAutoOpenMongoCompletion(fullDoc, position);
}
if (props.databaseType === "victoriametrics" || props.databaseType === "meilisearch") return false;
if (props.databaseType === "redis" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
// Preserve old character-based checks for non-SQL providers.
if (!insertedText && removedText) {
const completionContext = getEditorSqlCompletionContext(fullDoc, position);
return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
if (insertedText.endsWith(".")) return true;
if (/[,(]$/.test(insertedText)) {
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 = getEditorSqlCompletionContext(fullDoc, position);
return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
// SQL providers: use unified trigger mode policy.
return shouldTriggerSqlCompletionForPosition(fullDoc, position);
}
function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number, scope: CompletionMetadataScope) {
if (!props.connectionId || props.database == null) return null;
const databaseNames = localCompletionDatabaseNames(completionContext);
const currentDatabaseSchemaNames = localCompletionSchemasForDatabaseDisambiguation(completionContext, databaseNames, scope);
const schemaLookupDatabase = resolveSqlCompletionSchemaLookupDatabase({
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
knownDatabases: databaseNames,
knownSchemas: currentDatabaseSchemaNames,
});
const shouldLoadTables = !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)));
const tableLookupTarget = resolveSqlCompletionTableLookupTarget({
currentDatabase: scope.database,
currentSchema: scope.schema,
supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(),
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
knownDatabases: databaseNames,
});
const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier;
const tables = schemaLookupDatabase ? [] : shouldLoadTables ? connectionStore.lookupLocalCompletionTables(props.connectionId, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, globalOracleTableSearch ? undefined : tableLookupTarget.schema, props.catalog) : cachedTables;
const shouldLoadObjects = shouldLoadCompletionObjects(completionContext);
const completionObjectScope = routineCompletionScopeForContext(completionContext, scope);
const scopedCachedCompletionObjects = completionObjectsForScope(completionObjectScope);
const completionObjects = shouldLoadObjects ? lookupLocalCompletionObjectsForContext(completionContext, scope) : scopedCachedCompletionObjects;
const schemaNames =
completionContext.suggestTables && !completionContext.insertTable
? schemaLookupDatabase
? connectionStore.lookupLocalCompletionSchemas(props.connectionId, schemaLookupDatabase, completionContext.prefix, MAX_COMPLETION_TABLES)
: !completionContext.qualifier
? mergeSqlCompletionQualifierNames(connectionStore.lookupLocalCompletionSchemas(props.connectionId, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES), databaseNames)
: []
: [];
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
if (completionContext.insertTable) {
const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? scope.database;
const insertSchema = completionContext.insertSchema ?? scope.schema;
const insertColumns = usesOracleSessionCompletionColumns(insertSchema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, insertDatabase, completionContext.insertTable, insertSchema, props.catalog);
if (insertColumns.length > 0) {
columnsByTable.set(completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope), insertColumns);
}
}
const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext);
if (qualifiedColumnTarget) {
const reference = completionContext.referencedTables.find((table) => completionTablesMatch(table, qualifiedColumnTarget));
const qualifiedCacheTable = { ...qualifiedColumnTarget, nameQuoted: reference?.nameQuoted, schemaQuoted: reference?.schemaQuoted };
const cacheKey = completionCacheKey(qualifiedCacheTable, scope);
const cachedPrefix = completionContext.prefix.length >= 2 && (props.databaseType === "postgres" || props.databaseType === "mysql") ? lookupCachedPrefixColumns(qualifiedCacheTable, scope, completionContext.prefix) : undefined;
const cached = cachedPrefix ?? cachedColumnsByTable.get(cacheKey);
if (cached) {
columnsByTable.set(cacheKey, cached);
} else {
const target = completionMetadataTarget(qualifiedColumnTarget, scope);
const prefixColumns =
target && completionContext.prefix.length >= 2 && (props.databaseType === "postgres" || props.databaseType === "mysql")
? connectionStore.lookupLocalCompletionColumnsByPrefix(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema, completionContext.prefix, target.catalog, completionColumnRequestContext(reference))
: [];
const localColumns =
prefixColumns.length > 0
? prefixColumns
: target && !usesOracleSessionCompletionColumns(target.schema)
? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, qualifiedColumnTarget.name, target.schema, target.catalog, completionColumnRequestContext(reference))
: [];
if (localColumns.length > 0) {
columnsByTable.set(cacheKey, localColumns);
}
}
}
const cteDefs = extractCteDefinitions(fullDoc);
for (const refTable of completionContext.referencedTables) {
if (refTable.columns?.length) {
columnsByTable.set(
refTable.name,
refTable.columns.map((name) => ({ name, table: refTable.name, schema: refTable.schema })),
);
continue;
}
if (isVirtualCompletionTableReference(refTable)) continue;
const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase());
if (cteDef) {
columnsByTable.set(
refTable.name,
cteDef.columns.map((name) => ({
name,
table: refTable.name,
dataType: undefined,
})),
);
continue;
}
const cacheKey = completionCacheKey(refTable, scope);
const prefixCompletion =
(props.databaseType === "postgres" || props.databaseType === "mysql") &&
completionContext.qualifier &&
completionContext.prefix.length >= 2 &&
isReferencedTableQualifier(completionContext) &&
(refTable.alias?.toLowerCase() === completionContext.qualifier.toLowerCase() || refTable.name.toLowerCase() === completionContext.qualifier.toLowerCase())
? completionContext.prefix
: undefined;
const cached = (prefixCompletion ? lookupCachedPrefixColumns(refTable, scope, prefixCompletion) : undefined) ?? cachedColumnsByTable.get(cacheKey);
if (cached) {
columnsByTable.set(cacheKey, cached);
continue;
}
const target = completionMetadataTarget(refTable, scope);
const prefixColumns = target && prefixCompletion ? connectionStore.lookupLocalCompletionColumnsByPrefix(props.connectionId, target.database, refTable.name, target.schema, prefixCompletion, target.catalog, refTable) : [];
const localColumns = prefixColumns.length > 0 ? prefixColumns : target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema, target.catalog, refTable) : [];
if (localColumns.length > 0) {
columnsByTable.set(cacheKey, localColumns);
}
const localForeignKeys = target ? connectionStore.lookupLocalCompletionForeignKeys(props.connectionId, target.database, refTable.name, target.schema) : [];
if (localForeignKeys.length > 0) {
cachedForeignKeysByTable.set(cacheKey, localForeignKeys);
}
}
if (
tables.length === 0 &&
completionObjects.length === 0 &&
schemaNames.length === 0 &&
columnsByTable.size === 0 &&
!driverProfileHasCompletionCandidates(sqlDriverProfile.value, completionContext) &&
(completionContext.exclusiveTableSuggestions || completionContext.exclusiveColumnSuggestions || completionContext.exclusiveRoutineSuggestions)
) {
return null;
}
const items = buildSqlCompletionItemsFromContext(completionContext, {
tables,
objects: completionObjects,
columnsByTable,
foreignKeysByTable: cachedForeignKeysByTable,
schemas: schemaNames,
translations: completionTranslations.value,
snippets: settingsStore.editorSettings.snippets,
dialect: props.dialect,
databaseType: snippetDatabaseType.value,
driverProfile: sqlDriverProfile.value,
currentSchema: scope.schema,
keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase,
functionCase: settingsStore.editorSettings.sqlFormatter.functionCase,
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
});
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number, scope: CompletionMetadataScope) {
if (!props.connectionId || props.database == null) return;
const localOnlyMetadata = usesLocalOnlyCompletionMetadata();
const onDemandOnlyColumns = usesOnDemandOnlyCompletionColumns();
const tableNameCompletion = isTableNameCompletionContext(completionContext);
const connectionId = props.connectionId;
const database = scope.database;
const databaseNames = localCompletionDatabaseNames(completionContext);
const currentDatabaseSchemaNames = localCompletionSchemasForDatabaseDisambiguation(completionContext, databaseNames, scope);
const schemaLookupDatabase = resolveSqlCompletionSchemaLookupDatabase({
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
knownDatabases: databaseNames,
knownSchemas: currentDatabaseSchemaNames,
});
const tableLookupTarget = resolveSqlCompletionTableLookupTarget({
currentDatabase: database,
currentSchema: scope.schema,
supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(),
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
knownDatabases: databaseNames,
});
if (!localOnlyMetadata && !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)))) {
const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier;
const refreshEpoch = completionEpoch;
queueTableCompletionRefresh(async () => {
if (refreshEpoch !== completionEpoch) return;
try {
const tables = await connectionStore.refreshCompletionTables(connectionId, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch, scope.schema, props.catalog);
if (refreshEpoch !== completionEpoch) return;
const scopedTables = tables.map((table) => ({ ...table, database: table.database ?? tableLookupTarget.database }));
cachedTables = mergeCompletionTables(cachedTables, scopedTables);
if (completionContext.suggestJoinConditions && completionContext.referencedTables.length > 0) {
void ensureForeignKeysForTables(completionContext.referencedTables);
}
} catch {
// Local candidates remain available when the remote refresh fails.
}
});
}
if (!localOnlyMetadata && shouldLoadCompletionObjects(completionContext)) {
const completionObjectScope = routineCompletionScopeForContext(completionContext, scope);
void listCompletionObjectsForContext(completionContext, scope)
.then((objects) => {
const cachedObjects = completionObjectsForScope(completionObjectScope);
const merged = mergeCompletionObjects(cachedObjects, objects);
const changed = completionObjectsDiffer(cachedObjects, merged);
cachedCompletionObjectsByScope.set(completionObjectScopeKey(completionObjectScope), merged);
if (changed) refreshActiveSqlCompletion(fullDoc, position, completionContext);
})
.catch(() => {});
}
if (!localOnlyMetadata && completionContext.suggestTables && !completionContext.insertTable) {
if (schemaLookupDatabase) {
void connectionStore.refreshCompletionSchemas(connectionId, schemaLookupDatabase).catch(() => {});
} else if (!completionContext.qualifier) {
void connectionStore.refreshCompletionSchemas(connectionId, database).catch(() => {});
if (supportsDatabaseNameCompletion(props.databaseType)) {
void connectionStore.refreshCompletionDatabases(connectionId).catch(() => {});
}
}
}
if (!onDemandOnlyColumns && completionContext.insertTable) {
const insertTable = completionContext.insertTable;
const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? database;
void refreshCompletionColumnsForEditor(connectionId, insertDatabase, insertTable, completionContext.insertSchema ?? scope.schema)
.then((columns) => {
const insertSchema = completionContext.insertSchema ?? scope.schema;
cachedColumnsByTable.set(completionCacheKey({ name: insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope), columns);
})
.catch(() => {});
}
const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext);
const qualifiedColumnCacheKey = qualifiedColumnTarget ? completionCacheKey(qualifiedColumnTarget, scope) : undefined;
if (!onDemandOnlyColumns && qualifiedColumnTarget && qualifiedColumnCacheKey && !cachedColumnsByTable.has(qualifiedColumnCacheKey)) {
const target = completionMetadataTarget(qualifiedColumnTarget, scope);
if (target) {
void refreshCompletionColumnsForEditor(connectionId, target.database, qualifiedColumnTarget.name, target.schema, target.catalog)
.then((columns) => {
if (columns.length > 0) cachedColumnsByTable.set(qualifiedColumnCacheKey, columns);
})
.catch(() => {});
}
}
if (!onDemandOnlyColumns && !tableNameCompletion) {
for (const refTable of completionContext.referencedTables) {
if (isVirtualCompletionTableReference(refTable)) continue;
if (refTable.columns && refTable.columns.length > 0) continue;
const cacheKey = completionCacheKey(refTable, scope);
if (cacheKey === qualifiedColumnCacheKey) continue;
if (cachedColumnsByTable.has(cacheKey)) continue;
const target = completionMetadataTarget(refTable, scope);
if (!target) continue;
void refreshCompletionColumnsForEditor(connectionId, target.database, refTable.name, target.schema, target.catalog, refTable)
.then((columns) => {
if (columns.length > 0) cachedColumnsByTable.set(cacheKey, columns);
})
.catch(() => {});
}
}
if (!tableNameCompletion && completionContext.suggestJoinConditions && completionContext.referencedTables.length > 0) {
void ensureForeignKeysForTables(completionContext.referencedTables);
}
}
function mergeCompletionTables(existing: SqlCompletionTable[], incoming: SqlCompletionTable[]): SqlCompletionTable[] {
const merged = [...existing];
const indexes = new Map(existing.map((table, index) => [`${table.catalog ?? ""}.${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(), index]));
for (const table of incoming) {
const key = `${table.catalog ?? ""}.${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase();
const index = indexes.get(key);
if (index == null) {
indexes.set(key, merged.length);
merged.push(table);
} else {
const existing = merged[index];
// Preserve the more specific tree type if an older metadata endpoint reports a materialized view as VIEW.
merged[index] = {
...existing,
...table,
type: mergeSqlObjectNavigationType(existing.type, table.type),
};
}
}
return merged;
}
function withCompletionLatencyBudget<T>(remote: Promise<T>, local: T): Promise<T> {
return Promise.race([remote, new Promise<T>((resolve) => setTimeout(() => resolve(local), COMPLETION_REMOTE_LATENCY_BUDGET_MS))]);
}
function listCompletionTablesWithLatencyBudget(connectionId: string, database: string, filter: string, limit: number, schema?: string, globalSearch = false, catalog = props.catalog, currentSchema = props.schema): Promise<SqlCompletionTable[]> {
const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, globalSearch ? undefined : schema, catalog).map((table) => ({ ...table, catalog: table.catalog ?? catalog, database: table.database ?? database }));
const remote = connectionStore.listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog).then((tables) => {
const scopedTables = tables.map((table) => ({ ...table, catalog: table.catalog ?? catalog, database: table.database ?? database }));
cachedTables = mergeCompletionTables(cachedTables, scopedTables);
return scopedTables;
});
if (local.length === 0) return remote;
return withCompletionLatencyBudget(remote, local);
}
interface RoutineCompletionTarget {
schema?: string;
parentName?: string;
globalSearch?: boolean;
}
function shouldLoadCompletionObjects(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
// Doris/StarRocks external catalogs currently expose table and column
// metadata through catalog-aware APIs. The generic routine/object endpoint
// is catalogless and would query the internal catalog, aborting completion.
if (props.catalog) return false;
const routineContext = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
return routineContext && !isReferencedTableQualifier(completionContext);
}
/**
* Databases whose qualified routine completion treats the first qualifier as a
* package (Oracle) or as a package-or-schema in A compatibility mode
* (openGauss). The backend re-validates: non-package parents fall through to
* the ordinary routine search, so routing is safe even when the compatibility
* map is not yet warm. openGauss keeps the package-aware routing while the
* mode is unknown (cold start, so A-mode completion works immediately after
* connect) and once the mode is known to be A; a known B/PG mode skips the
* extra package-style queries entirely.
*/
function usesPackageAwareRoutineCompletion(): boolean {
if (props.databaseType === "oracle") return true;
if (props.databaseType !== "opengauss") return false;
const mode = connectionStore.databaseCompatibilityMode(props.connectionId, props.database)?.trim().toUpperCase();
return mode === undefined || mode === "A";
}
function oracleRoutineCompletionTargets(completionContext: ReturnType<typeof getSqlCompletionContext>): RoutineCompletionTarget[] {
const parts = (completionContext.qualifierParts?.length ? completionContext.qualifierParts : completionContext.qualifier?.split("."))?.filter(Boolean) ?? [];
if (parts.length === 0) {
// Oracle resolves unqualified routines across all schemas (owner semantics).
// openGauss keeps the PG search_path scope for the unqualified case.
if (props.databaseType === "oracle") return [{ schema: props.schema, globalSearch: true }];
return [{ schema: props.schema }];
}
if (parts.length === 1) {
return [{ schema: props.schema, parentName: parts[0] }, { schema: parts[0] }];
}
return [{ schema: parts[parts.length - 2], parentName: parts[parts.length - 1] }];
}
function routineCompletionTargetForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope) {
return resolveSqlCompletionRoutineLookupTarget({
currentDatabase: scope.database,
currentSchema: scope.schema,
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
});
}
function routineCompletionScopeForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): CompletionMetadataScope {
if (usesPackageAwareRoutineCompletion()) return scope;
const target = routineCompletionTargetForContext(completionContext, scope);
return { database: target.database, schema: target.schema };
}
function lookupLocalCompletionObjectsForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): SqlCompletionObject[] {
if (!props.connectionId || props.database == null) return [];
if (usesPackageAwareRoutineCompletion()) {
return connectionStore.lookupLocalCompletionObjects(props.connectionId, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES);
}
const target = routineCompletionTargetForContext(completionContext, scope);
return connectionStore.lookupLocalCompletionObjects(props.connectionId, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema);
}
async function listCompletionObjectsForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): Promise<SqlCompletionObject[]> {
if (!props.connectionId || props.database == null) return [];
const objectKinds = completionObjectKindsForContext(completionContext);
if (!usesPackageAwareRoutineCompletion()) {
const target = routineCompletionTargetForContext(completionContext, scope);
return connectionStore.listCompletionObjects(props.connectionId, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema, undefined, false, scope.schema, objectKinds);
}
const groups = await Promise.all(
oracleRoutineCompletionTargets(completionContext).map((target) => connectionStore.listCompletionObjects(props.connectionId!, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES, target.schema, target.parentName, target.globalSearch, scope.schema, objectKinds)),
);
return groups.reduce((objects, group) => mergeCompletionObjects(objects, group), [] as SqlCompletionObject[]);
}
function completionObjectKindsForContext(completionContext: ReturnType<typeof getSqlCompletionContext>): CompletionAssistantObjectKind[] {
if (completionContext.contextKind === "exec") return ["procedure"];
if (completionContext.suggestColumns && completionContext.referencedTables.length > 0 && !completionContext.qualifier) return ["function"];
return ["routine"];
}
async function performAsyncCompletionWithResult(epoch: number, completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number, scope: CompletionMetadataScope) {
const localOnlyMetadata = usesLocalOnlyCompletionMetadata();
const onDemandOnlyColumns = usesOnDemandOnlyCompletionColumns();
// Handle INSERT column list: fetch columns for the target table
let insertColumnsByTable = new Map<string, SqlCompletionColumn[]>();
if (completionContext.insertTable) {
try {
const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? scope.database;
const insertCols = await listCompletionColumnsForEditor(props.connectionId!, insertDatabase, completionContext.insertTable, completionContext.insertSchema ?? scope.schema);
if (epoch !== completionEpoch) return null;
if (insertCols.length > 0) {
const insertSchema = completionContext.insertSchema ?? scope.schema;
const insertKey = completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope);
insertColumnsByTable.set(insertKey, insertCols);
}
} catch {
// ignore
}
}
let databaseNames = localCompletionDatabaseNames(completionContext);
let currentDatabaseSchemaNames = localCompletionSchemasForDatabaseDisambiguation(completionContext, databaseNames, scope);
const mayCompleteDatabaseSchema = mayCompleteDatabaseSchemaQualifier(completionContext);
if (!localOnlyMetadata && supportsDatabaseNameCompletion(props.databaseType) && completionContext.suggestTables && !completionContext.insertTable && (!completionContext.qualifier || mayCompleteDatabaseSchema)) {
const [databasesResult, schemasResult] = await Promise.allSettled([connectionStore.listCompletionDatabases(props.connectionId!), mayCompleteDatabaseSchema ? connectionStore.listCompletionSchemas(props.connectionId!, scope.database) : Promise.resolve(currentDatabaseSchemaNames)]);
databaseNames = databasesResult.status === "fulfilled" ? databasesResult.value : [];
if (schemasResult.status === "fulfilled") currentDatabaseSchemaNames = mergeSqlCompletionQualifierNames(scope.schema ? [scope.schema] : [], schemasResult.value);
if (epoch !== completionEpoch) return null;
}
const schemaLookupDatabase = resolveSqlCompletionSchemaLookupDatabase({
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
knownDatabases: databaseNames,
knownSchemas: currentDatabaseSchemaNames,
});
const shouldLoadTables = !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)));
const tableLookupTarget = resolveSqlCompletionTableLookupTarget({
currentDatabase: scope.database,
currentSchema: scope.schema,
supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(),
supportsDatabaseSchemaQualifier: supportsDatabaseSchemaQualifierCompletion(),
completionContext,
knownDatabases: databaseNames,
});
const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier;
let tables = schemaLookupDatabase
? []
: shouldLoadTables
? localOnlyMetadata
? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, globalOracleTableSearch ? undefined : tableLookupTarget.schema, props.catalog)
: await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch, props.catalog, scope.schema)
: cachedTables;
if (localOnlyMetadata && tables.length === 0 && supportsDatabaseSchemaQualifierCompletion() && (completionContext.qualifierParts?.length ?? 0) >= 2 && allowsOnDemandQualifiedTableCompletion(completionContext.prefix)) {
tables = await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT, tableLookupTarget.schema, false, props.catalog, scope.schema);
}
if (epoch !== completionEpoch) return null;
const shouldLoadObjects = shouldLoadCompletionObjects(completionContext);
const completionObjectScope = routineCompletionScopeForContext(completionContext, scope);
const scopedCachedCompletionObjects = completionObjectsForScope(completionObjectScope);
let completionObjects = shouldLoadObjects ? (localOnlyMetadata ? lookupLocalCompletionObjectsForContext(completionContext, scope) : await listCompletionObjectsForContext(completionContext, scope)) : scopedCachedCompletionObjects;
if (epoch !== completionEpoch) return null;
if (!props.catalog && props.databaseType !== "oracle" && !localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) {
const target = routineCompletionTargetForContext(completionContext, scope);
const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema, undefined, false, scope.schema);
if (schemaObjects.length > 0) {
completionObjects = schemaObjects;
}
if (epoch !== completionEpoch) return null;
}
cachedCompletionObjectsByScope.set(completionObjectScopeKey(completionObjectScope), mergeCompletionObjects(scopedCachedCompletionObjects, completionObjects));
// Fetch schemas for schema completion
let schemaNames: string[] = [];
if (completionContext.suggestTables && !completionContext.insertTable && (schemaLookupDatabase || !completionContext.qualifier)) {
const database = schemaLookupDatabase ?? scope.database;
if (localOnlyMetadata) {
const schemas = connectionStore.lookupLocalCompletionSchemas(props.connectionId!, database, completionContext.prefix, MAX_COMPLETION_TABLES);
schemaNames = schemaLookupDatabase ? schemas : mergeSqlCompletionQualifierNames(schemas, databaseNames);
} else {
try {
const schemas = await connectionStore.listCompletionSchemas(props.connectionId!, database);
schemaNames = schemaLookupDatabase ? schemas : mergeSqlCompletionQualifierNames(schemas, databaseNames);
if (epoch !== completionEpoch) return null;
} catch {
schemaNames = schemaLookupDatabase ? [] : databaseNames;
}
}
}
// If qualifier didn't match any table names, try it as a schema name
let qualifierIsSchema = false;
if (completionContext.qualifier && !schemaLookupDatabase && !tableLookupTarget.qualifierDatabase && !isReferencedTableQualifier(completionContext) && tables.length === 0 && (completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)) {
let schemaTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier, props.catalog);
if (!localOnlyMetadata) {
schemaTables = await listCompletionTablesWithLatencyBudget(props.connectionId!, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier, false, props.catalog, scope.schema);
} else if (schemaTables.length === 0 && allowsOnDemandQualifiedTableCompletion(completionContext.prefix)) {
schemaTables = await listCompletionTablesWithLatencyBudget(props.connectionId!, scope.database, completionContext.prefix, PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT, completionContext.qualifier, false, props.catalog, scope.schema);
}
if (schemaTables.length > 0) {
tables = schemaTables;
qualifierIsSchema = true;
}
if (epoch !== completionEpoch) return null;
}
// Collect referenced tables — enrich with schema from filtered table lookup
let refs = completionContext.referencedTables.map((rt) => {
if (usesOracleSessionCompletionColumns(rt.schema)) return rt;
if (!rt.schema) {
const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase());
if (cached && cached.schema) {
return { ...rt, schema: cached.schema };
}
}
return rt;
});
const unresolvedRefs = refs.filter((rt) => !usesOracleSessionCompletionColumns(rt.schema) && !rt.schema && !rt.columns && !isVirtualCompletionTableReference(rt));
if (!localOnlyMetadata && unresolvedRefs.length > 0) {
const lookupGroups = await Promise.all(
unresolvedRefs.map((rt) => {
const target = completionMetadataTarget(rt, scope);
return connectionStore.listCompletionTables(props.connectionId!, target?.database ?? scope.database, rt.name, 20, target?.schema ?? scope.schema, false, scope.schema, target?.catalog ?? props.catalog);
}),
);
if (epoch !== completionEpoch) return null;
const lookupTables = lookupGroups.flat();
refs = refs.map((rt) => {
if (usesOracleSessionCompletionColumns(rt.schema)) return rt;
if (rt.schema || rt.columns) return rt;
const matched = lookupTables.find((table) => table.name.toLowerCase() === rt.name.toLowerCase());
return matched?.schema ? { ...rt, schema: matched.schema } : rt;
});
}
// If no referenced tables but qualifier exists, infer table from tables list
if (refs.length === 0 && completionContext.qualifier) {
const q = completionContext.qualifier.toLowerCase();
const matched = tables.filter((t) => t.name.toLowerCase() === q || t.name.toLowerCase().endsWith("." + q));
refs = matched.map((t) => ({ name: t.name, schema: t.schema }));
}
const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext);
if (qualifiedColumnTarget && !refs.some((ref) => completionTablesMatch(ref, qualifiedColumnTarget))) {
refs.push(qualifiedColumnTarget);
}
// Populate CTE columns from parsed definitions
const cteDefs = extractCteDefinitions(fullDoc);
for (const refTable of refs) {
if (refTable.columns) continue;
const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase());
if (cteDef) {
refTable.columns = cteDef.columns;
}
}
const tableNameCompletion = isTableNameCompletionContext(completionContext);
const shouldFetchColumnsForCompletion = !tableNameCompletion && (!onDemandOnlyColumns || completionContext.suggestColumns || completionContext.exclusiveColumnSuggestions || !!completionContext.insertTable);
const hasQualifiedColumnPrefix = (props.databaseType === "postgres" || props.databaseType === "mysql") && completionContext.qualifier && completionContext.prefix.length >= 2 && isReferencedTableQualifier(completionContext);
const columnRefs = hasQualifiedColumnPrefix
? refs.filter((refTable) => refTable.alias?.toLowerCase() === completionContext.qualifier!.toLowerCase() || refTable.name.toLowerCase() === completionContext.qualifier!.toLowerCase() || (!!qualifiedColumnTarget && completionTablesMatch(refTable, qualifiedColumnTarget)))
: refs.slice(0, 4);
if (shouldFetchColumnsForCompletion) {
await Promise.all(
columnRefs.map(async (refTable) => {
if (isVirtualCompletionTableReference(refTable)) return;
if (refTable.columns && refTable.columns.length > 0) return;
const cacheKey = completionCacheKey(refTable, scope);
const prefixCompletion = hasQualifiedColumnPrefix ? completionContext.prefix : undefined;
const prefixCacheKey = prefixCompletion ? completionPrefixCacheKey(refTable, scope, prefixCompletion) : undefined;
if (prefixCompletion ? lookupCachedPrefixColumns(refTable, scope, prefixCompletion) : cachedColumnsByTable.has(cacheKey)) return;
try {
const target = completionMetadataTarget(refTable, scope);
if (!target) return;
const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog, refTable, prefixCompletion);
if (epoch !== completionEpoch) return;
if (columns.length === 0) return;
if (prefixCacheKey) cachedPrefixColumnsByTable.set(prefixCacheKey, columns);
else cachedColumnsByTable.set(cacheKey, columns);
} catch (e) {
console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e);
}
}),
);
}
if (epoch !== completionEpoch) return null;
if (!tableNameCompletion && completionContext.suggestJoinConditions && refs.length > 0) {
await ensureForeignKeysForTables(refs.filter((table) => !("columns" in table) || !table.columns || table.columns.length === 0));
if (epoch !== completionEpoch) return null;
}
// Build columnsByTable — from cache or CTE definitions
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
if (insertColumnsByTable.size > 0) {
for (const [key, cols] of insertColumnsByTable.entries()) {
columnsByTable.set(key, cols);
}
} else {
for (const refTable of refs) {
if (hasQualifiedColumnPrefix && !columnRefs.includes(refTable)) continue;
if (refTable.columns && refTable.columns.length > 0) {
const key = refTable.name;
columnsByTable.set(
key,
refTable.columns.map((name) => ({
name,
table: refTable.name,
dataType: undefined,
})),
);
continue;
}
const cacheKey = completionCacheKey(refTable, scope);
const prefixCompletion =
(props.databaseType === "postgres" || props.databaseType === "mysql") &&
completionContext.qualifier &&
completionContext.prefix.length >= 2 &&
isReferencedTableQualifier(completionContext) &&
(refTable.alias?.toLowerCase() === completionContext.qualifier.toLowerCase() || refTable.name.toLowerCase() === completionContext.qualifier.toLowerCase())
? completionContext.prefix
: undefined;
const cached = (prefixCompletion ? lookupCachedPrefixColumns(refTable, scope, prefixCompletion) : undefined) ?? cachedColumnsByTable.get(cacheKey);
if (cached) {
columnsByTable.set(cacheKey, cached);
}
let cachedForeignKeys = cachedForeignKeysByTable.get(cacheKey);
if (!cachedForeignKeys) {
const target = completionMetadataTarget(refTable, scope);
cachedForeignKeys = target ? connectionStore.lookupLocalCompletionForeignKeys(props.connectionId!, target.database, refTable.name, target.schema) : [];
if (cachedForeignKeys.length > 0) cachedForeignKeysByTable.set(cacheKey, cachedForeignKeys);
}
if (cachedForeignKeys) {
foreignKeysByTable.set(cacheKey, cachedForeignKeys);
}
}
}
const effectiveContext = qualifierIsSchema
? {
...completionContext,
qualifier: undefined,
suggestTables: true,
suggestColumns: false,
exclusiveColumnSuggestions: false,
}
: completionContext;
const items = buildSqlCompletionItemsFromContext(effectiveContext, {
tables,
objects: completionObjects,
columnsByTable,
foreignKeysByTable,
schemas: schemaNames,
translations: completionTranslations.value,
snippets: settingsStore.editorSettings.snippets,
dialect: props.dialect,
databaseType: snippetDatabaseType.value,
driverProfile: sqlDriverProfile.value,
currentSchema: scope.schema,
keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase,
functionCase: settingsStore.editorSettings.sqlFormatter.functionCase,
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
});
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
if (!completionContext.qualifier) return false;
const qualifier = completionContext.qualifier.toLowerCase();
const qualifiedColumnTarget = completionQualifiedTableTarget(completionContext);
return completionContext.referencedTables.some((table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier || (!!qualifiedColumnTarget && completionTablesMatch(table, qualifiedColumnTarget)));
}
function isTableNameCompletionContext(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
return completionContext.suggestTables || completionContext.exclusiveTableSuggestions;
}
function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) {
const merged = [...existing];
const indexes = new Map(existing.map((object, index) => [completionObjectIdentityKey(object), index]));
for (const object of incoming) {
const key = completionObjectIdentityKey(object);
const index = indexes.get(key);
if (index == null) {
indexes.set(key, merged.length);
merged.push(object);
} else {
merged[index] = { ...merged[index], ...object };
}
}
return merged;
}
function completionObjectScopeKey(scope: CompletionMetadataScope): string {
return `${scope.database}:${scope.schema ?? ""}`.toLowerCase();
}
function completionObjectsForScope(scope: CompletionMetadataScope): SqlCompletionObject[] {
return cachedCompletionObjectsByScope.get(completionObjectScopeKey(scope)) ?? [];
}
function completionObjectIdentityKey(object: SqlCompletionObject): string {
return `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase();
}
function completionObjectsDiffer(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]): boolean {
if (existing.length !== incoming.length) return true;
return existing.some((object, index) => {
const other = incoming[index];
return (
!other ||
object.name !== other.name ||
object.schema !== other.schema ||
object.type !== other.type ||
object.parentSchema !== other.parentSchema ||
object.parentName !== other.parentName ||
object.dataType !== other.dataType ||
object.signature !== other.signature ||
object.comment !== other.comment ||
object.applyName !== other.applyName ||
object.boost !== other.boost
);
});
}
function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType<typeof getSqlCompletionContext>) {
const currentView = view.value;
if (!currentView || codeMirrorCompletionStatus?.(currentView.state) !== "active") return;
if (currentView.state.doc.toString() !== fullDoc || currentView.state.selection.main.head !== position) return;
const currentContext = getSqlCompletionContext(fullDoc, position);
if (currentContext.prefix !== completionContext.prefix || currentContext.contextKind !== completionContext.contextKind) return;
scheduleSqlCompletionStart(currentView);
}
function refreshCompletionCache() {
cachedTables = [];
cachedCompletionObjectsByScope.clear();
cachedColumnsByTable.clear();
cachedPrefixColumnsByTable.clear();
cachedInsertValueHintColumnsByTable.clear();
loadedColumnsByTable.clear();
cachedForeignKeysByTable.clear();
}
function defaultKeymapExtension() {
if (!editorViewModule || !codeMirrorDefaultKeymap || !codeMirrorToggleBlockComment) return [];
return editorViewModule.keymap.of(defaultKeymapForGlobalShortcuts(codeMirrorDefaultKeymap, settingsStore.editorSettings.shortcuts).filter((item) => item.run !== codeMirrorToggleBlockComment));
}
onMounted(async () => {
if (!editorRef.value) return;
// Pre-load SQL highlighter for hover tooltips (non-blocking)
void (async () => {
try {
const { createShikiSqlHighlighter } = await import("@/lib/sql/sqlHighlighter");
hoverSqlHighlighter = await createShikiSqlHighlighter({
appearance: () => (isDark.value ? "dark" : "light"),
});
} catch {
// Highlighter unavailable; hover falls back to plain text
}
})();
const [
{
EditorView,
keymap,
rectangularSelection,
hoverTooltip,
showTooltip,
closeHoverTooltips,
Decoration,
tooltips,
gutter,
GutterMarker,
lineNumberMarkers,
lineNumbers,
highlightActiveLineGutter,
highlightSpecialChars,
drawSelection,
dropCursor,
crosshairCursor,
scrollPastEnd,
ViewPlugin,
layer,
RectangleMarker,
},
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
langSql,
{
autocompletion,
startCompletion,
acceptCompletion,
closeBrackets,
closeBracketsKeymap,
snippetCompletion,
completionStatus,
completionKeymap,
insertCompletionText,
nextSnippetField,
closeCompletion,
moveCompletionSelection,
selectedCompletion,
selectedCompletionIndex,
currentCompletions,
setSelectedCompletion,
},
{ copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, toggleBlockComment, history, defaultKeymap, historyKeymap },
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree, highlightingFor, syntaxTree },
{ searchKeymap },
] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("@codemirror/autocomplete"), import("@codemirror/commands"), import("@codemirror/language"), import("@codemirror/search")]);
editorViewModule = {
EditorView,
keymap,
rectangularSelection,
} as typeof import("@codemirror/view");
hoverCloseEffect = closeHoverTooltips;
codeMirrorLineNumbers = lineNumbers;
codeMirrorPrec = Prec;
codeMirrorEditorSelection = EditorSelection;
codeMirrorSnippetCompletion = snippetCompletion;
fontThemeComp = new Compartment();
codeMirrorTheme = new Compartment();
wordWrapComp = new Compartment();
lineNumbersComp = new Compartment();
vimModeComp = new Compartment();
closeBracketsComp = new Compartment();
sqlLanguageComp = new Compartment();
sqlSemanticHighlightComp = new Compartment();
sqlSignatureComp = new Compartment();
codeMirrorCloseBrackets = closeBrackets;
codeMirrorCloseBracketsKeymap = closeBracketsKeymap;
readOnlyComp = new Compartment();
runGutterComp = new Compartment();
runKeymapComp = new Compartment();
historyResetComp = new Compartment();
defaultKeymapComp = new Compartment();
completionComp = new Compartment();
diagnosticComp = new Compartment();
previewRangeComp = new Compartment();
indentComp = new Compartment();
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
statementBoundariesRefreshEffect = StateEffect.define<null>();
// Keeps the lenient statement-boundary view glued to the live document:
// each keystroke shifts the known starts / frame range through the
// ChangeSet (O(statements)) and schedules one full rebuild after typing
// pauses, so neither the run gutter nor the statement frame re-parses the
// whole document synchronously on every keystroke.
const statementBoundariesTrackingPlugin = ViewPlugin.fromClass(
class {
update(update: import("@codemirror/view").ViewUpdate) {
if (!update.docChanged) return;
const boundaries = statementBoundariesView;
// No consumer (run gutter off + statement frame off) ever initialized
// the view — nothing to maintain and no refresh to schedule.
if (!boundaries) return;
if (boundaries.doc === update.startState.doc) {
statementBoundariesView = {
doc: update.state.doc,
startsIndex: mapStatementGutterStartIndex(boundaries.startsIndex, update.changes),
frameRange: boundaries.frameRange
? {
from: update.changes.mapPos(boundaries.frameRange.from, 1),
to: update.changes.mapPos(boundaries.frameRange.to, 1),
}
: null,
fresh: false,
generation: boundaries.generation,
};
}
scheduleStatementBoundariesRefresh(update.view);
}
destroy() {
if (statementBoundariesRefreshTimer !== null) {
clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = null;
}
}
},
);
codeMirrorCompletionStatus = completionStatus;
codeMirrorAcceptCompletion = acceptCompletion;
codeMirrorCurrentCompletions = currentCompletions;
codeMirrorSelectedCompletion = selectedCompletion;
codeMirrorSelectedCompletionIndex = selectedCompletionIndex;
codeMirrorSetSelectedCompletion = setSelectedCompletion;
codeMirrorMoveCompletionSelection = moveCompletionSelection;
codeMirrorSelectFirstCompletion = moveCompletionSelection(true);
codeMirrorCloseCompletion = closeCompletion;
codeMirrorStartCompletion = startCompletion;
codeMirrorInsertCompletionText = insertCompletionText;
codeMirrorNextSnippetField = nextSnippetField;
codeMirrorIndentMore = indentMore;
codeMirrorIndentLess = indentLess;
codeMirrorCopyLineDown = copyLineDown;
codeMirrorCopyLineUp = copyLineUp;
codeMirrorDeleteLine = deleteLine;
codeMirrorMoveLineUp = moveLineUp;
codeMirrorMoveLineDown = moveLineDown;
codeMirrorUndo = undo;
codeMirrorRedo = redo;
codeMirrorHistory = history;
codeMirrorSelectAll = selectAll;
codeMirrorInsertNewlineKeepIndent = insertNewlineKeepIndent;
codeMirrorToggleLineComment = toggleLineComment;
codeMirrorToggleBlockComment = toggleBlockComment;
codeMirrorDefaultKeymap = defaultKeymap;
codeMirrorToggleFold = toggleFold;
codeMirrorIndentUnit = indentUnit;
window.addEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
window.addEventListener("blur", clearTableNavigationHover);
const diagnosticTheme = EditorView.baseTheme({
".cm-sql-error": {
textDecoration: "underline wavy var(--dbx-editor-diagnostic-error, var(--destructive))",
textUnderlineOffset: "3px",
},
".cm-sql-semantic-warning": {
textDecoration: "underline wavy var(--dbx-editor-diagnostic-warning, var(--warning))",
textUnderlineOffset: "3px",
},
});
buildSqlDiagnosticExtension = () => {
const diagnosticEffect = setSqlDiagnosticsEffect;
const buildDecorations = (state: import("@codemirror/state").EditorState) => {
const errorDecorations = sqlErrorDecorationRange(state).map((range) =>
Decoration.mark({
class: "cm-sql-error",
attributes: { title: range.message },
}).range(range.from, range.to),
);
const semanticDecorations = sqlSemanticDecorationRanges(state).map((range) =>
Decoration.mark({
class: range.severity === "error" ? "cm-sql-error" : "cm-sql-semantic-warning",
attributes: { title: range.message },
}).range(range.from, range.to),
);
return Decoration.set([...errorDecorations, ...semanticDecorations], true);
};
const field = StateField.define({
create: buildDecorations,
update(value, transaction) {
const diagnosticsChanged = !!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect));
if (diagnosticsChanged) return buildDecorations(transaction.state);
if (transaction.docChanged) return Decoration.set([]);
return value;
},
provide: (field) => EditorView.decorations.from(field),
});
return [field, diagnosticTheme];
};
setPreviewRangeEffect = StateEffect.define<{
from: number;
to: number;
} | null>();
buildPreviewRangeExtension = () => {
const effectType = setPreviewRangeEffect!;
const field = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) {
const range = effect.value;
if (!range) return Decoration.none;
return Decoration.set([Decoration.mark({ class: "cm-db-execution-preview" }).range(range.from, range.to)]);
}
}
if (transaction.docChanged || transaction.selection) return Decoration.none;
return decorations;
},
provide: (f) => EditorView.decorations.from(f),
});
return field;
};
class ResultSourceLineNumberMarker extends GutterMarker {
elementClass = "cm-db-result-source-line-number";
}
const resultSourceLineNumberMarker = new ResultSourceLineNumberMarker();
setResultSourceRangeEffect = StateEffect.define<{
from: number;
to: number;
} | null>();
buildResultSourceRangeExtension = () => {
const effectType = setResultSourceRangeEffect!;
const markersForRange = (state: import("@codemirror/state").EditorState, range: { from: number; to: number }) => {
const from = Math.max(0, Math.min(range.from, state.doc.length));
const to = Math.max(from, Math.min(range.to, state.doc.length));
const startLine = state.doc.lineAt(from);
const endLine = state.doc.lineAt(Math.max(from, to - 1));
const markers = Array.from({ length: endLine.number - startLine.number + 1 }, (_, index) => resultSourceLineNumberMarker.range(state.doc.line(startLine.number + index).from));
return RangeSet.of(markers);
};
const field = StateField.define({
create() {
return RangeSet.empty;
},
update(markers, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) {
return effect.value ? markersForRange(transaction.state, effect.value) : RangeSet.empty;
}
}
if (transaction.docChanged || transaction.selection) return RangeSet.empty;
return markers;
},
provide: (field) => lineNumberMarkers.from(field),
});
const highlightField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) {
const range = effect.value;
if (!range) return Decoration.none;
const from = Math.max(0, Math.min(range.from, transaction.state.doc.length));
const to = Math.max(from, Math.min(range.to, transaction.state.doc.length));
return from === to ? Decoration.none : Decoration.set([Decoration.mark({ class: "cm-db-result-source-highlight" }).range(from, to)]);
}
}
if (transaction.docChanged || transaction.selection) return Decoration.none;
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
return [field, highlightField];
};
class StatementExecutionStateMarker extends GutterMarker {
constructor(readonly marker: StatementExecutionMarker) {
super();
}
eq(other: import("@codemirror/view").GutterMarker): boolean {
return other instanceof StatementExecutionStateMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount && other.marker.runningCount === this.marker.runningCount;
}
}
class StatementGutterMarker extends GutterMarker {
constructor(
readonly canExecute: boolean,
readonly marker?: StatementExecutionMarker,
) {
super();
}
eq(other: import("@codemirror/view").GutterMarker): boolean {
return (
other instanceof StatementGutterMarker &&
other.canExecute === this.canExecute &&
other.marker?.status === this.marker?.status &&
other.marker?.successCount === this.marker?.successCount &&
other.marker?.errorCount === this.marker?.errorCount &&
other.marker?.runningCount === this.marker?.runningCount
);
}
toDOM() {
return createStatementGutterMarkerDom({
canExecute: this.canExecute,
executeLabel: t("editor.contextMenu.executeCurrent"),
status: this.marker?.status,
statusLabel: this.marker ? statementExecutionMarkerTitle(this.marker) : undefined,
});
}
}
function statementExecutionMarkerTitle(marker: StatementExecutionMarker) {
const parts = [];
if ((marker.runningCount ?? 0) > 0) parts.push(t("editor.statementExecutionRunning", { count: marker.runningCount }));
if (marker.successCount > 0) parts.push(t("editor.statementExecutionSucceeded", { count: marker.successCount }));
if (marker.errorCount > 0) parts.push(t("editor.statementExecutionFailed", { count: marker.errorCount }));
return parts.join(", ");
}
setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
buildRunStatementGutterExtension = () => {
const effectType = setStatementExecutionMarkersEffect!;
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
const ranges = markers.map((marker) => {
const from = Math.max(0, Math.min(marker.from, state.doc.length));
return new StatementExecutionStateMarker(marker).range(state.doc.lineAt(from).from);
});
return RangeSet.of(ranges, true);
};
const field = StateField.define({
create(state) {
return markersForState(state, props.statementExecutionMarkers ?? []);
},
update(markers, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) return markersForState(transaction.state, effect.value);
}
if (transaction.docChanged) return RangeSet.empty;
return markers;
},
provide: (field) =>
gutter({
class: "cm-run-statement-gutter",
markers: (currentView) => currentView.state.field(field),
lineMarker(currentView, line, markers) {
const executionMarker = markers.find((marker) => marker instanceof StatementExecutionStateMarker)?.marker;
// Membership check against the lenient index (mapped through the
// ChangeSet while typing) instead of a full-document re-parse per
// keystroke. The click handler still resolves the exact range on
// demand, so display-level approximation never affects execution.
const canExecute = showRunButtons && statementGutterStartIndexHasStartAt(statementBoundariesForState(currentView.state).startsIndex, line.from);
return canExecute || executionMarker ? new StatementGutterMarker(canExecute, executionMarker) : null;
},
lineMarkerChange(update) {
// Redraw once the debounced full rebuild has landed.
const refreshEffect = statementBoundariesRefreshEffect;
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
},
domEventHandlers: showRunButtons
? {
mousedown: executeSqlStatementFromGutter,
}
: {},
}),
});
return field;
};
buildSqlSignatureExtension = () =>
showTooltip.compute(["doc", "selection"], (currentState) => {
const cursor = currentState.selection.main.head;
// Signature detection only scans backward from the cursor, so window the
// text instead of materializing the whole document prefix on every
// keystroke — doc.toString() was O(document) per key on large scripts.
const windowFrom = Math.max(0, cursor - SQL_SIGNATURE_HELP_WINDOW_CHARS);
const signature = getSqlFunctionSignatureHelp(currentState.doc.sliceString(windowFrom, cursor), cursor - windowFrom, props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 });
if (!signature) return null;
return {
pos: cursor,
above: false,
clip: false,
create: () => ({ dom: createSqlSignatureTooltipDom(signature) }),
};
});
buildSqlCompletionExtension = () =>
autocompletion({
activateOnTyping: true,
defaultKeymap: false,
selectOnOpen: settingsStore.editorSettings.selectFirstCompletionOnOpen,
compareCompletions: (a, b) => compareSqlCompletions(a, b, settingsStore.editorSettings.sortCompletionColumnsAlphabetically),
// Keep normal completion lists virtualized; batch-field selection needs every row in the DOM.
maxRenderedOptions: batchColumnSelectionExpandedRendering ? Number.MAX_SAFE_INTEGER : 100,
optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : ""),
addToOptions: [
{ position: 5, render: renderBatchColumnSelectionActionMarker },
{ position: 10, render: renderBatchColumnSelectionCheckbox },
],
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
});
const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });
buildSqlLanguageExtension = () => [
langSql.sql({
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
}),
// Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the
// comment marker that toggleLineComment reads from language data.
Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])),
Prec.highest(EditorState.languageData.of(() => queryEditorWordLanguageData(props.databaseType))),
// The SQL grammar does not tokenize `//`, so those comments are highlighted by hand.
queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : [],
];
const MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS = 32;
const MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH = 128_000;
const SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS = 100;
const refreshSqlSemanticHighlightEffect = StateEffect.define<null>();
buildSqlSemanticHighlightExtension = () => [
createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }),
ViewPlugin.fromClass(
class {
decorations: import("@codemirror/view").DecorationSet;
private refreshTask = createDeferredEditorTask(() => {
if (this.currentView.dom.isConnected) this.currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
private cachedDoc: import("@codemirror/state").Text | null = null;
private prewarmedDoc: import("@codemirror/state").Text | null = null;
private cachedSql = "";
private cachedDialectId = "";
private cachedDatabaseType: DatabaseType | undefined;
private cachedWindows: Array<{
from: number;
to: number;
spans: Array<{ start: number; end: number }>;
}> = [];
constructor(private currentView: import("@codemirror/view").EditorView) {
this.decorations = Decoration.none;
this.decorations = this.buildDecorations(currentView);
}
update(update: import("@codemirror/view").ViewUpdate) {
const refreshRequested = update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshSqlSemanticHighlightEffect)));
if (update.docChanged) {
this.decorations = this.decorations.map(update.changes);
this.scheduleRefresh(update.view);
return;
}
if (refreshRequested) {
this.cancelRefresh();
this.decorations = this.buildDecorations(update.view);
} else if (update.viewportChanged) {
// Keep existing decorations while scrolling. Parse only after the
// viewport settles instead of blocking CodeMirror's layout update.
this.scheduleRefresh(update.view);
}
}
scheduleRefresh(currentView: import("@codemirror/view").EditorView) {
this.currentView = currentView;
this.refreshTask.schedule();
}
cancelRefresh() {
this.refreshTask.cancel();
}
destroy() {
this.cancelRefresh();
}
buildDecorations(currentView: import("@codemirror/view").EditorView) {
const dialectId = resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() });
const doc = currentView.state.doc;
if (this.cachedDoc !== doc || this.cachedDialectId !== dialectId || this.cachedDatabaseType !== props.databaseType) {
this.cachedDoc = doc;
this.cachedSql = doc.toString();
this.cachedDialectId = dialectId;
this.cachedDatabaseType = props.databaseType;
this.cachedWindows = [];
}
const sql = this.cachedSql;
const shouldPrewarmFullDocument = this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
const windows: Array<{
from: number;
to: number;
spans: Array<{ start: number; end: number }>;
}> = [];
const pendingWindows: Array<{ from: number; to: number }> = [];
const rangesToHighlight = shouldPrewarmFullDocument ? [{ from: 0, to: sql.length }] : currentView.visibleRanges;
for (const visibleRange of rangesToHighlight) {
const cached = this.cachedWindows.find((candidate) => candidate.from <= visibleRange.from && candidate.to >= visibleRange.to);
if (cached) {
if (!windows.includes(cached)) windows.push(cached);
continue;
}
const next = expandToSqlStatementWindow(sql, visibleRange.from, visibleRange.to, dialectId);
const cachedWindow = this.cachedWindows.find((candidate) => candidate.from <= next.from && candidate.to >= next.to);
if (cachedWindow) {
if (!windows.includes(cachedWindow)) windows.push(cachedWindow);
continue;
}
const previous = pendingWindows[pendingWindows.length - 1];
if (previous && next.from <= previous.to) previous.to = Math.max(previous.to, next.to);
else pendingWindows.push({ ...next });
}
if (pendingWindows.length > 0) {
const requestedTo = Math.max(...pendingWindows.map((window) => window.to));
const tree = ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
if (!tree) {
// The Lezer parse has not reached the pending windows yet (long
// documents). Keep the decorations that are still valid and let
// the deferred refresh rebuild them once parsing catches up —
// returning an empty set here wiped table-name colors after
// scrolling stopped or when a freshly mounted editor (tab
// switch) had no later viewport change to trigger a rebuild.
this.scheduleRefresh(currentView);
return this.decorations;
}
if (shouldPrewarmFullDocument) this.prewarmedDoc = doc;
for (const window of pendingWindows) {
const entry = {
...window,
spans: sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
}),
};
this.cachedWindows.push(entry);
windows.push(entry);
}
if (this.cachedWindows.length > MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS) {
this.cachedWindows.splice(0, this.cachedWindows.length - MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS);
}
}
const ranges = windows.flatMap((window) => window.spans);
return Decoration.set(
ranges.map((range) =>
Decoration.mark({
class: "cm-sql-table-name",
attributes: {
"data-sql-token": "table",
},
}).range(range.start, range.end),
),
true,
);
}
},
{ decorations: (value) => value.decorations },
),
sqlSemanticHighlightTheme(EditorView),
shellLineCommentTheme(EditorView),
];
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
if (initialSettings.vimModeEnabled) {
await ensureCodeMirrorVim();
}
const currentStatementFrameExtension = currentStatementFrameLayer(
{ layer, RectangleMarker },
(view) => {
if (!settingsStore.editorSettings.showCurrentStatementFrame) return null;
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
const cursorPos = view.state.selection.main.head;
const boundaries = statementBoundariesForState(view.state);
if (boundaries && !boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
// Typing in progress: reuse the ChangeSet-shifted range instead of
// re-parsing the whole document. The debounced refresh rebuilds and
// repaints the exact frame once typing pauses.
return { from: boundaries.frameRange.from, to: currentStatementFrameTo(view, { from: boundaries.frameRange.from, to: boundaries.frameRange.to, sql: "" }) };
}
let range = currentExecutableStatementRange(view);
if (!range) {
const cursorLine = view.state.doc.lineAt(cursorPos);
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, view.state.doc, props.databaseType, sqlStatementParameterOptions());
// Find ranges that overlap the cursor line, then expand to include
// adjacent ranges (handles parser fragments from edge cases like
// ultra-long comments splitting a statement).
let mergedFrom = cursorLine.from;
let mergedTo = cursorLine.from;
let changed = true;
while (changed) {
changed = false;
for (const cachedRange of executableStatementRangeCache.ranges) {
// Only merge ranges that overlap the cursor line or are adjacent
// to the current merged region
if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) {
const newFrom = Math.min(mergedFrom, cachedRange.from);
const newTo = Math.max(mergedTo, cachedRange.to);
if (newFrom !== mergedFrom || newTo !== mergedTo) {
mergedFrom = newFrom;
mergedTo = newTo;
changed = true;
}
}
}
}
if (mergedTo > mergedFrom) {
range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) };
}
}
if (boundaries) boundaries.frameRange = range ? { from: range.from, to: range.to } : null;
if (!range) return null;
return { from: range.from, to: currentStatementFrameTo(view, range) };
},
{
shouldRefresh(update) {
const refreshEffect = statementBoundariesRefreshEffect;
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
},
},
);
function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
return currentStatementFrameRangeTo(view.state.doc, range);
}
const activeLineHighlighter = ViewPlugin.fromClass(
class {
decorations: import("@codemirror/view").DecorationSet;
constructor(view: import("@codemirror/view").EditorView) {
this.decorations = this.getDeco(view);
}
update(update: import("@codemirror/view").ViewUpdate) {
if (update.docChanged || update.selectionSet) this.decorations = this.getDeco(update.view);
}
getDeco(view: import("@codemirror/view").EditorView) {
if (!view.state.selection.main.empty) return Decoration.none;
let lastLineStart = -1;
const deco: any[] = [];
for (const r of view.state.selection.ranges) {
if (!r.empty) continue;
const line = view.lineBlockAt(r.head);
if (line.from > lastLineStart) {
deco.push(Decoration.line({ class: "cm-activeLine" }).range(line.from));
lastLineStart = line.from;
}
}
return Decoration.set(deco);
}
},
{ decorations: (v) => v.decorations },
);
const editorElement = editorRef.value;
if (!editorElement) return;
const tooltipParent = editorElement.closest<HTMLElement>("#root")?.querySelector<HTMLElement>("#dbx-query-editor-tooltip-root") ?? editorElement;
const state = EditorState.create({
doc: props.modelValue,
selection: normalizedEditorSelection(props.initialSelection, props.modelValue.length),
extensions: [
cmSearch({
top: true,
createPanel: () => {
const dom = document.createElement("span");
dom.style.display = "none";
return { dom };
},
// Center the match instead of the default "nearest" alignment, which
// often lands the match flush against the viewport edge and makes an
// immediate drag-select there trigger CodeMirror's edge autoscroll.
scrollToMatch: (range) => EditorView.scrollIntoView(range, { y: "center" }),
}),
// Must update before the run gutter's plugin registers below: each
// keystroke maps the boundary view to the new doc before any lineMarker
// callback reads it, otherwise the gutter would trigger a full parse.
statementBoundariesTrackingPlugin,
runGutterComp.of(runStatementGutterExtension()),
lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
currentStatementFrameExtension,
highlightActiveLineGutter(),
highlightSpecialChars(),
historyResetComp.of(history()),
foldGutter({
markerDOM(open: boolean) {
const span = document.createElement("span");
span.className = "cm-foldMarker-svg";
span.innerHTML = open
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return span;
},
}),
sqlBlockFoldService,
drawSelection(),
trimmedSelectionLayer(),
selectionMatchOccurrences(),
dropCursor(),
props.readOnly ? [] : scrollPastEnd(),
EditorView.dragMovesSelection.of((event) => !event.ctrlKey && !event.metaKey),
Prec.highest(
EditorView.mouseSelectionStyle.of((currentView, event) =>
createQueryEditorStringMouseSelection(currentView, event, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
language: queryEditorSelectionLanguage(),
composing: isEditorComposing(currentView),
}),
),
),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
crosshairCursor(),
activeLineHighlighter,
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
vimModeComp.of(vimModeExtension(initialSettings.vimModeEnabled)),
defaultKeymapComp.of(defaultKeymapExtension()),
keymap.of([...searchKeymapWithoutModD(searchKeymap), ...historyKeymap, ...foldKeymap, ...completionKeymap]),
sqlLanguageComp.of(buildSqlLanguageExtension()),
sqlSemanticHighlightComp.of(buildSqlSemanticHighlightExtension()),
tooltips({ parent: tooltipParent }),
completionComp.of(buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
codeMirrorTheme.of(theme),
closeBracketsComp.of(closeBracketsExtension(initialSettings.autoCloseBrackets)),
bracketMatching(),
// Fix: intercept quote characters to prevent closeBrackets from
// producing triple quotes ('''). When the cursor is immediately
// before an auto-inserted closing quote, just skip past it.
Prec.highest(
EditorView.inputHandler.of((view: EditorViewType, _from: number, _to: number, text: string) => {
if (text !== "'" && text !== '"' && text !== "`") return false;
const pos = view.state.selection.main.head;
const nextChar = view.state.doc.sliceString(pos, pos + 1);
if (nextChar !== text) return false;
// Only skip when the character ahead matches and it was auto-inserted
// (i.e. the doc has a matching pair at this position).
const prevChar = pos > 0 ? view.state.doc.sliceString(pos - 1, pos) : "";
if (prevChar === text) return false; // already inside a quoted region
view.dispatch({
selection: { anchor: pos + 1 },
scrollIntoView: true,
});
return true;
}),
),
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
sqlSignatureComp.of(buildSqlSignatureExtension()),
diagnosticComp.of(buildSqlDiagnosticExtension()),
createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
}),
previewRangeComp.of(buildPreviewRangeExtension()),
buildResultSourceRangeExtension(),
Prec.highest(
keymap.of([
{ key: "'", run: handleSqlSingleQuote },
{ key: "Tab", run: handleTab },
{
key: "Escape",
run: createQueryEditorEscapeHandler({
clearBatchSelection: clearBatchColumnSelectionSession,
cancelPendingAcceptance: () => {
clearPendingCompletionEnter();
clearPendingCompletionTab();
},
closeSearch: () => searchPanelRef.value?.closeSearch() ?? false,
closeCompletion: (currentView) => codeMirrorCloseCompletion?.(currentView) ?? false,
}),
},
]),
),
runKeymapComp.of(runKeymapExtension(keymap)),
Prec.highest(
EditorView.domEventHandlers({
keydown(event, currentView) {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
return runQueryEditorAltExtendSelection(event, shortcuts.extendSelection, currentView, extendQueryEditorSelectionForView);
},
}),
),
wordWrapComp.of(props.forceWordWrap || initialSettings.wordWrap ? EditorView.lineWrapping : []),
readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]),
indentComp.of(indentExtension()),
// Alt+drag belongs exclusively to rectangular selection. Registering the
// same gesture as an added cursor preserves the previous cursor.
rectangularSelection({
eventFilter: startsQueryEditorRectangularSelection,
}),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
searchPanelRef.value?.scheduleDocumentSearchUpdate();
if (isEditorComposing(update.view)) {
pendingImeModelEmit = true;
completionEpoch++;
} else {
emitModelValue(update.view);
invalidateSemanticDiagnosticsForDocumentChange();
scheduleSemanticDiagnostics();
let insertedText = "";
let removedText = "";
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
insertedText += inserted.toString();
removedText += update.startState.doc.sliceString(fromA, toA);
});
const suppressCompletionAutoStart = consumeSqlCompletionAutoStartSuppression();
if (!suppressCompletionAutoStart) scheduleDeferredCompletionTrigger(update.view, insertedText, removedText);
}
if (update.transactions.some((tr) => tr.isUserEvent("input.paste"))) {
resyncCaretAfterPaste(update.view);
}
}
if (update.selectionSet || update.docChanged) {
syncEditorSelectionState(update.view);
schedulePreviewContextRefresh(update.view);
emit("selectionChange", selectedSql.value);
emit("cursorChange", update.state.selection.main.head);
latestSelection = readEditorSelection(update.view);
if (editorIsActive) emitEditorSelection(latestSelection);
}
// Clear activeCompletionOrigin when the completion session ends.
if (codeMirrorCompletionStatus) {
const status = codeMirrorCompletionStatus(update.state) ?? null;
if (status === null) {
activeCompletionOrigin = null;
clearBatchColumnSelectionSession();
}
}
}),
fontThemeComp.of(
editorFontTheme(EditorView, liveFontSize.value, initialSettings.fontFamily, {
fixedHeight: true,
scrollable: true,
}),
),
EditorView.domEventHandlers({
paste(event, currentView) {
return recoverLargeTauriPaste(event, currentView);
},
dragover(event) {
if (props.readOnly || !hasDroppedTableReference(event)) {
hideQueryEditorDropCaret();
return false;
}
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
showQueryEditorDropCaretAt(event.clientX, event.clientY);
return true;
},
dragleave() {
hideQueryEditorDropCaret();
return false;
},
drop(event, currentView) {
hideQueryEditorDropCaret();
return insertDroppedTableReference(currentView, event);
},
blur(_event, currentView) {
latestSelection = readEditorSelection(currentView);
if (editorIsActive) emitEditorSelection(latestSelection);
return false;
},
compositionstart() {
imeCompositionActive = true;
completionEpoch++;
return false;
},
compositionend() {
imeCompositionActive = false;
window.setTimeout(flushImeComposition, 0);
return false;
},
[DBX_VIM_SAVE_EVENT]() {
emit("save");
return true;
},
wheel(event) {
if (!wheelZoomGestureGuard.accepts(event)) return false;
event.preventDefault();
const next = fontSizeFromWheelDelta(liveFontSize.value, event.deltaY);
applyLiveFontSize(next);
scheduleFontSizeCommit(next);
return true;
},
mousemove: (event: MouseEvent) => {
const currentView = view.value;
if (!currentView) return false;
updateTableNavigationHover(currentView, event);
return false;
},
mouseleave: () => {
clearTableNavigationHover();
return false;
},
mousedown: (event: MouseEvent) => {
clearTableNavigationHover();
dismissHoverTooltip();
const currentView = view.value;
if (currentView && startEditorSelectionDrag(currentView, event)) {
return true;
}
if (currentView) stabilizeUnfocusedQueryEditorPointerDown(currentView, event);
// Alt belongs to CodeMirror's rectangular and multi-cursor gestures,
// even when Cmd/Ctrl is held at the same time.
if (!usesQueryEditorObjectNavigationModifier(event)) {
// Click without modifier -> close column panel
if (!event.metaKey && !event.ctrlKey && event.button === 0) {
emit("closeColumnPanel");
}
return false;
}
if (event.button !== 0) return false;
if (!currentView || !props.connectionId || props.database == null) {
return false;
}
// Use posAtCoords for accurate click position
const coords = { x: event.clientX, y: event.clientY };
const pos = currentView.posAtCoords(coords);
if (pos == null) {
return false;
}
const doc = currentView.state.doc.toString();
const extracted = extractIdentifierDetailsAt(doc, pos);
if (!extracted) {
return false;
}
if (!extracted.quoted && isSqlKeyword(extracted.identifier)) {
return false;
}
const identifier = extracted.identifier;
// Prevent default, resolve async
event.preventDefault();
setTimeout(async () => {
try {
// Single identity model: quote flags + role (relation column list vs routine call vs unknown).
const identity = resolveSqlObjectNavigationIdentity(doc, pos);
if (!identity) return;
const identifierParts = identity.parts.map((part) => part.value);
const tableLookupFilter = identity.name;
const objectNameFilter = identity.name;
// 3-part schema.package.member; 2-part stays ambiguous until metadata resolves it.
const objectParentHint = identity.parts.length >= 3 ? identity.qualifier : undefined;
const objectSchemaHint = identity.parts.length >= 3 ? identity.schema : identity.parts.length === 1 ? props.schema : undefined;
const isRoutineCall = identity.role === "routine_call";
const isRelationColumnList = identity.role === "relation_column_list";
const tableLookup = resolveHoverTableLookupTarget({
database: props.database!,
schema: props.schema,
catalog: props.catalog,
databaseType: props.databaseType,
tableName: tableLookupFilter,
identifierParts,
mode: settingsStore.editorSettings.tableHoverLookupMode,
});
const tableLookupSchema = tableLookup.preferGlobalFirst ? undefined : (tableLookup.schema ?? props.schema);
const matchNavigationTable = (tables: typeof cachedTables) =>
matchHoverTableCandidates(tables, {
lookups: [identifier],
tableName: tableLookupFilter,
preferredSchema: props.schema,
});
const relationNavigationTarget = (target: SqlObjectNavigationTarget) =>
queryTableNavigationTargetAtSqlPosition(
{
connectionId: props.connectionId!,
database: props.database!,
schema: props.schema,
databaseType: props.databaseType,
sql: doc,
position: pos,
},
target,
);
// 1. Local table lookup with the resolved scope (do not trust stale editor cache alone —
// completion/hover may have filled cachedTables with the current schema only).
const localScopedTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema, props.catalog);
cachedTables = mergeCompletionTables(localScopedTables, cachedTables);
let matchedTable = matchNavigationTable(localScopedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
if (!matchedTable && tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) {
const localGlobalTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, undefined, props.catalog);
cachedTables = mergeCompletionTables(cachedTables, localGlobalTables);
matchedTable = matchNavigationTable(localGlobalTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
}
const preserveOracleStoreCase = (value?: string) => !!value && value !== value.toUpperCase();
const openMatchedObject = (matchedObject: { name: string; schema?: string; type: string; signature?: string; parentName?: string; parentSchema?: string }) => {
const navigationType = sqlObjectNavigationTypeFromCompletionObjectType(matchedObject.type);
if (!navigationType) return false;
// Metadata names are store-correct; mark mixed-case (or click-quoted) so Oracle normalize won't force UPPER.
emit(
"openObjectSource",
sqlObjectNavigationTarget({
name: matchedObject.name,
schema: matchedObject.schema,
type: navigationType,
signature: matchedObject.signature,
parentName: matchedObject.parentName,
parentSchema: matchedObject.parentSchema,
nameQuoted: identity.nameQuoted || preserveOracleStoreCase(matchedObject.name),
schemaQuoted: matchedObject.schema ? identity.schemaQuoted || identity.qualifierQuoted || preserveOracleStoreCase(matchedObject.schema) : undefined,
parentNameQuoted: matchedObject.parentName ? identity.qualifierQuoted || preserveOracleStoreCase(matchedObject.parentName) : undefined,
parentSchemaQuoted: matchedObject.parentSchema ? identity.schemaQuoted || preserveOracleStoreCase(matchedObject.parentSchema) : undefined,
}),
false,
);
return true;
};
// 1b. Local routine cache — skip for pure relation column lists (INSERT INTO t(...)).
if (!isRelationColumnList) {
const localObjects = connectionStore.lookupLocalCompletionObjects(props.connectionId!, props.database!, objectNameFilter, MAX_COMPLETION_TABLES, props.schema);
let matchedObject = matchSqlObject(identifier, localObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
if (!usesLocalOnlyCompletionMetadata()) {
// Disambiguate schema.routine vs package.member with small scoped lookups (no global scan).
if (identity.parts.length === 2 && identity.qualifier) {
// Prefer package.member under session schema (Oracle common: PKG.MEMBER()).
const packageObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, objectNameFilter, 20, props.schema, identity.qualifier, false, props.schema, ["routine"]);
matchedObject = matchSqlObject(identifier, packageObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
// Then schema.routine with qualifier as owner.
const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, objectNameFilter, 20, identity.qualifier, undefined, false, props.schema, ["routine"]);
matchedObject = matchSqlObject(identifier, schemaObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
} else {
const scopedObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, objectNameFilter, 20, objectSchemaHint, objectParentHint, false, props.schema, ["routine"]);
matchedObject = matchSqlObject(identifier, scopedObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
}
}
}
// 1c. Remote table metadata — never skip for relation column lists or unknown identifiers.
// Routine-call sites may still hit this when a table and procedure share a name and
// local caches were empty; table wins only if listed as a relation.
if (!usesLocalOnlyCompletionMetadata() && (!isRoutineCall || isRelationColumnList || identity.role === "unknown")) {
cachedTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema, tableLookup.preferGlobalFirst, props.schema, props.catalog);
matchedTable = matchNavigationTable(cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
if (tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) {
const globalTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, undefined, true, props.schema, props.catalog);
cachedTables = mergeCompletionTables(cachedTables, globalTables);
matchedTable = matchNavigationTable(cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
}
} else if (!usesLocalOnlyCompletionMetadata() && isRoutineCall) {
// Lightweight table check so INSERT INTO ORDERS(…) is not the only guarded path —
// still avoid unbounded scans: session-scoped first, optional name-filtered global fallback.
cachedTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, 20, tableLookupSchema, tableLookup.preferGlobalFirst, props.schema, props.catalog);
matchedTable = matchNavigationTable(cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
if (tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) {
const globalTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, 20, undefined, true, props.schema, props.catalog);
cachedTables = mergeCompletionTables(cachedTables, globalTables);
matchedTable = matchNavigationTable(cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
}
}
// 1d. Optimistic routine open only for confirmed call sites after metadata + table checks.
if (isRoutineCall) {
const optimistic = sqlObjectNavigationTargetFromIdentity(identity, {
fallbackSchema: props.schema,
preferType: "procedure",
// 2-part: package.member under session schema (not schema.routine).
asPackageMember: identity.twoPartAmbiguous,
});
if (optimistic) {
emit("openObjectSource", optimistic, false);
return;
}
}
// 2. Parse SQL at click position to get referenced tables
const context = getSqlCompletionContext(doc, pos);
let referencedTables: Array<SqlCompletionReferencedTable & Pick<SqlCompletionTable, "type">> = context.referencedTables;
// Enrich referenced tables with schema from cachedTables
referencedTables = referencedTables.map((rt) => {
if (usesOracleSessionCompletionColumns(rt.schema)) return rt;
const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase() && (!rt.schema || !ct.schema || ct.schema.toLowerCase() === rt.schema.toLowerCase()));
if (!cached) return rt;
return {
...rt,
...(!rt.schema && cached.schema ? { schema: cached.schema } : {}),
...(cached.type ? { type: cached.type } : {}),
};
});
// Check if identifier has a qualifier (e.g., c.card_name or schema.table)
const qualifier = identifierParts.length >= 2 ? identifierParts[identifierParts.length - 2] : null;
const matchedRef = matchTable(identifier, referencedTables);
if (matchedRef) {
emit("clickTable", relationNavigationTarget(matchedRef));
return;
}
const colName = identifierParts[identifierParts.length - 1] ?? identifier;
const colLower = colName.toLowerCase();
if (referencedTables.length === 0) {
return;
}
// 3. Fetch columns — if qualifier, only check matching table; otherwise check all
const tablesToCheck = qualifier ? referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : referencedTables;
if (tablesToCheck.length === 0 && qualifier) {
return;
}
const matchedCols: Array<{
name: string;
table: string;
schema?: string;
}> = [];
for (const refTable of tablesToCheck) {
const cacheKey = completionCacheKey(refTable);
// Use persistent column cache; fetch only if missing
let cols = cachedColumnsByTable.get(cacheKey);
if (!cols) {
try {
const target = completionMetadataTarget(refTable);
if (!target) continue;
cols = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog, refTable);
cachedColumnsByTable.set(cacheKey, cols);
} catch {
continue;
}
}
for (const col of cols) {
if (col.name.toLowerCase() === colLower) {
matchedCols.push({
name: col.name,
table: refTable.name,
schema: col.schema || refTable.schema,
});
}
}
}
if (matchedCols.length > 0) {
emit("clickColumn", matchedCols);
}
} catch (e) {
console.error("[DBX] Ctrl+click error:", e);
}
}, 0);
return true;
},
}),
],
});
view.value = new EditorView({ state, parent: editorElement });
batchColumnSelectionTooltipParents.set(view.value, tooltipParent);
postCompositionKeyGuardCleanup = postCompositionKeyGuard.attach(view.value.contentDOM);
registerEditorScrollbarPointerGuard(view.value);
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
passive: true,
});
// Register context-menu scroll listener on the actual EditorView scrollDOM
// (deferred until after creation so view.value is non-null).
const scrollDOM = view.value.scrollDOM;
const onEditorScroll = () => {
if (contextMenuOpen.value) {
contextMenuOpen.value = false;
}
};
scrollDOM.addEventListener("scroll", onEditorScroll);
contextMenuPointerCleanup = () => {
scrollDOM.removeEventListener("scroll", onEditorScroll);
contextMenuPointerCleanup = null;
};
restoreEditorSelection(props.initialSelection, !props.initialViewport);
restoreEditorViewport();
syncContextMenuState(view.value);
emit("previewChangesAvailable", !!previewContextSql.value);
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
syncEditorDiagnosticCssVars();
registerTableReferenceDropListener();
cachedTables = [];
cachedCompletionObjectsByScope.clear();
scheduleSemanticDiagnostics();
if (props.autoFocus) {
// Query tabs opt in; shared editor instances must preserve the surrounding UI focus.
nextTick(() => {
requestAnimationFrame(() => {
focusEditorView(view.value);
});
});
}
// Ensure theme is applied with the latest settings after mount
void nextTick(async () => {
if (!view.value || !codeMirrorTheme) return;
const settings = settingsStore.editorSettings;
const themeColors = settings.theme === "custom" ? getCurrentCustomThemeColors() : settings.customThemeColors;
const themeExt = await loadEditorTheme(settings.theme, editorThemeAppearance(), themeColors, themePalette.value);
view.value.dispatch({
effects: [codeMirrorTheme.reconfigure(themeExt)],
});
});
});
// When completionTriggerMode changes, close any open typing session
// that would no longer be allowed under the new mode.
watch(
() => settingsStore.editorSettings.completionTriggerMode,
(newMode) => {
if (!view.value || !codeMirrorCompletionStatus || !codeMirrorCloseCompletion) return;
const status = codeMirrorCompletionStatus(view.value.state);
if (!status || activeCompletionOrigin !== "typing") return;
// If switching to manual, close all typing sessions.
if (newMode === "manual") {
codeMirrorCloseCompletion(view.value);
return;
}
// For other mode changes, re-evaluate the policy.
// If the current position would not trigger under the new mode, close.
const fullDoc = view.value.state.doc.toString();
const position = view.value.state.selection.main.head;
if (!shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
codeMirrorCloseCompletion(view.value);
}
},
);
// 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;
// Flush the outgoing document before props and restored scroll positions
// become the new tab's state. The event carries its original owner. A
// before-tab-switch capture already flushed this editor while it was still
// visible, so avoid reading the reset scroll position during the transition.
if (!tabSwitchStateCaptured) flushEditorViewport();
viewportOwnerTabId = tabId;
latestViewport = props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 };
lastEmittedViewport = undefined;
clearScheduledPreviewContextRefresh();
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);
// First activation in this editor instance (or a cache-evicted tab, e.g.
// beyond MAX_CACHED_TAB_STATES): restore the tab's saved cursor and scroll
// position exactly like the cached-state branch, otherwise the swapped-in
// document keeps whatever scroll offset the dispatch left behind (#8374).
// A brand-new tab has no saved state, so reset it instead of falling back
// to the previous tab's latest position (#8378).
restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 }, !props.initialViewport);
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
// 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(undefined, !props.initialViewport);
restoreEditorViewport();
clearScheduledPreviewContextRefresh();
syncContextMenuState(currentView);
emit("previewChangesAvailable", !!previewContextSql.value);
scheduleSemanticDiagnostics();
}
watch([() => props.tabId, () => props.modelValue], ([tabId, val], [prevTabId]) => {
if (!view.value) return;
if (tabId !== prevTabId) {
activateTabDocument(prevTabId, tabId, val);
if (props.autoFocus) restoreEditorFocus();
return;
}
if (val !== currentEditorDocText(view.value)) {
if (isEditorComposing(view.value)) return;
view.value.dispatch({
changes: { from: 0, to: view.value.state.doc.length, insert: val },
});
scheduleSemanticDiagnostics();
}
});
watch(
() => props.initialViewport,
(viewport, previousViewport) => {
if (!view.value || !viewport || previousViewport) return;
// Saved SQL content can hydrate after the editor has already mounted. In
// that case the initial prop was undefined and the mount-time restore had
// nothing to apply.
latestViewport = { ...viewport };
lastEmittedViewport = { ...viewport };
restoreEditorViewport(viewport);
},
{ deep: true },
);
watch(
() => props.initialSelection,
(selection, previousSelection) => {
if (!view.value || !selection || previousSelection) return;
// Keep the cursor and viewport in sync when a saved SQL tab hydrates after
// the editor component has already been mounted.
restoreEditorSelection(selection, !props.initialViewport);
},
{ deep: true },
);
watch(
() => props.formatRequestId,
(val) => {
if (val && val > lastHandledFormatRequestId) {
lastHandledFormatRequestId = val;
formatCurrentSql();
}
},
);
watch(
() => props.compressRequestId,
(val) => {
if (val && val > lastHandledCompressRequestId) {
lastHandledCompressRequestId = val;
compressCurrentSql();
}
},
);
watch(
() => props.executionError,
() => {
reconfigureDiagnostics();
},
);
watch(
() => props.statementExecutionMarkers ?? [],
(markers) => {
if (!view.value || !setStatementExecutionMarkersEffect) return;
view.value.dispatch({
effects: setStatementExecutionMarkersEffect.of(markers),
});
},
{ deep: true },
);
watch(
() => props.connectionId,
() => {
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
},
);
watch(
() => props.database,
() => {
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
},
);
watch(
() => props.catalog,
() => {
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
},
);
watch(
() => props.schema,
() => {
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
},
);
watch(
() => connectionStore.completionCacheRevision(props.connectionId, props.database),
() => {
completionEpoch++;
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
},
);
watch([() => props.clientSessionId, () => props.completionContextVersion], () => {
completionEpoch++;
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
});
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile], () => {
executableStatementRangeCache = null;
statementBoundariesGeneration += 1;
if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension || !sqlSignatureComp || !buildSqlSignatureExtension) return;
// Signature tooltips depend on the external dialect, so refresh them even when the document and selection stay unchanged.
view.value.dispatch({
effects: [sqlLanguageComp.reconfigure(buildSqlLanguageExtension()), sqlSemanticHighlightComp.reconfigure(buildSqlSemanticHighlightExtension()), sqlSignatureComp.reconfigure(buildSqlSignatureExtension())],
});
});
// openGauss compatibility mode is loaded asynchronously from the backend into a
// dedicated store map (not the sidebar tree). A restored tab may open before the
// map is warm; when the mode arrives, re-derive statement boundaries and
// diagnostics so package DDL is parsed with the correct PL/SQL rules.
watch(
() => (props.databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(props.connectionId, props.database) : undefined),
(now, before) => {
if (now === before) return;
executableStatementRangeCache = null;
statementBoundariesGeneration += 1;
if (props.databaseType !== "opengauss") return;
if (!view.value) return;
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics(0);
},
);
watch(
() => props.forceWordWrap,
() => {
if (!view.value || !wordWrapComp) return;
view.value.dispatch({
effects: wordWrapComp.reconfigure(wordWrapExtension()),
});
},
);
// Derive current custom theme colors from settingsStore
function getCurrentCustomThemeColors() {
const settings = settingsStore.editorSettings;
if (settings.theme !== "custom") return settings.customThemeColors;
const activeTheme = settings.customThemes?.find((t: { id: string }) => t.id === settings.activeCustomThemeId) || settings.customThemes?.[0];
return activeTheme?.colors ?? settings.customThemeColors;
}
// 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],
() => {
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],
() => {
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],
() => {
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],
() => {
applyEditorCompletionExtension();
},
{ deep: true },
);
watch(
() => settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled,
(enabled) => {
if (props.databaseType === "redis" || props.databaseType === "victoriametrics") return;
if (!shouldSkipSqlSemanticDiagnostics() && enabled) {
scheduleSemanticDiagnostics(0);
return;
}
clearScheduledSemanticDiagnostics();
setSemanticDiagnostics([]);
},
);
watch(
() => settingsStore.editorSettings.showInsertValueHints,
() => {
if (view.value) requestInsertValueHintsRefresh(view.value);
},
);
function pauseQueryEditorBackgroundWork() {
finishBatchColumnSelectionDrag(false);
cancelBatchColumnSelectionRefresh();
const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured;
tabSwitchStateCaptured = false;
// A KeepAlive-evicted editor can be unmounted after it was already
// deactivated. Its DOM scroll position has been reset by then, so flushing
// that inactive view would overwrite the saved viewport with zero.
if (editorIsActive && !stateWasCapturedBeforeTabSwitch) {
flushEditorViewport();
flushEditorSelection();
emit("editorStateFlushed");
}
clearTableNavigationHover();
clearPendingCompletionEnter();
clearPendingCompletionTab();
clearDeferredCompletionTrigger();
clearScheduledPreviewContextRefresh();
executionViewportOwnership.reset();
editorIsActive = false;
clearScheduledSemanticDiagnostics();
completionEpoch++;
unregisterTableReferenceDropListener();
}
function captureEditorStateBeforeTabSwitch(event: Event) {
const fromTabId = (event as CustomEvent<{ fromTabId?: string }>).detail?.fromTabId;
if (!view.value || !fromTabId || fromTabId !== props.tabId) return;
// Capture while the outgoing editor is still visible. Once KeepAlive starts
// deactivating the surface, WebKit can report a reset scrollTop of zero.
flushEditorViewport();
flushEditorSelection();
emit("editorStateFlushed");
tabSwitchStateCaptured = true;
}
function resumeQueryEditorBackgroundWork() {
editorIsActive = true;
registerTableReferenceDropListener();
scheduleSemanticDiagnostics();
if (view.value) schedulePreviewContextRefresh(view.value);
restoreEditorSelection(undefined, !props.initialViewport);
restoreEditorFocus();
restoreEditorViewport();
}
onActivated(resumeQueryEditorBackgroundWork);
onDeactivated(pauseQueryEditorBackgroundWork);
onMounted(() => {
if (typeof window === "undefined") return;
window.addEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch);
});
onBeforeUnmount(() => {
pauseQueryEditorBackgroundWork();
viewportEmitTask.cancel();
if (viewportRestoreFrame !== null) {
cancelAnimationFrame(viewportRestoreFrame);
viewportRestoreFrame = null;
}
editorScrollbarPointerCleanup?.();
editorSelectionDragCleanup?.();
view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit);
window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
window.removeEventListener("blur", clearTableNavigationHover);
window.removeEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch);
contextMenuPointerCleanup?.();
postCompositionKeyGuardCleanup?.();
postCompositionKeyGuardCleanup = null;
zoomCommitScheduler.dispose();
view.value?.destroy();
});
function readEditorViewport(currentView: EditorViewType) {
return {
scrollTop: Math.max(0, currentView.scrollDOM.scrollTop),
scrollLeft: Math.max(0, currentView.scrollDOM.scrollLeft),
};
}
function sameEditorViewport(a: { scrollTop: number; scrollLeft: number } | undefined, b: { scrollTop: number; scrollLeft: number }) {
return a?.scrollTop === b.scrollTop && a.scrollLeft === b.scrollLeft;
}
function normalizedEditorSelection(selection: { anchor: number; head: number } | undefined, docLength: number) {
if (!selection) return undefined;
return {
anchor: Math.min(Math.max(0, selection.anchor), docLength),
head: Math.min(Math.max(0, selection.head), docLength),
};
}
function readEditorSelection(currentView: EditorViewType) {
const selection = currentView.state.selection.main;
return {
anchor: selection.anchor,
head: selection.head,
};
}
function emitEditorSelection(selection: { anchor: number; head: number }) {
emit("selectionStateChange", selection);
}
function flushEditorSelection() {
if (view.value) latestSelection = readEditorSelection(view.value);
if (latestSelection) emitEditorSelection(latestSelection);
}
function restoreEditorSelection(selection = props.initialSelection ?? latestSelection, scrollIntoView = false) {
const normalizedSelection = normalizedEditorSelection(selection, props.modelValue.length);
if (!view.value || !normalizedSelection) return;
view.value.dispatch({ selection: normalizedSelection, scrollIntoView });
}
function restoreEditorFocus() {
const focusEditorAcrossFrames = () => {
focusEditorView(view.value);
};
focusEditorAcrossFrames();
nextTick(() => {
focusEditorAcrossFrames();
requestAnimationFrame(focusEditorAcrossFrames);
});
}
function emitEditorViewport(viewport: { scrollTop: number; scrollLeft: number }) {
if (sameEditorViewport(lastEmittedViewport, viewport)) return;
lastEmittedViewport = { ...viewport };
emit("viewportChange", viewport, viewportOwnerTabId);
}
function scheduleEditorViewportEmit() {
if (!view.value || !editorIsActive) return;
latestViewport = readEditorViewport(view.value);
scheduleSemanticDiagnostics(700, { preserveOutsideRanges: true });
viewportEmitTask.schedule();
}
function flushEditorViewport() {
if (view.value) latestViewport = readEditorViewport(view.value);
viewportEmitTask.flush();
if (latestViewport) emitEditorViewport(latestViewport);
}
function restoreEditorViewport(viewport = props.initialViewport ?? latestViewport) {
if (!view.value || !viewport) return;
const restoreScroll = () => {
if (!view.value) return;
view.value.scrollDOM.scrollTo({
top: viewport.scrollTop,
left: viewport.scrollLeft,
});
view.value.scrollDOM.scrollTop = viewport.scrollTop;
view.value.scrollDOM.scrollLeft = viewport.scrollLeft;
};
if (viewportRestoreFrame !== null) cancelAnimationFrame(viewportRestoreFrame);
restoreScroll();
nextTick(() => {
restoreScroll();
let attempts = 0;
const restoreNextFrame = () => {
restoreScroll();
attempts += 1;
if (attempts >= 32) {
viewportRestoreFrame = null;
return;
}
viewportRestoreFrame = requestAnimationFrame(restoreNextFrame);
};
viewportRestoreFrame = requestAnimationFrame(restoreNextFrame);
});
}
function openSearch(): boolean {
return searchPanelRef.value?.openSearch() ?? false;
}
function openReplace(): boolean {
if (props.readOnly) return false;
return searchPanelRef.value?.openReplace() ?? false;
}
function scrollCursorIntoView() {
const preserveViewport = executionViewportOwnership.consumeCompletionPreservation();
const currentView = view.value;
if (!currentView || !editorViewModule || !editorIsActive || preserveViewport) return;
const pos = currentView.state.selection.main.head;
if (isQueryEditorPositionVisible(pos, currentView.visibleRanges, currentView.viewport)) return;
// Use "center" rather than "nearest": by the time this runs, the results pane has already
// opened/resized and shrunk the editor viewport, so the cursor's old position is often no
// longer visible. "nearest" then pins it right at the new viewport's edge (Fixes #5281: in a
// long multi-statement file, the just-executed statement lands flush against the results pane
// divider), which is exactly where it's hardest to see and re-click. Centering keeps it
// comfortably visible so the user doesn't have to scroll to find/re-run it.
currentView.dispatch({
effects: editorViewModule.EditorView.scrollIntoView(pos, { y: "center" }),
});
}
function beginExecutionViewportTracking() {
executionViewportOwnership.beginExecution();
}
function recordExecutionViewportInteraction() {
executionViewportOwnership.recordUserInteraction();
}
function dismissHoverTooltip() {
if (!view.value || !hoverCloseEffect) return;
view.value.dispatch({ effects: hoverCloseEffect });
}
function acceptGutterExecutionViewport(requestId: number) {
return executionViewportOwnership.acceptRequest(requestId);
}
function cancelGutterExecutionViewport(requestId: number) {
return executionViewportOwnership.cancelPendingRequest(requestId);
}
function shouldBlockExecutionShortcut(event?: KeyboardEvent, currentView: EditorViewType | null = view.value): boolean {
return (currentView ? isEditorComposing(currentView) : false) || (event ? postCompositionKeyGuard.blocks(event) : false);
}
defineExpose({
openSearch,
openReplace,
scrollCursorIntoView,
beginExecutionViewportTracking,
acceptGutterExecutionViewport,
cancelGutterExecutionViewport,
shouldBlockExecutionShortcut,
requestExecute,
requestExecuteInNewResultTab,
requestPreviewChanges,
captureExecutionSnapshot,
pasteClipboardAsSqlInCondition,
focusStatementRange,
focusErrorPosition,
previewStatementRange,
refreshCompletionCache,
});
</script>
<template>
<div class="h-full w-full overflow-hidden relative" @wheel="recordExecutionViewportInteraction" @pointerdown="recordExecutionViewportInteraction" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
<CustomContextMenu :items="currentContextMenuItems" @close="contextMenuOpen = false" v-slot="{ onContextMenu }">
<div
ref="editorRef"
data-query-editor-root
class="h-full w-full overflow-hidden"
@contextmenu="
(e: MouseEvent) => {
if (view) {
syncContextMenuStateAtEvent(view, e);
dismissHoverTooltip();
}
onContextMenu(e);
contextMenuOpen = true;
}
"
/>
</CustomContextMenu>
<div v-show="queryEditorDropCaret" data-query-editor-drop-caret class="pointer-events-none absolute z-20 w-0.5 rounded-full bg-primary/70" :style="queryEditorDropCaretStyle" />
<EditorSearchPanel ref="searchPanelRef" :view="view" />
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
<CodeSnapshotDialog v-model:open="codeSnapshotOpen" :source="codeSnapshotSource" />
<!-- SQL 意图操作弹出菜单(参考 DataGrip Alt+Enter) -->
<Teleport to="body">
<div v-if="intentionPopup?.visible" class="intention-popup-overlay" @click.self="closeIntentionPopup">
<div class="intention-popup" :style="{ left: intentionPopup.position.x + 'px', top: intentionPopup.position.y + 'px' }">
<div v-for="(action, i) in intentionPopup.actions" :key="i" class="intention-popup-item" :class="{ 'intention-popup-item--active': i === intentionPopup.selectedIndex }" @click="executeIntentionAction(action)" @mouseenter="intentionPopup.selectedIndex = i">
<span class="intention-popup-item__icon">💡</span>
<span class="intention-popup-item__label">{{ action.label || getIntentionActionLabel(action.kind) }}</span>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
[data-query-editor-root] > :deep(.cm-editor) {
/* The host supplies both dimensions. Keep viewport DOM changes from
invalidating intrinsic sizes throughout the surrounding flex layout;
WebKit otherwise spends a frame recomputing it on each viewport change.
Leave paint uncontained so editor overlays retain their overflow. */
contain: size layout style;
}
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar) {
width: 5px;
height: 5px;
}
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar-track) {
background: rgba(127, 127, 127, 0.1);
}
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar-thumb) {
background: rgba(127, 127, 127, 0.7);
border-radius: 999px;
}
@supports not selector(::-webkit-scrollbar) {
[data-query-editor-root] :deep(.cm-scroller) {
scrollbar-width: thin;
}
}
.query-editor--table-navigation-hover :deep(.cm-content),
.query-editor--table-navigation-hover :deep(.cm-line) {
cursor: pointer;
}
:deep(.cm-db-execution-preview) {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35));
}
:deep(.cm-db-result-source-highlight) {
background: var(--dbx-editor-selection-background, rgba(126, 34, 206, 0.2));
}
:deep(.cm-lineNumbers .cm-db-result-source-line-number) {
color: rgb(126 34 206) !important;
font-weight: 700;
}
:global(.dark) :deep(.cm-lineNumbers .cm-db-result-source-line-number) {
color: rgb(216 180 254) !important;
}
:deep(.cm-db-currentStatementFrameLayer) {
pointer-events: none;
}
:deep(.cm-db-currentStatementFrame) {
box-sizing: border-box;
border: 1px solid rgb(34 197 94 / 0.75);
border-radius: 2px;
pointer-events: none;
}
:deep(.cm-run-statement-gutter) {
min-width: 28px;
}
:deep(.cm-run-statement-gutter .cm-gutterElement) {
align-items: center;
box-sizing: border-box;
display: flex;
justify-content: center;
min-width: 28px;
padding: 0 2px;
}
:deep(.cm-statement-execution-marker) {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
height: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
margin: 0;
padding: 0;
border: 1px solid transparent;
border-radius: var(--dbx-radius-fixed-6);
vertical-align: middle;
white-space: nowrap;
transition:
color 0.15s,
background-color 0.15s;
user-select: none;
flex-shrink: 0;
}
:deep(.cm-statement-execution-marker--success) {
background: rgb(16 185 129 / 0.1);
color: rgb(4 120 87);
}
:deep(.cm-statement-execution-marker--running) {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
:deep(.cm-statement-execution-marker--error) {
background: rgb(239 68 68 / 0.1);
color: rgb(185 28 28);
}
:deep(.dark .cm-statement-execution-marker--success) {
color: rgb(110 231 183);
}
:deep(.dark .cm-statement-execution-marker--error) {
color: rgb(252 165 165);
}
:deep(.cm-statement-execution-marker svg) {
display: block;
width: min(14px, 70%);
height: min(14px, 70%);
pointer-events: none;
flex-shrink: 0;
}
:deep(.cm-run-statement-marker) {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
height: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
margin: 0;
padding: 0;
border: 1px solid transparent;
border-radius: var(--dbx-radius-fixed-6);
background: transparent;
color: transparent;
vertical-align: middle;
white-space: nowrap;
transition:
color 0.15s,
background-color 0.15s;
outline: none;
user-select: none;
flex-shrink: 0;
}
:deep(.cm-run-statement-marker--active) {
background: rgb(16 185 129 / 0.1);
color: rgb(4 120 87);
cursor: pointer;
}
:deep(.cm-run-statement-marker--active:hover) {
background: rgb(16 185 129 / 0.2);
color: rgb(6 95 70);
}
:deep(.dark .cm-run-statement-marker--active) {
color: rgb(110 231 183);
}
:deep(.dark .cm-run-statement-marker--active:hover) {
color: rgb(167 243 208);
}
:deep(.cm-run-statement-marker > svg) {
display: block;
width: min(14px, 70%);
height: min(14px, 70%);
pointer-events: none;
flex-shrink: 0;
}
:deep(.cm-statement-execution-badge) {
position: absolute;
right: -1px;
bottom: -1px;
display: inline-flex;
align-items: center;
justify-content: center;
width: min(9px, 45%);
height: min(9px, 45%);
border-radius: 9999px;
color: white;
box-shadow: 0 0 0 1px rgb(255 255 255 / 0.9);
pointer-events: none;
}
:deep(.cm-statement-execution-badge--success) {
background: rgb(5 150 105);
}
:deep(.cm-statement-execution-badge--running) {
background: var(--primary);
}
:deep(.cm-statement-execution-badge--error) {
background: rgb(220 38 38);
}
:deep(.cm-statement-execution-badge svg) {
display: block;
width: 75%;
height: 75%;
}
:deep(.cm-statement-execution-spinner) {
animation: dbx-statement-execution-spin 0.8s linear infinite;
}
@keyframes dbx-statement-execution-spin {
to {
transform: rotate(360deg);
}
}
:deep(.cm-foldMarker-svg) {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
width: 16px;
height: 16px;
color: var(--muted-foreground);
opacity: 0.65;
transition: opacity 0.15s;
}
:deep(.cm-foldMarker-svg:hover) {
opacity: 0.95;
}
:deep(.cm-foldMarker-svg svg) {
display: block;
width: 16px;
height: 16px;
}
</style>
<style>
[data-sql-structure-hover-content="true"] {
scrollbar-color: color-mix(in oklab, var(--foreground) 42%, transparent) color-mix(in oklab, var(--muted) 65%, transparent);
}
[data-sql-structure-hover-content="true"]::-webkit-scrollbar {
width: 8px;
height: 0;
}
[data-sql-structure-hover-content="true"]::-webkit-scrollbar:horizontal {
display: none;
height: 0;
}
[data-sql-structure-hover-content="true"]::-webkit-scrollbar-track {
border-radius: 999px;
background: color-mix(in oklab, var(--muted) 65%, transparent);
}
[data-sql-structure-hover-content="true"]::-webkit-scrollbar-thumb {
min-width: 32px;
min-height: 32px;
border: 1px solid transparent;
border-radius: 999px;
background: color-mix(in oklab, var(--foreground) 42%, transparent);
background-clip: padding-box;
}
[data-sql-structure-hover-content="true"]::-webkit-scrollbar-corner {
background: transparent;
}
[data-sql-structure-hover-scrollbar="true"] {
position: relative;
width: 100%;
height: 10px;
margin-top: 6px;
border-radius: 999px;
background: color-mix(in oklab, var(--muted) 72%, var(--border));
cursor: pointer;
touch-action: none;
user-select: none;
flex: 0 0 10px;
}
[data-sql-structure-hover-scrollbar-thumb="true"] {
position: absolute;
top: 1px;
left: 0;
height: 8px;
border: 1px solid transparent;
border-radius: 999px;
background: color-mix(in oklab, var(--foreground) 52%, transparent);
background-clip: padding-box;
}
[data-sql-structure-hover-scrollbar="true"]:hover [data-sql-structure-hover-scrollbar-thumb="true"] {
background: color-mix(in oklab, var(--foreground) 70%, transparent);
}
.intention-popup-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: transparent;
}
.intention-popup {
position: fixed;
z-index: 10000;
min-width: 220px;
max-width: 360px;
background: var(--popover);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 4px 0;
overflow: hidden;
}
.intention-popup-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
color: var(--foreground);
font-size: 13px;
line-height: 1.5;
transition: background 0.1s;
}
.intention-popup-item:hover,
.intention-popup-item--active {
background: var(--accent);
color: var(--accent-foreground);
}
.intention-popup-item__icon {
font-size: 14px;
flex-shrink: 0;
}
.intention-popup-item__label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.cm-batch-column-selection-checkbox {
width: 14px;
height: 14px;
margin: 0 2px 0 0;
accent-color: var(--primary);
cursor: pointer;
flex: 0 0 auto;
}
</style>