8363 lines
369 KiB
Vue
8363 lines
369 KiB
Vue
<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>
|