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

2972 lines
119 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
// Replay guard for the toolbar format/compress requests. The ids are global
// monotonic counters shared by every tab, and ContentArea delivers them by
// gating the inactive tab's prop back to `undefined`; returning to the tab
// re-arms the same stale id on the reused editor instance (a replay, not a new
// command). The cursors live at module scope — not in `<script setup>` — so
// they also survive an editor unmount/remount (data-page switches), letting a
// freshly mounted editor consume the stale id instead of replaying it. The
// check is strictly monotonic (`>`), not `!==`: because ids are shared across
// tabs, a lower id is always an already-handled request from an active tab
// (delivery is same-tick), never a pending undelivered one.
let lastHandledFormatRequestId = 0;
let lastHandledCompressRequestId = 0;
</script>
<script setup lang="ts">
import { useQueryEditorCompletionKeys } from "./useQueryEditorCompletionKeys";
import { useQueryEditorPointer } from "./useQueryEditorPointer";
import { useQueryEditorTableDrop } from "./useQueryEditorTableDrop";
import { createQueryEditorHoverContent } from "./queryEditorHoverContent";
import { useQueryEditorHover } from "./useQueryEditorHover";
import { useQueryEditorObjectNavigation } from "./useQueryEditorObjectNavigation";
import { useQueryEditorCodeMirror } from "./useQueryEditorCodeMirror";
import { configureQueryEditorSqlExtensions } from "./queryEditorSqlExtensions";
import { useQueryEditorStatementBoundaries } from "./useQueryEditorStatementBoundaries";
import { createQueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime";
import { useQueryEditorCompletion } from "./useQueryEditorCompletion";
import { useQueryEditorCompletionMetadata } from "./useQueryEditorCompletionMetadata";
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Transaction } from "@codemirror/state";
import type { Text } from "@codemirror/state";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { search as cmSearch } from "@codemirror/search";
import { useQueryEditorBatchSelection } from "./useQueryEditorBatchSelection";
import { useQueryEditorDiagnostics } from "./useQueryEditorDiagnostics";
import { useQueryEditorDocumentState } from "./useQueryEditorDocumentState";
import type { QueryEditorProps } from "./queryEditorTypes";
import { useQueryEditorLayout } from "./useQueryEditorLayout";
import { useQueryEditorExecution } from "./useQueryEditorExecution";
import { useQueryEditorTextActions } from "./useQueryEditorTextActions";
import { useQueryEditorIntentions } from "./useQueryEditorIntentions";
import QueryEditorIntentionPopup from "./QueryEditorIntentionPopup.vue";
import EditorSearchPanel from "./EditorSearchPanel.vue";
import EditorGotoLinePanel from "./EditorGotoLinePanel.vue";
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
import DelimitedListDialog from "./DelimitedListDialog.vue";
import TableStructurePeekDialog from "./TableStructurePeekDialog.vue";
import type { TableStructurePeekInsertKind } from "./TableStructurePeekDialog.vue";
import type { ColumnInfo } from "@/types/database";
import { createColumnReferencePayload, tableReferenceInsertText } from "@/lib/editor/queryEditorTableDrop";
import { clearRememberedFocusedQueryEditorView, focusedQueryEditorView, queryEditorInsertContext, registerQueryEditorInsertContext, rememberFocusedQueryEditorView, unregisterQueryEditorInsertContext } from "@/lib/editor/focusedQueryEditorView";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { structurePeekPanelId } from "@/lib/editor/structurePeekPanel";
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
import QueryEditorContextMenu, { type QueryEditorContextMenuState, type QueryEditorContextMenuActions } from "./QueryEditorContextMenu.vue";
import { readTextFromClipboard } from "@/lib/common/clipboard";
import { resolveExecutableSql, type SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import { supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
import { canFormatSqlForDatabaseType, formatSqlForEditing, compressSqlText } from "@/lib/sql/sqlFormatter";
import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisible } from "@/lib/editor/queryEditorExecutionViewport";
import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines";
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
import { detectAndFormatElasticsearchRequests } from "@/lib/elasticsearch/elasticsearchFormatter";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { useToast } from "@/composables/useToast";
import { buildSelectStarExpansion, selectStarResultColumnsMatch } from "@/lib/sql/sqlCompletion";
import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSources } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { sqlObjectNavigationSourceKind, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, sqlCompletionTheme } from "@/lib/editor/editorThemes";
import { shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
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 { createShowWhitespaceExtension } from "@/lib/editor/codemirrorShowWhitespace";
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 { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings";
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync";
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 { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut";
import { supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect";
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
const props = defineProps<QueryEditorProps>();
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 === "solr" || 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: [];
editorRevealConsumed: [];
sendSelectionToAi: [sql: string];
}>();
const editorRef = ref<HTMLDivElement>();
useQueryEditorLayout(editorRef);
const view = shallowRef<EditorViewType | null>(null);
const contextMenuOpen = ref(false);
let contextMenuPointerCleanup: (() => void) | null = null;
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
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 sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined));
const MAX_COMPLETION_TABLES = 200;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20;
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
const isGestureZooming = ref(false);
const { pickerVisible, pickerCandidates, pickerActiveIndex, pickerAnchor, captureExecutionSnapshot, requestExecute, requestExecuteInNewResultTab, onPickerActiveIndexChange, onPickerConfirm, closePicker, sqlExecutionSnapshotFromView, sqlExecutionSnapshotForRange } = useQueryEditorExecution({
props,
view,
editorRef,
settingsStore,
executionViewportOwnership,
sqlStatementParameterOptions,
setPreviewRange,
emitExecutionRequest,
onExecute: (source) => emit("execute", source),
});
const searchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const gotoLinePanelRef = ref<InstanceType<typeof EditorGotoLinePanel>>();
const { intentionPopup, closeIntentionPopup, executeIntentionAction, handleSqlIntentionActions } = useQueryEditorIntentions({ props, view, sqlBehaviorDialect, focusEditor });
const selectedSql = ref("");
const {
delimitedListOpen,
delimitedListSelectedText,
codeSnapshotOpen,
codeSnapshotSource,
openCodeSnapshot,
openDelimitedListDialog,
applyDelimitedListResult,
copySelectedSqlFromContextMenu,
copySelectedSqlAsRichTextFromContextMenu,
cutSelectedSqlFromContextMenu,
pasteClipboardSqlFromContextMenu,
toggleCommentFromContextMenu,
toggleBlockCommentFromContextMenu,
selectAllSqlFromContextMenu,
convertSelectedSqlCase,
convertSelectedNamingStyle,
pasteClipboardAsSqlInCondition,
deleteEmptyLines,
} = useQueryEditorTextActions({
props,
view,
selectedSql,
settingsStore,
sqlBehaviorDialect,
focusEditor,
getEditorSelection: () => codeMirrorRuntime.codeMirrorEditorSelection,
toggleLineComment: (currentView) => {
codeMirrorRuntime.codeMirrorToggleLineComment?.(currentView);
},
toggleBlockComment: (currentView) => {
codeMirrorRuntime.codeMirrorToggleBlockComment?.(currentView);
},
});
const executableSql = ref("");
const previewContextSql = ref("");
interface StructurePeekPanelState {
id: string;
target: SqlObjectNavigationTarget;
columns: ColumnInfo[];
loading: boolean;
error: string;
zIndex: number;
cascadeIndex: number;
requestId: number;
}
const structurePeekPanels = ref<StructurePeekPanelState[]>([]);
let structurePeekRequestSeq = 0;
let structurePeekZIndexSeq = 50;
let structurePeekCascadeSeq = 0;
function bringStructurePeekToFront(panelId: string) {
const panel = structurePeekPanels.value.find((item) => item.id === panelId);
if (!panel) return;
structurePeekZIndexSeq += 1;
panel.zIndex = structurePeekZIndexSeq;
}
function closeStructurePeekPanel(panelId: string) {
structurePeekPanels.value = structurePeekPanels.value.filter((item) => item.id !== panelId);
}
async function loadStructurePeekColumns(panelId: string, requestId: number, target: SqlObjectNavigationTarget, database: string, schema: string) {
if (!props.connectionId) return;
try {
const objectMetadataRequest = {
connectionId: props.connectionId,
database,
schema,
tableName: target.name,
catalog: props.catalog,
objectType: sqlObjectNavigationSourceKind(target),
};
const { value } = await loadObjectMetadataFacet(objectMetadataRequest, "columns", () => api.getColumns(props.connectionId!, database, schema, target.name, props.catalog, props.clientSessionId));
const current = structurePeekPanels.value.find((item) => item.id === panelId && item.requestId === requestId);
if (!current) return;
current.columns = value;
current.error = "";
current.loading = false;
} catch (error: unknown) {
const current = structurePeekPanels.value.find((item) => item.id === panelId && item.requestId === requestId);
if (!current) return;
current.error = error instanceof Error ? error.message : String(error);
current.columns = [];
current.loading = false;
}
}
async function openTableStructurePeek(target: SqlObjectNavigationTarget) {
if (!props.connectionId) return;
const database = target.database || props.database || "";
const schema = target.schema ?? props.schema ?? "";
const id = structurePeekPanelId(props.connectionId, database, schema, target.name, props.catalog);
const existing = structurePeekPanels.value.find((item) => item.id === id);
if (existing) {
bringStructurePeekToFront(existing.id);
// Retry when a previous open left the panel in an error state.
if (existing.error && !existing.loading) {
const requestId = ++structurePeekRequestSeq;
existing.requestId = requestId;
existing.loading = true;
existing.error = "";
existing.columns = [];
await loadStructurePeekColumns(existing.id, requestId, existing.target, database, schema);
}
return;
}
structurePeekZIndexSeq += 1;
const requestId = ++structurePeekRequestSeq;
const cascadeIndex = structurePeekCascadeSeq++;
const panel: StructurePeekPanelState = {
id,
target: { ...target, database, schema: schema || target.schema },
columns: [],
loading: true,
error: "",
zIndex: structurePeekZIndexSeq,
cascadeIndex,
requestId,
};
structurePeekPanels.value = [...structurePeekPanels.value, panel];
await loadStructurePeekColumns(id, requestId, panel.target, database, schema);
}
function syncQueryEditorInsertContext(currentView: EditorViewType | null = view.value) {
if (!currentView) return;
registerQueryEditorInsertContext(currentView, {
connectionId: props.connectionId,
database: props.database,
schema: props.schema,
databaseType: props.databaseType,
});
}
function insertStructurePeekValue(panel: StructurePeekPanelState, value: string, kind: TableStructurePeekInsertKind) {
if (!value) return;
// Insert into the focused query editor, or the last one if focus is inside peek chrome.
// Focus elsewhere (sidebar, etc.) → no-op.
const targetView = focusedQueryEditorView();
if (!targetView || targetView.state.readOnly) return;
const targetCtx = queryEditorInsertContext(targetView);
const connectionId = targetCtx?.connectionId ?? props.connectionId;
const databaseType = targetCtx?.databaseType ?? props.databaseType;
const database = panel.target.database || targetCtx?.database || props.database || "";
let insertText = value;
if (kind === "identifier" && connectionId && database != null) {
const payload = createColumnReferencePayload({
connectionId,
database,
schema: panel.target.schema ?? targetCtx?.schema ?? props.schema,
columnNames: [value],
databaseType,
columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
});
if (payload) {
insertText = tableReferenceInsertText(payload, databaseType, {
columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
});
}
}
// Peek inserts at the caret (or replaces a non-empty selection). Do not use
// replaceSelectedEditorText — that helper requires a selection and no-ops on caret.
const selection = targetView.state.selection.main;
const from = selection.from;
const to = selection.empty ? from : selection.to;
targetView.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: from + insertText.length },
scrollIntoView: true,
userEvent: "input.type",
});
focusEditorView(targetView);
rememberFocusedQueryEditorView(targetView);
}
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 canExecuteContextSql = computed(() => executableSql.value.trim().length > 0);
interface EditorGestureEvent extends Event {
scale?: number;
}
const codeMirrorRuntime = createQueryEditorCodeMirrorRuntime();
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let editorIsActive = true;
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) ? (codeMirrorRuntime.buildRunStatementGutterExtension?.() ?? []) : [];
}
let executableStatementRangeCache: ExecutableStatementRangeCache | null = null;
const statementBoundaries = useQueryEditorStatementBoundaries({
props,
view,
sqlStatementParameterOptions,
runtime: codeMirrorRuntime,
cache: {
get value() {
return executableStatementRangeCache;
},
set value(value) {
executableStatementRangeCache = value;
},
},
});
const DBX_VIM_SAVE_EVENT = "dbx-vim-save";
function editorThemeAppearance() {
return editorThemeAppearanceFor(isDark.value ? "dark" : "light", themePalette.value, themePalette.value === "custom" ? activeCustomUiColors.value : undefined);
}
const completionMetadata = useQueryEditorCompletionMetadata({
props,
view,
connectionStore,
sqlBehaviorDialect,
remoteLatencyBudgetMs: COMPLETION_REMOTE_LATENCY_BUDGET_MS,
maxCompletionTables: MAX_COMPLETION_TABLES,
onDemandMinPrefix: PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
});
const {
sqlCompletionDialectOptions,
getEditorSqlCompletionContext,
ensureColumnsForTable,
cachedColumnsByTable,
completionCacheKey,
usesOracleSessionCompletionColumns,
cachedCompletionObjectsByScope,
loadedColumnsByTable,
findExactSemanticDiagnosticTable,
isMissingTableMetadataError,
getInsertValueHintTableColumns,
requestInsertValueHintTableColumns,
refreshCompletionCache,
} = completionMetadata;
const hoverContent = createQueryEditorHoverContent({ isDark, t, toast });
const { resolveSqlHoverTooltip } = useQueryEditorHover({ props, contextMenuOpen, settingsStore, connectionStore, metadata: completionMetadata, createHoverDom: hoverContent.createHoverDom, semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, maxCompletionTables: MAX_COMPLETION_TABLES });
const pointerInteractions = useQueryEditorPointer({ props, clearTableNavigationHover: () => clearTableNavigationHover(), emit });
const { registerEditorScrollbarPointerGuard, startEditorSelectionDrag } = pointerInteractions;
const tableDrop = useQueryEditorTableDrop({ props, view, editorRef, settingsStore });
const { hasDroppedTableReference, insertDroppedTableReference, queryEditorDropCaret, queryEditorDropCaretStyle, showQueryEditorDropCaretAt, hideQueryEditorDropCaret, registerTableReferenceDropListener, unregisterTableReferenceDropListener } = tableDrop;
const objectNavigation = useQueryEditorObjectNavigation({
props,
view,
editorRef,
settingsStore,
connectionStore,
metadata: completionMetadata,
runtime: codeMirrorRuntime,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
dismissHoverTooltip,
startEditorSelectionDrag,
emit,
});
const { clearTableNavigationHover, updateTableNavigationHover, onEditorMouseDown } = objectNavigation;
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,
showWhitespace: settings.showWhitespace,
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 (!codeMirrorRuntime.fontThemeComp || !codeMirrorRuntime.editorViewModule || !view.value) return;
view.value.dispatch({
effects: codeMirrorRuntime.fontThemeComp.reconfigure(
editorFontTheme(codeMirrorRuntime.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);
}
function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab = false) {
if (typeof source === "string" || source.editorViewportRequestId === undefined) {
executionViewportOwnership.cancelPendingRequest();
}
if (openInNewResultTab) {
emit("executeInNewResultTab", source);
} else {
emit("execute", source);
}
}
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 = codeMirrorRuntime.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 setPreviewRange(range: { from: number; to: number } | null) {
if (!view.value || !codeMirrorRuntime.setPreviewRangeEffect) return;
view.value.dispatch({
effects: codeMirrorRuntime.setPreviewRangeEffect.of(range),
});
}
function setResultSourceRange(range: { from: number; to: number } | null) {
if (!view.value || !codeMirrorRuntime.setResultSourceRangeEffect) return;
view.value.dispatch({
effects: codeMirrorRuntime.setResultSourceRangeEffect.of(range),
});
}
function previewStatementRange(range: { from: number; to: number } | null) {
const currentView = view.value;
if (!range || !currentView || !codeMirrorRuntime.editorViewModule || !codeMirrorRuntime.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: [codeMirrorRuntime.setResultSourceRangeEffect.of({ from, to }), codeMirrorRuntime.editorViewModule.EditorView.scrollIntoView(from, { y: "center" })],
});
}
function focusStatementRange(range: { from: number; to: number } | null) {
const currentView = view.value;
if (!range || !currentView || !codeMirrorRuntime.editorViewModule || !codeMirrorRuntime.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: [codeMirrorRuntime.setResultSourceRangeEffect.of({ from, to }), codeMirrorRuntime.editorViewModule.EditorView.scrollIntoView(from, { y: "center" })],
});
currentView.focus();
}
function focusErrorPosition(offset: number) {
const currentView = view.value;
if (!currentView || !codeMirrorRuntime.editorViewModule) return;
const errorPos = Math.max(0, Math.min(offset, currentView.state.doc.length));
currentView.dispatch({
selection: { anchor: errorPos },
effects: [codeMirrorRuntime.editorViewModule.EditorView.scrollIntoView(errorPos, { y: "center" })],
});
currentView.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();
rememberFocusedQueryEditorView(view.value);
}
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;
}
}
// See queryEditorPasteCaretResync.ts for why this nudge is needed (WebKit-only caret bug).
function resyncCaretAfterPaste(view: EditorViewType) {
const EditorSelection = codeMirrorRuntime.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 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 "peekTableStructure":
void openTableStructurePeek(route.payload[0]);
focusEditor();
return;
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 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. An explicit
// selection overlapping that statement is more specific, so preserve it; a
// selection elsewhere in the document must not hijack the click.
const editorViewportRequestId = executionViewportOwnership.beginRequest();
const selection = currentView.state.selection.main;
const hasSelectedSql = !selection.empty && currentView.state.sliceDoc(selection.from, selection.to).trim().length > 0;
const selectionOverlapsStatement = hasSelectedSql && selection.from < statementRange.to && statementRange.from < selection.to;
const executionSnapshot = selectionOverlapsStatement ? sqlExecutionSnapshotFromView(currentView) : sqlExecutionSnapshotForRange(currentView, statementRange);
emitExecutionRequest({ ...executionSnapshot, 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 contextMenuActions: QueryEditorContextMenuActions = {
executeFromContextMenu,
executeInNewResultTabFromContextMenu,
requestPreviewChanges,
exportQueryFromContextMenu,
toggleCommentFromContextMenu,
toggleBlockCommentFromContextMenu,
formatCurrentSql,
compressCurrentSql,
copySelectedSqlFromContextMenu,
copySelectedSqlAsRichTextFromContextMenu,
cutSelectedSqlFromContextMenu,
pasteClipboardSqlFromContextMenu,
convertSelectedSqlCase,
convertSelectedNamingStyle,
openDelimitedListDialog,
addNextSelectionOccurrenceFromContextMenu,
selectAllSelectionOccurrencesFromContextMenu,
openFindReplaceFromContextMenu,
deleteEmptyLines,
selectAllSqlFromContextMenu,
emitContextObjectAction,
openCodeSnapshot,
sendSelectionToAi: () => {
if (selectedSql.value.trim()) emit("sendSelectionToAi", selectedSql.value);
},
};
function getContextMenuState(): QueryEditorContextMenuState {
const target = selectStarExpansionTarget.value;
return {
readOnly: props.readOnly,
hideExecutionControls: props.hideExecutionControls,
databaseType: props.databaseType,
selectedSql: selectedSql.value,
executableSql: executableSql.value,
previewContextSql: previewContextSql.value,
contextObjectTarget: contextObjectTarget.value,
shortcuts: settingsStore.editorSettings.shortcuts,
expandSelectStar: target ? () => void expandSelectStar(target) : undefined,
};
}
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 = codeMirrorRuntime.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") => codeMirrorRuntime.codeMirrorMoveCompletionSelection?.(forward, by)?.(view) ?? false;
return [
codeMirrorRuntime.editorViewModule
? (Prec?.high(
codeMirrorRuntime.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),
...binding(shortcuts.gotoLine, openGotoLine),
...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) => codeMirrorRuntime.codeMirrorIndentMore?.(view) ?? false),
...binding(shortcuts.indentLess, (view) => codeMirrorRuntime.codeMirrorIndentLess?.(view) ?? false),
...binding(shortcuts.insertLineBelow, insertLineBelow),
...binding(shortcuts.joinLines, joinQueryEditorLines),
...binding(shortcuts.duplicateLine, (view) => codeMirrorRuntime.codeMirrorCopyLineDown?.(view) ?? false),
...binding(shortcuts.deleteLine, (view) => codeMirrorRuntime.codeMirrorDeleteLine?.(view) ?? false),
...binding(shortcuts.moveLineUp, (view) => codeMirrorRuntime.codeMirrorMoveLineUp?.(view) ?? false),
...binding(shortcuts.moveLineDown, (view) => codeMirrorRuntime.codeMirrorMoveLineDown?.(view) ?? false),
...binding(shortcuts.copyLineUp, (view) => codeMirrorRuntime.codeMirrorCopyLineUp?.(view) ?? false),
...binding(shortcuts.copyLineDown, (view) => codeMirrorRuntime.codeMirrorCopyLineDown?.(view) ?? false),
...binding(shortcuts.undo, (view) => codeMirrorRuntime.codeMirrorUndo?.(view) ?? false),
...binding(shortcuts.redo, (view) => codeMirrorRuntime.codeMirrorRedo?.(view) ?? false),
...binding(shortcuts.selectAll, (view) => codeMirrorRuntime.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) => codeMirrorRuntime.codeMirrorToggleLineComment?.(view) ?? false),
...binding(shortcuts.toggleBlockComment, (view) => {
if (!supportsQueryEditorBlockComments(props.databaseType)) return false;
return codeMirrorRuntime.codeMirrorToggleBlockComment?.(view) ?? false;
}),
...binding(shortcuts.toggleFold, (view) => codeMirrorRuntime.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 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 wordWrapExtension() {
if (!codeMirrorRuntime.editorViewModule) return [];
return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? codeMirrorRuntime.editorViewModule.EditorView.lineWrapping : [];
}
function showWhitespaceExtension(enabled = settingsStore.editorSettings.showWhitespace) {
if (!codeMirrorRuntime.editorViewModule) return [];
return createShowWhitespaceExtension(codeMirrorRuntime.editorViewModule, enabled);
}
function lineNumbersExtension(enabled = settingsStore.editorSettings.showLineNumbers) {
return buildQueryEditorLineNumbersExtension(codeMirrorRuntime.codeMirrorLineNumbers, enabled, {
domEventHandlers: {
mousedown: selectSqlLineFromGutter,
},
});
}
function closeBracketsExtension(enabled = settingsStore.editorSettings.autoCloseBrackets) {
if (!enabled || !codeMirrorRuntime.codeMirrorCloseBrackets) return [];
const exts: import("@codemirror/state").Extension[] = [codeMirrorRuntime.codeMirrorCloseBrackets()];
if (codeMirrorRuntime.codeMirrorCloseBracketsKeymap?.length && codeMirrorRuntime.codeMirrorPrec && codeMirrorRuntime.editorViewModule) {
exts.push(codeMirrorRuntime.codeMirrorPrec.highest(codeMirrorRuntime.editorViewModule.keymap.of([...codeMirrorRuntime.codeMirrorCloseBracketsKeymap])));
}
return exts;
}
function vimModeExtension(enabled = settingsStore.editorSettings.vimModeEnabled) {
if (!codeMirrorRuntime.codeMirrorVim || !enabled) return [];
const vimExtension = codeMirrorRuntime.codeMirrorVim({ status: true });
if (!codeMirrorRuntime.codeMirrorPrec || !codeMirrorRuntime.editorViewModule || !codeMirrorRuntime.codeMirrorGetVimCm || !codeMirrorRuntime.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 codeMirrorRuntime.codeMirrorPrec.highest([
codeMirrorRuntime.editorViewModule.keymap.of([
{
key: "Ctrl-[",
mac: "Ctrl-[",
linux: "Ctrl-[",
win: "Ctrl-[",
run(currentView) {
const cm = codeMirrorRuntime.codeMirrorGetVimCm?.(currentView);
if (cm?.state.vim?.insertMode) {
codeMirrorRuntime.codeMirrorVimApi?.exitInsertMode(cm as any, true);
return true;
}
return false;
},
},
]),
vimExtension,
]);
}
function configureDbxVimCommands(vimApi: typeof import("@replit/codemirror-vim").Vim) {
if (codeMirrorRuntime.dbxVimCommandsConfigured) return;
codeMirrorRuntime.dbxVimCommandsConfigured = true;
vimApi.defineEx("write", "w", (cm) => {
cm.cm6?.contentDOM.dispatchEvent(new CustomEvent(DBX_VIM_SAVE_EVENT, { bubbles: true }));
});
}
async function ensureCodeMirrorVim() {
if (codeMirrorRuntime.codeMirrorVim && codeMirrorRuntime.codeMirrorVimApi && codeMirrorRuntime.codeMirrorGetVimCm) return true;
codeMirrorRuntime.codeMirrorVimImportPromise ??= import("@replit/codemirror-vim");
const { vim, Vim, getCM } = await codeMirrorRuntime.codeMirrorVimImportPromise;
codeMirrorRuntime.codeMirrorVim = vim;
codeMirrorRuntime.codeMirrorVimApi = Vim;
codeMirrorRuntime.codeMirrorGetVimCm = getCM;
configureDbxVimCommands(Vim);
return true;
}
function indentExtension() {
if (!codeMirrorRuntime.codeMirrorIndentUnit) return [];
return codeMirrorRuntime.codeMirrorIndentUnit.of(editorIndentUnit());
}
function selectedSqlFromView(currentView: EditorViewType): string {
const selection = currentView.state.selection.main;
return currentView.state.sliceDoc(selection.from, selection.to);
}
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();
}
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics } = useQueryEditorDiagnostics({
props,
view,
settingsStore,
connectionStore,
sqlDriverProfile,
sqlStatementParameterOptions,
sqlBehaviorDialect,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
runtime: {
get setSqlDiagnosticsEffect() {
return codeMirrorRuntime.setSqlDiagnosticsEffect;
},
get diagnosticComp() {
return codeMirrorRuntime.diagnosticComp;
},
get buildSqlDiagnosticExtension() {
return codeMirrorRuntime.buildSqlDiagnosticExtension;
},
get codeMirrorCompletionStatus() {
return codeMirrorRuntime.codeMirrorCompletionStatus;
},
get executableStatementRangeCache() {
return executableStatementRangeCache;
},
set executableStatementRangeCache(value) {
executableStatementRangeCache = value;
},
get editorIsActive() {
return editorIsActive;
},
},
metadata: {
get cachedTables() {
return completionMetadata.cachedTables;
},
cachedColumnsByTable,
loadedColumnsByTable,
usesOracleSessionCompletionColumns,
findExactSemanticDiagnosticTable,
completionCacheKey,
ensureColumnsForTable,
isMissingTableMetadataError,
},
});
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 },
});
}
const batchSelection = useQueryEditorBatchSelection({
view,
settingsStore,
markCompletionAccepted: (item) => completion.markCompletionAccepted(item),
runtime: {
get completionComp() {
return codeMirrorRuntime.completionComp;
},
get buildSqlCompletionExtension() {
return codeMirrorRuntime.buildSqlCompletionExtension;
},
get codeMirrorCompletionStatus() {
return codeMirrorRuntime.codeMirrorCompletionStatus;
},
get codeMirrorStartCompletion() {
return codeMirrorRuntime.codeMirrorStartCompletion;
},
get codeMirrorCurrentCompletions() {
return codeMirrorRuntime.codeMirrorCurrentCompletions;
},
get codeMirrorSetSelectedCompletion() {
return codeMirrorRuntime.codeMirrorSetSelectedCompletion;
},
get codeMirrorSelectedCompletion() {
return codeMirrorRuntime.codeMirrorSelectedCompletion;
},
get codeMirrorSnippetCompletion() {
return codeMirrorRuntime.codeMirrorSnippetCompletion;
},
},
});
const { cancelBatchColumnSelectionRefresh, finishBatchColumnSelectionDrag, clearBatchColumnSelectionSession, toggleSelectedBatchColumnSelection } = batchSelection;
const completion = useQueryEditorCompletion({
props,
view,
connectionStore,
settingsStore,
sqlDriverProfile,
t,
metadata: completionMetadata,
batchSelection,
isEditorComposing,
debounceDelayMs: COMPLETION_DEBOUNCE_DELAY_MS,
triggerDeferDelayMs: COMPLETION_TRIGGER_DEFER_DELAY_MS,
maxCompletionTables: MAX_COMPLETION_TABLES,
onDemandTableLimit: PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
runtime: {
get codeMirrorCompletionStatus() {
return codeMirrorRuntime.codeMirrorCompletionStatus;
},
get codeMirrorInsertCompletionText() {
return codeMirrorRuntime.codeMirrorInsertCompletionText;
},
get codeMirrorSnippetCompletion() {
return codeMirrorRuntime.codeMirrorSnippetCompletion;
},
get codeMirrorStartCompletion() {
return codeMirrorRuntime.codeMirrorStartCompletion;
},
get imeCompositionActive() {
return imeCompositionActive;
},
},
});
const { triggerSqlCompletion, shouldTriggerSqlCompletionForPosition, scheduleSqlCompletionStart, consumeSqlCompletionAutoStartSuppression, scheduleDeferredCompletionTrigger, clearDeferredCompletionTrigger } = completion;
const { editorIndentUnit, handleTab, handleEnter, acceptCompletionOrNextSnippetField, acceptSqlServerCompletionOnSpace, clearPendingCompletionEnter, clearPendingCompletionTab } = useQueryEditorCompletionKeys({
props,
settingsStore,
runtime: codeMirrorRuntime,
completion,
batchSelection,
isEditorComposing,
retryDelayMs: COMPLETION_TAB_RETRY_DELAY_MS,
tabMaxWaitMs: COMPLETION_TAB_MAX_WAIT_MS,
enterMaxWaitMs: COMPLETION_ENTER_MAX_WAIT_MS,
});
function isEditorComposing(currentView: EditorViewType): boolean {
return imeCompositionActive || currentView.compositionStarted || currentView.composing;
}
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);
documentState.recordSelection(currentView);
const fullDoc = currentView.state.doc.toString();
const position = currentView.state.selection.main.head;
if (shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
scheduleSqlCompletionStart(currentView);
}
}
function defaultKeymapExtension() {
if (!codeMirrorRuntime.editorViewModule || !codeMirrorRuntime.codeMirrorDefaultKeymap || !codeMirrorRuntime.codeMirrorToggleBlockComment) return [];
return codeMirrorRuntime.editorViewModule.keymap.of(defaultKeymapForGlobalShortcuts(codeMirrorRuntime.codeMirrorDefaultKeymap, settingsStore.editorSettings.shortcuts).filter((item) => item.run !== codeMirrorRuntime.codeMirrorToggleBlockComment));
}
const codeMirrorLifecycle = useQueryEditorCodeMirror({
editorRef,
view,
runtime: codeMirrorRuntime,
beforeLoad() {
hoverContent.initializeHighlighter();
},
async prepare(modules) {
const initializedRuntime = modules.runtime;
const {
EditorView,
keymap,
rectangularSelection,
hoverTooltip,
tooltips,
highlightActiveLineGutter,
highlightSpecialChars,
drawSelection,
dropCursor,
crosshairCursor,
scrollPastEnd,
ViewPlugin,
EditorState,
Prec,
completionKeymap,
history,
historyKeymap,
bracketMatching,
foldGutter,
indentOnInput,
syntaxHighlighting,
defaultHighlightStyle,
foldKeymap,
searchKeymap,
} = modules;
const statementBoundariesTrackingPlugin = statementBoundaries.createTrackingPlugin(ViewPlugin);
objectNavigation.attach();
const sqlExtensions = configureQueryEditorSqlExtensions({
props,
runtime: codeMirrorRuntime,
modules,
settingsStore,
t,
sqlBehaviorDialect,
sqlDriverProfile,
sqlStatementParameterOptions,
queryEditorSelectionLanguage,
cache: {
get value() {
return executableStatementRangeCache;
},
set value(value) {
executableStatementRangeCache = value;
},
},
completion,
batchSelection,
diagnostics: { sqlErrorDecorationRange, sqlSemanticDecorationRanges },
statementBoundaries,
currentExecutableStatementRange,
executeSqlStatementFromGutter,
signatureHelpWindowChars: SQL_SIGNATURE_HELP_WINDOW_CHARS,
});
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
if (initialSettings.vimModeEnabled) {
await ensureCodeMirrorVim();
}
const { currentStatementFrameExtension, activeLineHighlighter } = sqlExtensions.createViewDecorations();
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,
initializedRuntime.runGutterComp.of(runStatementGutterExtension()),
initializedRuntime.lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
currentStatementFrameExtension,
highlightActiveLineGutter(),
highlightSpecialChars(),
initializedRuntime.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(),
editorClipboardLineEndingsExtension(EditorView),
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.
initializedRuntime.vimModeComp.of(vimModeExtension(initialSettings.vimModeEnabled)),
initializedRuntime.defaultKeymapComp.of(defaultKeymapExtension()),
keymap.of([...searchKeymapWithoutModD(searchKeymap), ...historyKeymap, ...foldKeymap, ...completionKeymap]),
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
tooltips({ parent: tooltipParent }),
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
initializedRuntime.codeMirrorTheme.of(theme),
initializedRuntime.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)),
initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension()),
initializedRuntime.diagnosticComp.of(sqlExtensions.buildSqlDiagnosticExtension()),
createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
}),
initializedRuntime.previewRangeComp.of(sqlExtensions.buildPreviewRangeExtension()),
sqlExtensions.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) => initializedRuntime.codeMirrorCloseCompletion?.(currentView) ?? false,
}),
},
]),
),
initializedRuntime.runKeymapComp.of(runKeymapExtension(keymap)),
Prec.highest(
EditorView.domEventHandlers({
keydown(event, currentView) {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
return runQueryEditorAltExtendSelection(event, shortcuts.extendSelection, currentView, extendQueryEditorSelectionForView);
},
}),
),
initializedRuntime.wordWrapComp.of(props.forceWordWrap || initialSettings.wordWrap ? EditorView.lineWrapping : []),
initializedRuntime.showWhitespaceComp.of(showWhitespaceExtension(initialSettings.showWhitespace)),
initializedRuntime.readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]),
initializedRuntime.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.focusChanged && update.view.hasFocus) {
rememberFocusedQueryEditorView(update.view);
}
if (update.docChanged) {
searchPanelRef.value?.scheduleDocumentSearchUpdate();
if (isEditorComposing(update.view)) {
pendingImeModelEmit = true;
completion.invalidateRequests();
} 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);
documentState.recordSelection(update.view);
}
// Clear activeCompletionOrigin when the completion session ends.
if (initializedRuntime.codeMirrorCompletionStatus) {
const status = initializedRuntime.codeMirrorCompletionStatus(update.state) ?? null;
if (status === null) {
completion.activeOrigin = null;
clearBatchColumnSelectionSession();
}
}
}),
initializedRuntime.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) {
documentState.recordSelection(currentView);
return false;
},
compositionstart() {
imeCompositionActive = true;
completion.invalidateRequests();
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: onEditorMouseDown,
}),
],
});
return {
state,
parent: editorElement,
onReady() {
if (!view.value) return;
syncQueryEditorInsertContext(view.value);
batchSelection.attach(view.value, tooltipParent);
postCompositionKeyGuardCleanup = postCompositionKeyGuard.attach(view.value.contentDOM);
registerEditorScrollbarPointerGuard(view.value);
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
passive: true,
});
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();
performEditorReveal();
syncContextMenuState(view.value);
emit("previewChangesAvailable", !!previewContextSql.value);
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
syncEditorDiagnosticCssVars();
registerTableReferenceDropListener();
completionMetadata.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);
});
});
}
void nextTick(async () => {
if (!view.value || !initializedRuntime.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: [initializedRuntime.codeMirrorTheme.reconfigure(themeExt)],
});
});
},
};
},
});
onMounted(codeMirrorLifecycle.initialize);
// 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 || !codeMirrorRuntime.codeMirrorCompletionStatus || !codeMirrorRuntime.codeMirrorCloseCompletion) return;
const status = codeMirrorRuntime.codeMirrorCompletionStatus(view.value.state);
if (!status || completion.activeOrigin !== "typing") return;
// If switching to manual, close all typing sessions.
if (newMode === "manual") {
codeMirrorRuntime.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)) {
codeMirrorRuntime.codeMirrorCloseCompletion(view.value);
}
},
);
const documentState = useQueryEditorDocumentState({
isEditorComposing,
props,
view,
previewContextSql,
emit,
scheduleSemanticDiagnostics,
clearScheduledPreviewContextRefresh,
syncContextMenuState,
applyEditorAppearance,
applyEditorShortcutKeymaps,
applyEditorIndentExtension,
applyEditorCompletionExtension,
invalidateSemanticDiagnosticsForDocumentChange,
currentEditorDocText,
scheduleDocumentSearchUpdate: () => searchPanelRef.value?.scheduleDocumentSearchUpdate(),
runtime: {
get historyResetComp() {
return codeMirrorRuntime.historyResetComp;
},
get codeMirrorHistory() {
return codeMirrorRuntime.codeMirrorHistory;
},
get codeMirrorEditorSelection() {
return codeMirrorRuntime.codeMirrorEditorSelection;
},
get editorIsActive() {
return editorIsActive;
},
},
});
const { normalizedEditorSelection, restoreEditorSelection, performEditorReveal, restoreEditorFocus, scheduleEditorViewportEmit, restoreEditorViewport } = documentState;
// The editor component is reused across tabs, so a tab switch can change the
// connection/database without remounting it.
watch([() => props.connectionId, () => props.database, () => props.catalog, () => props.clientSessionId], () => warmActiveTabConnection());
watch(
() => [props.connectionId, props.database, props.schema, props.databaseType] as const,
() => syncQueryEditorInsertContext(),
);
// Restored tabs mount before their connection is established, so the warm-up
// above is skipped and never retried when connecting finishes later.
watch(
() => (props.connectionId ? connectionStore.connectedIds.has(props.connectionId) : false),
(connected) => {
if (connected) warmActiveTabConnection();
},
);
// A content-search jump. Fires on id change (a new or reused tab, or a repeat
// click on the same result), and on mount when the request is already pending.
watch(
() => props.revealRequest?.id,
(id, previousId) => {
if (!id || id === previousId) return;
performEditorReveal();
},
);
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 || !codeMirrorRuntime.setStatementExecutionMarkersEffect) return;
view.value.dispatch({
effects: codeMirrorRuntime.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),
() => {
completion.invalidateRequests();
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
},
);
watch([() => props.clientSessionId, () => props.completionContextVersion], () => {
completion.invalidateRequests();
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics();
});
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile], () => {
executableStatementRangeCache = null;
statementBoundaries.invalidate();
if (!view.value || !codeMirrorRuntime.sqlLanguageComp || !codeMirrorRuntime.buildSqlLanguageExtension || !codeMirrorRuntime.sqlSemanticHighlightComp || !codeMirrorRuntime.buildSqlSemanticHighlightExtension || !codeMirrorRuntime.sqlSignatureComp || !codeMirrorRuntime.buildSqlSignatureExtension)
return;
// Signature tooltips depend on the external dialect, so refresh them even when the document and selection stay unchanged.
view.value.dispatch({
effects: [
codeMirrorRuntime.sqlLanguageComp.reconfigure(codeMirrorRuntime.buildSqlLanguageExtension()),
codeMirrorRuntime.sqlSemanticHighlightComp.reconfigure(codeMirrorRuntime.buildSqlSemanticHighlightExtension()),
codeMirrorRuntime.sqlSignatureComp.reconfigure(codeMirrorRuntime.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;
statementBoundaries.invalidate();
if (props.databaseType !== "opengauss") return;
if (!view.value) return;
refreshCompletionCache();
setSemanticDiagnostics([]);
scheduleSemanticDiagnostics(0);
},
);
watch(
() => props.forceWordWrap,
() => {
if (!view.value || !codeMirrorRuntime.wordWrapComp) return;
view.value.dispatch({
effects: codeMirrorRuntime.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 ||
!codeMirrorRuntime.codeMirrorTheme ||
!codeMirrorRuntime.fontThemeComp ||
!codeMirrorRuntime.wordWrapComp ||
!codeMirrorRuntime.lineNumbersComp ||
!codeMirrorRuntime.vimModeComp ||
!codeMirrorRuntime.closeBracketsComp ||
!codeMirrorRuntime.runGutterComp ||
!codeMirrorRuntime.runKeymapComp ||
!codeMirrorRuntime.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 ||
!codeMirrorRuntime.codeMirrorTheme ||
!codeMirrorRuntime.wordWrapComp ||
!codeMirrorRuntime.lineNumbersComp ||
!codeMirrorRuntime.vimModeComp ||
!codeMirrorRuntime.closeBracketsComp ||
!codeMirrorRuntime.runGutterComp ||
!codeMirrorRuntime.runKeymapComp ||
!codeMirrorRuntime.editorViewModule
) {
return;
}
view.value.dispatch({
effects: [
codeMirrorRuntime.codeMirrorTheme.reconfigure(themeExt),
codeMirrorRuntime.wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? codeMirrorRuntime.editorViewModule.EditorView.lineWrapping : []),
...(codeMirrorRuntime.showWhitespaceComp ? [codeMirrorRuntime.showWhitespaceComp.reconfigure(showWhitespaceExtension(ss.showWhitespace))] : []),
codeMirrorRuntime.lineNumbersComp.reconfigure(lineNumbersExtension(ss.showLineNumbers)),
codeMirrorRuntime.vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)),
codeMirrorRuntime.closeBracketsComp.reconfigure(closeBracketsExtension(settingsStore.editorSettings.autoCloseBrackets)),
codeMirrorRuntime.runGutterComp.reconfigure(runStatementGutterExtension()),
codeMirrorRuntime.runKeymapComp.reconfigure(runKeymapExtension(codeMirrorRuntime.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 || !codeMirrorRuntime.editorViewModule) return;
const effects = [];
if (codeMirrorRuntime.defaultKeymapComp) {
effects.push(codeMirrorRuntime.defaultKeymapComp.reconfigure(defaultKeymapExtension()));
}
if (codeMirrorRuntime.runKeymapComp) {
effects.push(codeMirrorRuntime.runKeymapComp.reconfigure(runKeymapExtension(codeMirrorRuntime.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 || !codeMirrorRuntime.indentComp) return;
view.value.dispatch({ effects: codeMirrorRuntime.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() {
completion.invalidateRequests();
if (!view.value || !codeMirrorRuntime.completionComp || !codeMirrorRuntime.buildSqlCompletionExtension) return;
view.value.dispatch({
effects: codeMirrorRuntime.completionComp.reconfigure(codeMirrorRuntime.buildSqlCompletionExtension()),
});
if (codeMirrorRuntime.codeMirrorCompletionStatus?.(view.value.state) === "active") {
codeMirrorRuntime.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 === "mongodb" || 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();
documentState.flushBeforeDeactivation();
clearTableNavigationHover();
clearPendingCompletionEnter();
clearPendingCompletionTab();
clearDeferredCompletionTrigger();
clearScheduledPreviewContextRefresh();
executionViewportOwnership.reset();
editorIsActive = false;
clearScheduledSemanticDiagnostics();
completion.invalidateRequests();
unregisterTableReferenceDropListener();
}
function resumeQueryEditorBackgroundWork() {
editorIsActive = true;
registerTableReferenceDropListener();
scheduleSemanticDiagnostics();
// Warm the database driver/pool while the user is still reading or typing, so
// the first Run does not pay pool creation or external-driver startup.
warmActiveTabConnection();
if (view.value) schedulePreviewContextRefresh(view.value);
restoreEditorSelection(undefined, !props.initialViewport);
restoreEditorFocus();
restoreEditorViewport();
}
function warmActiveTabConnection() {
if (!props.connectionId) return;
connectionStore.warmConnection(props.connectionId, {
database: props.database,
catalog: props.catalog,
clientSessionId: props.clientSessionId,
});
}
onActivated(resumeQueryEditorBackgroundWork);
onDeactivated(pauseQueryEditorBackgroundWork);
onMounted(() => {
if (typeof window === "undefined") return;
documentState.attach();
warmActiveTabConnection();
});
onBeforeUnmount(() => {
pauseQueryEditorBackgroundWork();
documentState.dispose();
pointerInteractions.dispose();
objectNavigation.dispose();
contextMenuPointerCleanup?.();
postCompositionKeyGuardCleanup?.();
postCompositionKeyGuardCleanup = null;
batchSelection.dispose();
zoomCommitScheduler.dispose();
if (view.value) {
unregisterQueryEditorInsertContext(view.value);
clearRememberedFocusedQueryEditorView(view.value);
}
codeMirrorLifecycle.dispose();
});
function openSearch(): boolean {
return searchPanelRef.value?.openSearch() ?? false;
}
function openGotoLine(): boolean {
return gotoLinePanelRef.value?.openGotoLine() ?? 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 || !codeMirrorRuntime.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: codeMirrorRuntime.editorViewModule.EditorView.scrollIntoView(pos, { y: "center" }),
});
}
function beginExecutionViewportTracking() {
executionViewportOwnership.beginExecution();
}
function recordExecutionViewportInteraction() {
executionViewportOwnership.recordUserInteraction();
}
function dismissHoverTooltip() {
if (!view.value || !codeMirrorRuntime.hoverCloseEffect) return;
view.value.dispatch({ effects: codeMirrorRuntime.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">
<QueryEditorContextMenu :get-state="getContextMenuState" :actions="contextMenuActions" @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;
}
"
/>
</QueryEditorContextMenu>
<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" @open="gotoLinePanelRef?.closeGotoLine()" />
<EditorGotoLinePanel ref="gotoLinePanelRef" :view="view" @open="searchPanelRef?.closeSearch()" />
<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" />
<TableStructurePeekDialog
v-for="panel in structurePeekPanels"
:key="panel.id"
:table-name="panel.target.name"
:schema="panel.target.schema"
:columns="panel.columns"
:loading="panel.loading"
:error="panel.error"
:z-index="panel.zIndex"
:cascade-index="panel.cascadeIndex"
@close="closeStructurePeekPanel(panel.id)"
@activate="bringStructurePeekToFront(panel.id)"
@insert-value="(value, kind) => insertStructurePeekValue(panel, value, kind)"
/>
<CodeSnapshotDialog v-model:open="codeSnapshotOpen" :source="codeSnapshotSource" />
<QueryEditorIntentionPopup :state="intentionPopup" @close="closeIntentionPopup" @confirm="executeIntentionAction" @select="intentionPopup && (intentionPopup.selectedIndex = $event)" />
</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);
}
.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>