1783 lines
60 KiB
Vue
1783 lines
60 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed } from "vue";
|
|
import { Play, Copy, TextSelect } from "lucide-vue-next";
|
|
import { useI18n } from "vue-i18n";
|
|
import type { CompletionContext } from "@codemirror/autocomplete";
|
|
import type { EditorView as EditorViewType } from "@codemirror/view";
|
|
import { search as cmSearch } from "@codemirror/search";
|
|
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
|
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
|
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import { useTheme } from "@/composables/useTheme";
|
|
import { useToast } from "@/composables/useToast";
|
|
import {
|
|
buildSqlCompletionItemsFromContext,
|
|
getSqlFunctionSignatureHelp,
|
|
getSqlCompletionContext,
|
|
getSqlCompletionResultValidFor,
|
|
shouldAutoOpenSqlCompletion,
|
|
extractCteDefinitions,
|
|
} from "@/lib/sqlCompletion";
|
|
import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation";
|
|
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sqlDiagnostics";
|
|
import {
|
|
DBX_TABLE_REFERENCE_MIME,
|
|
DBX_TABLE_REFERENCE_DROP_EVENT,
|
|
activeTableReferencePayloadValue,
|
|
clearActiveTableReferencePayload,
|
|
hasTableReferencePayloadType,
|
|
parseTableReferencePayload,
|
|
tableReferenceInsertText,
|
|
type QueryEditorTableReferenceDropDetail,
|
|
type QueryEditorTableReferencePayload,
|
|
} from "@/lib/queryEditorTableDrop";
|
|
import {
|
|
EDITOR_FONT_FAMILY_CSS_VAR,
|
|
EDITOR_FONT_SIZE_CSS_VAR,
|
|
loadEditorTheme,
|
|
editorFontTheme,
|
|
sqlCompletionTheme,
|
|
} from "@/lib/editorThemes";
|
|
import {
|
|
clampEditorFontSize,
|
|
createEditorZoomCommitScheduler,
|
|
fontSizeFromGestureScale,
|
|
fontSizeFromWheelDelta,
|
|
} from "@/lib/editorZoom";
|
|
import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry";
|
|
import * as api from "@/lib/api";
|
|
import {
|
|
areSqlSemanticDiagnosticsEqual,
|
|
buildSqlParserErrorDiagnostic,
|
|
buildSqlSemanticDiagnostics,
|
|
shouldRunSqlSemanticDiagnostics,
|
|
type SqlSemanticDiagnostic,
|
|
} from "@/lib/sqlSemanticDiagnostics";
|
|
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject } from "@/lib/sqlCompletion";
|
|
import type {
|
|
DatabaseType,
|
|
ForeignKeyInfo,
|
|
SqlReferenceAnalysis,
|
|
SqlTableReference,
|
|
SqlTextSpan,
|
|
} from "@/types/database";
|
|
import { vscodeSelectionLayer } from "@/lib/codemirrorVscodeSelectionLayer";
|
|
|
|
const props = defineProps<{
|
|
modelValue: string;
|
|
connectionId?: string;
|
|
database?: string;
|
|
databaseType?: DatabaseType;
|
|
dialect?: "mysql" | "postgres" | "sqlserver";
|
|
formatDialect?: SqlFormatDialect;
|
|
formatRequestId?: number;
|
|
executionError?: string;
|
|
readOnly?: boolean;
|
|
forceWordWrap?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: string];
|
|
selectionChange: [value: string];
|
|
cursorChange: [pos: number];
|
|
formatError: [message: string];
|
|
execute: [sql: string];
|
|
save: [];
|
|
clickTable: [tableName: string];
|
|
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
|
|
closeColumnPanel: [];
|
|
}>();
|
|
|
|
const editorRef = ref<HTMLDivElement>();
|
|
const view = shallowRef<EditorViewType | null>(null);
|
|
const connectionStore = useConnectionStore();
|
|
const settingsStore = useSettingsStore();
|
|
const { isDark } = useTheme();
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
|
|
const SQL_FUNCTION_NAMES = [
|
|
"COUNT",
|
|
"SUM",
|
|
"AVG",
|
|
"MIN",
|
|
"MAX",
|
|
"GROUP_CONCAT",
|
|
"STRING_AGG",
|
|
"CONCAT",
|
|
"CONCAT_WS",
|
|
"SUBSTRING",
|
|
"REPLACE",
|
|
"TRIM",
|
|
"UPPER",
|
|
"LOWER",
|
|
"LENGTH",
|
|
"REGEXP_REPLACE",
|
|
"DATE_FORMAT",
|
|
"DATEDIFF",
|
|
"DATE_ADD",
|
|
"DATE_SUB",
|
|
"EXTRACT",
|
|
"NOW",
|
|
"ROUND",
|
|
"FLOOR",
|
|
"CEIL",
|
|
"ABS",
|
|
"MOD",
|
|
"COALESCE",
|
|
"IFNULL",
|
|
"NULLIF",
|
|
"CAST",
|
|
"JSON_EXTRACT",
|
|
"JSON_VALUE",
|
|
"JSON_OBJECT",
|
|
"JSON_ARRAY",
|
|
] as const;
|
|
|
|
const completionTranslations = computed(() => ({
|
|
nullValue: t("editor.completion.nullValue"),
|
|
isNull: t("editor.completion.isNull"),
|
|
isNotNull: t("editor.completion.isNotNull"),
|
|
stringLiteral: t("editor.completion.stringLiteral"),
|
|
numericLiteral: t("editor.completion.numericLiteral"),
|
|
booleanValue: t("editor.completion.booleanValue"),
|
|
starExpansionColumns: t("editor.completion.starExpansionColumns"),
|
|
functionDescriptions: Object.fromEntries(
|
|
SQL_FUNCTION_NAMES.map((name) => [name, t(`editor.completion.functionDescriptions.${name}`)]),
|
|
) as Record<string, string>,
|
|
}));
|
|
const MAX_COMPLETION_TABLES = 200;
|
|
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
|
|
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
|
|
const isGestureZooming = ref(false);
|
|
|
|
const searchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
|
|
const selectedSql = ref("");
|
|
const executableSql = ref("");
|
|
|
|
const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0);
|
|
const canCopySelectedSql = computed(() => selectedSql.value.length > 0);
|
|
const canExecuteContextSql = computed(() => executableSql.value.trim().length > 0);
|
|
const executeContextMenuLabel = computed(() =>
|
|
t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"),
|
|
);
|
|
|
|
interface EditorGestureEvent extends Event {
|
|
scale?: number;
|
|
}
|
|
|
|
let editorViewModule: typeof import("@codemirror/view") | null = null;
|
|
let codeMirrorPrec: typeof import("@codemirror/state").Prec | null = null;
|
|
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
|
|
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
|
|
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
|
|
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
|
|
let runKeymapComp: import("@codemirror/state").Compartment | null = null;
|
|
let completionComp: import("@codemirror/state").Compartment | null = null;
|
|
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
|
|
let buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null = null;
|
|
let buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null = null;
|
|
let buildSqlCompletionExtension: (() => import("@codemirror/state").Extension) | null = null;
|
|
let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion;
|
|
let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null;
|
|
let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null;
|
|
let codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null;
|
|
let codeMirrorIndentMore: typeof import("@codemirror/commands").indentMore | null = null;
|
|
let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
|
|
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
|
|
let semanticDiagnostics: SqlSemanticDiagnostic[] = [];
|
|
let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let semanticDiagnosticRunId = 0;
|
|
let editorIsActive = true;
|
|
let tableReferenceDropListenerRegistered = false;
|
|
|
|
function editorThemeAppearance() {
|
|
return isDark.value ? "dark" : "light";
|
|
}
|
|
|
|
// Completion cache
|
|
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
|
|
let cachedCompletionObjects: SqlCompletionObject[] = [];
|
|
// Persistent column cache keyed by "schema.table" or "table"
|
|
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
|
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
|
|
|
const zoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => {
|
|
if (settingsStore.editorSettings.fontSize === fontSize) return;
|
|
settingsStore.updateEditorSettings({ fontSize });
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
let pendingFontReconfig: { size: number; family: string } | null = null;
|
|
let fontReconfigScheduled = false;
|
|
|
|
function reconfigureFontTheme(size: number, family: string) {
|
|
if (!fontThemeComp || !editorViewModule || !view.value) return;
|
|
view.value.dispatch({
|
|
effects: fontThemeComp.reconfigure(
|
|
editorFontTheme(editorViewModule.EditorView, size, family, {
|
|
fixedHeight: true,
|
|
scrollable: true,
|
|
}),
|
|
),
|
|
});
|
|
}
|
|
|
|
function scheduleFontThemeReconfig(size: number, family: string) {
|
|
pendingFontReconfig = { size, family };
|
|
if (fontReconfigScheduled) return;
|
|
fontReconfigScheduled = true;
|
|
requestAnimationFrame(() => {
|
|
fontReconfigScheduled = false;
|
|
const p = pendingFontReconfig;
|
|
if (p) {
|
|
pendingFontReconfig = null;
|
|
reconfigureFontTheme(p.size, p.family);
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyLiveFontSize(size: number) {
|
|
const next = clampEditorFontSize(size);
|
|
if (liveFontSize.value === next) return;
|
|
liveFontSize.value = next;
|
|
syncEditorFontCssVars(next);
|
|
// Throttle compartment reconfiguration to at most once per animation
|
|
// frame so that CSS variable changes remain smooth on every wheel tick,
|
|
// while the CodeMirror measure → syncGutters path keeps gutters aligned.
|
|
scheduleFontThemeReconfig(next, settingsStore.editorSettings.fontFamily);
|
|
}
|
|
|
|
function scheduleFontSizeCommit(size: number) {
|
|
zoomCommitScheduler.schedule(size);
|
|
}
|
|
|
|
function onEditorGestureStart(event: EditorGestureEvent) {
|
|
event.preventDefault();
|
|
isGestureZooming.value = true;
|
|
gestureStartFontSize.value = liveFontSize.value;
|
|
}
|
|
|
|
function onEditorGestureChange(event: EditorGestureEvent) {
|
|
if (typeof event.scale !== "number") return;
|
|
event.preventDefault();
|
|
applyLiveFontSize(fontSizeFromGestureScale(gestureStartFontSize.value, event.scale));
|
|
}
|
|
|
|
function onEditorGestureEnd(event: Event) {
|
|
event.preventDefault();
|
|
isGestureZooming.value = false;
|
|
zoomCommitScheduler.flush(liveFontSize.value);
|
|
}
|
|
|
|
function handleTab(view: EditorViewType): boolean {
|
|
if (codeMirrorCompletionStatus?.(view.state) === "active") return false;
|
|
const { state, dispatch } = view;
|
|
const sel = state.selection.main;
|
|
if (!sel.empty) return codeMirrorIndentMore?.(view) ?? false;
|
|
const line = state.doc.lineAt(sel.from);
|
|
const before = line.text.slice(0, sel.from - line.from);
|
|
if (/^\s*$/.test(before)) return codeMirrorIndentMore?.(view) ?? false;
|
|
dispatch(state.update(state.replaceSelection(" "), { userEvent: "input.type" }));
|
|
return true;
|
|
}
|
|
|
|
function executeCurrentSql() {
|
|
if (view.value) emit("execute", executableSqlFromView(view.value));
|
|
return true;
|
|
}
|
|
|
|
function syncContextMenuState(currentView: EditorViewType) {
|
|
selectedSql.value = selectedSqlFromView(currentView);
|
|
executableSql.value = executableSqlFromView(currentView);
|
|
}
|
|
|
|
function focusEditor() {
|
|
view.value?.focus();
|
|
}
|
|
|
|
function executeFromContextMenu() {
|
|
if (!canExecuteContextSql.value) return;
|
|
executeCurrentSql();
|
|
focusEditor();
|
|
}
|
|
|
|
async function copySelectedSqlFromContextMenu() {
|
|
if (!canCopySelectedSql.value) return;
|
|
try {
|
|
await copyToClipboard(selectedSql.value);
|
|
toast(t("grid.copied"));
|
|
focusEditor();
|
|
} catch (e: any) {
|
|
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
|
}
|
|
}
|
|
|
|
function selectAllSqlFromContextMenu() {
|
|
const currentView = view.value;
|
|
if (!currentView) return;
|
|
currentView.dispatch({
|
|
selection: { anchor: 0, head: currentView.state.doc.length },
|
|
scrollIntoView: true,
|
|
});
|
|
focusEditor();
|
|
}
|
|
|
|
function selectSqlLineFromGutter(
|
|
currentView: EditorViewType,
|
|
line: { from: number; to: number },
|
|
event: Event,
|
|
): boolean {
|
|
if (!(event instanceof MouseEvent) || event.button !== 0) return false;
|
|
event.preventDefault();
|
|
currentView.dispatch({
|
|
selection: { anchor: line.from, head: line.to },
|
|
scrollIntoView: true,
|
|
userEvent: "select.pointer",
|
|
});
|
|
currentView.focus();
|
|
return true;
|
|
}
|
|
|
|
const contextMenuItems = computed<ContextMenuItem[]>(() => [
|
|
{
|
|
label: executeContextMenuLabel.value,
|
|
action: executeFromContextMenu,
|
|
disabled: !canExecuteContextSql.value,
|
|
icon: Play,
|
|
},
|
|
{ label: "", separator: true },
|
|
{
|
|
label: t("editor.contextMenu.copySelection"),
|
|
action: copySelectedSqlFromContextMenu,
|
|
disabled: !canCopySelectedSql.value,
|
|
icon: Copy,
|
|
},
|
|
{ label: t("editor.contextMenu.selectAll"), action: selectAllSqlFromContextMenu, icon: TextSelect },
|
|
]);
|
|
|
|
function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) {
|
|
const shortcuts = settingsStore.editorSettings.shortcuts;
|
|
const Prec = codeMirrorPrec;
|
|
return [
|
|
Prec?.high(
|
|
codeMirrorKeymap.of([
|
|
{
|
|
key: "Enter",
|
|
run: codeMirrorInsertNewlineKeepIndent ?? undefined,
|
|
shift: codeMirrorInsertNewlineKeepIndent ?? undefined,
|
|
},
|
|
{
|
|
key: shortcutToCodeMirrorKey(shortcuts.find),
|
|
preventDefault: true,
|
|
run: openSearch,
|
|
},
|
|
{
|
|
key: shortcutToCodeMirrorKey(shortcuts.replace),
|
|
preventDefault: true,
|
|
run: openReplace,
|
|
},
|
|
{
|
|
key: shortcutToCodeMirrorKey(shortcuts.executeSql),
|
|
preventDefault: true,
|
|
run: executeCurrentSql,
|
|
},
|
|
{
|
|
key: shortcutToCodeMirrorKey(shortcuts.saveSql),
|
|
preventDefault: true,
|
|
run: () => {
|
|
emit("save");
|
|
return true;
|
|
},
|
|
},
|
|
]),
|
|
) ?? [],
|
|
codeMirrorKeymap.of([
|
|
{
|
|
key: shortcutToCodeMirrorKey(shortcuts.acceptCompletion),
|
|
run: (view) => codeMirrorAcceptCompletion?.(view) ?? false,
|
|
},
|
|
]),
|
|
];
|
|
}
|
|
|
|
function wordWrapExtension() {
|
|
if (!editorViewModule) return [];
|
|
return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? editorViewModule.EditorView.lineWrapping : [];
|
|
}
|
|
|
|
function selectedSqlFromView(currentView: EditorViewType): string {
|
|
const selection = currentView.state.selection.main;
|
|
return currentView.state.sliceDoc(selection.from, selection.to);
|
|
}
|
|
|
|
function executableSqlFromView(currentView: EditorViewType): string {
|
|
return resolveExecutableSql(currentView.state.doc.toString(), selectedSqlFromView(currentView));
|
|
}
|
|
|
|
function identifierRangeAt(sql: string, pos: number): { from: number; to: number; text: string } | null {
|
|
const isIdentifierChar = (ch: string | undefined) => !!ch && /[\w$.]/.test(ch);
|
|
if (!isIdentifierChar(sql[pos]) && !isIdentifierChar(sql[pos - 1])) return null;
|
|
|
|
let from = pos;
|
|
while (from > 0 && isIdentifierChar(sql[from - 1])) from--;
|
|
let to = pos;
|
|
while (to < sql.length && isIdentifierChar(sql[to])) to++;
|
|
|
|
const text = sql.slice(from, to).replace(/^\.+|\.+$/g, "");
|
|
if (!text || isSqlKeyword(text)) return null;
|
|
return { from, to, text };
|
|
}
|
|
|
|
function completionCacheKey(table: { name: string; schema?: string | null }) {
|
|
return table.schema ? `${table.schema}.${table.name}` : table.name;
|
|
}
|
|
|
|
async function ensureColumnsForTable(table: { name: string; schema?: string | null }) {
|
|
const cacheKey = completionCacheKey(table);
|
|
if (cachedColumnsByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
|
|
const columns = await connectionStore.listCompletionColumns(
|
|
props.connectionId,
|
|
props.database,
|
|
table.name,
|
|
table.schema ?? undefined,
|
|
);
|
|
if (columns.length === 0) return;
|
|
cachedColumnsByTable.set(cacheKey, columns);
|
|
}
|
|
|
|
async function ensureForeignKeysForTable(table: { name: string; schema?: string | null }) {
|
|
const cacheKey = completionCacheKey(table);
|
|
if (cachedForeignKeysByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
|
|
const querySchema = table.schema ?? props.database;
|
|
try {
|
|
const foreignKeys = await api.listForeignKeys(props.connectionId, props.database, querySchema, table.name);
|
|
cachedForeignKeysByTable.set(
|
|
cacheKey,
|
|
foreignKeys.map((foreignKey: ForeignKeyInfo) => ({
|
|
name: foreignKey.name,
|
|
column: foreignKey.column,
|
|
ref_schema: foreignKey.ref_schema,
|
|
ref_table: foreignKey.ref_table,
|
|
ref_column: foreignKey.ref_column,
|
|
})),
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[DBX] Failed to load foreign keys for ${cacheKey}:`, e);
|
|
cachedForeignKeysByTable.set(cacheKey, []);
|
|
}
|
|
}
|
|
|
|
function createHoverDom(title: string, detail: string, rows: string[] = []) {
|
|
const dom = document.createElement("div");
|
|
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
|
|
|
|
const heading = document.createElement("div");
|
|
heading.className = "font-medium";
|
|
heading.textContent = title;
|
|
dom.appendChild(heading);
|
|
|
|
const detailNode = document.createElement("div");
|
|
detailNode.className = "mt-1 text-muted-foreground";
|
|
detailNode.textContent = detail;
|
|
dom.appendChild(detailNode);
|
|
|
|
for (const row of rows) {
|
|
const rowNode = document.createElement("div");
|
|
rowNode.className = "mt-1 font-mono text-muted-foreground";
|
|
rowNode.textContent = row;
|
|
dom.appendChild(rowNode);
|
|
}
|
|
|
|
return dom;
|
|
}
|
|
|
|
function createSignatureDom(signature: ReturnType<typeof getSqlFunctionSignatureHelp>) {
|
|
const dom = document.createElement("div");
|
|
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
|
|
if (!signature) return dom;
|
|
|
|
const signatureNode = document.createElement("div");
|
|
signatureNode.className = "font-mono";
|
|
|
|
const nameNode = document.createElement("span");
|
|
nameNode.className = "text-muted-foreground";
|
|
nameNode.textContent = `${signature.name}(`;
|
|
signatureNode.appendChild(nameNode);
|
|
|
|
signature.parameters.forEach((parameter, index) => {
|
|
if (index > 0) {
|
|
const comma = document.createElement("span");
|
|
comma.className = "text-muted-foreground";
|
|
comma.textContent = ", ";
|
|
signatureNode.appendChild(comma);
|
|
}
|
|
const parameterNode = document.createElement("span");
|
|
parameterNode.className =
|
|
index === signature.activeParameter ? "font-semibold text-foreground" : "text-muted-foreground";
|
|
parameterNode.textContent = parameter;
|
|
signatureNode.appendChild(parameterNode);
|
|
});
|
|
|
|
const closeNode = document.createElement("span");
|
|
closeNode.className = "text-muted-foreground";
|
|
closeNode.textContent = ")";
|
|
signatureNode.appendChild(closeNode);
|
|
dom.appendChild(signatureNode);
|
|
|
|
return dom;
|
|
}
|
|
|
|
async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) {
|
|
if (!props.connectionId || props.database == null) return null;
|
|
|
|
const sql = currentView.state.doc.toString();
|
|
const range = identifierRangeAt(sql, pos);
|
|
if (!range) return null;
|
|
|
|
const identifier = range.text;
|
|
const parts = identifier.split(".");
|
|
const name = parts[parts.length - 1] ?? identifier;
|
|
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
|
|
|
|
try {
|
|
if (cachedTables.length === 0) {
|
|
cachedTables = await connectionStore.listCompletionTables(
|
|
props.connectionId,
|
|
props.database,
|
|
name,
|
|
MAX_COMPLETION_TABLES,
|
|
);
|
|
}
|
|
|
|
let table = matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables);
|
|
if (!table) {
|
|
const hoverTables = await connectionStore.listCompletionTables(
|
|
props.connectionId,
|
|
props.database,
|
|
name,
|
|
MAX_COMPLETION_TABLES,
|
|
);
|
|
cachedTables = [...cachedTables, ...hoverTables];
|
|
table = matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
|
|
}
|
|
if (table && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
|
|
return {
|
|
pos: range.from,
|
|
end: range.to,
|
|
create: () => ({
|
|
dom: createHoverDom(table.name, table.schema ? `table in ${table.schema}` : "table"),
|
|
}),
|
|
};
|
|
}
|
|
|
|
const context = getSqlCompletionContext(sql, pos);
|
|
const candidates = qualifier
|
|
? context.referencedTables.filter(
|
|
(rt) =>
|
|
rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase(),
|
|
)
|
|
: context.referencedTables;
|
|
|
|
for (const refTable of candidates) {
|
|
await ensureColumnsForTable(refTable);
|
|
const columns = cachedColumnsByTable.get(completionCacheKey(refTable)) ?? [];
|
|
const column = columns.find((col) => col.name.toLowerCase() === name.toLowerCase());
|
|
if (!column) continue;
|
|
return {
|
|
pos: range.from,
|
|
end: range.to,
|
|
create: () => ({
|
|
dom: createHoverDom(column.name, column.dataType || "column", [
|
|
column.schema ? `${column.schema}.${column.table}` : column.table,
|
|
]),
|
|
}),
|
|
};
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function sqlErrorDecorationRange(currentState: import("@codemirror/state").EditorState) {
|
|
if (!props.executionError) return [];
|
|
const location = parseSqlErrorLocation(props.executionError);
|
|
if (!location) return [];
|
|
const offset = lineColumnToOffset(currentState.doc.toString(), location);
|
|
if (offset == null) return [];
|
|
return [
|
|
{
|
|
from: offset,
|
|
to: Math.min(offset + 1, currentState.doc.length),
|
|
message: props.executionError,
|
|
},
|
|
];
|
|
}
|
|
|
|
function sqlTextSpanToRange(sql: string, span: SqlTextSpan): { from: number; to: number } | null {
|
|
if (!span.start_line || !span.start_column) return null;
|
|
const from = lineColumnToOffset(sql, { line: span.start_line - 1, column: span.start_column - 1 });
|
|
const to = lineColumnToOffset(sql, {
|
|
line: Math.max(span.end_line - 1, span.start_line - 1),
|
|
column: Math.max(span.end_column, span.start_column),
|
|
});
|
|
if (from == null || to == null || to <= from) return null;
|
|
return { from, to };
|
|
}
|
|
|
|
function sqlSemanticDecorationRanges(currentState: import("@codemirror/state").EditorState) {
|
|
const sql = currentState.doc.toString();
|
|
return semanticDiagnostics
|
|
.map((diagnostic) => {
|
|
const range = sqlTextSpanToRange(sql, diagnostic.span);
|
|
return range ? { ...range, message: diagnostic.message, severity: diagnostic.severity } : null;
|
|
})
|
|
.filter((range): range is { from: number; to: number; message: string; severity: "error" | "warning" } => !!range);
|
|
}
|
|
|
|
function reconfigureDiagnostics() {
|
|
if (!view.value) return;
|
|
if (setSqlDiagnosticsEffect) {
|
|
view.value.dispatch({
|
|
effects: setSqlDiagnosticsEffect.of(semanticDiagnostics),
|
|
});
|
|
return;
|
|
}
|
|
if (!diagnosticComp || !buildSqlDiagnosticExtension) return;
|
|
view.value.dispatch({
|
|
effects: diagnosticComp.reconfigure(buildSqlDiagnosticExtension()),
|
|
});
|
|
}
|
|
|
|
function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) {
|
|
if (areSqlSemanticDiagnosticsEqual(semanticDiagnostics, next)) return;
|
|
semanticDiagnostics = next;
|
|
reconfigureDiagnostics();
|
|
}
|
|
|
|
async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]) {
|
|
if (!props.connectionId || props.database == null) return tables;
|
|
|
|
const enriched: SqlTableReference[] = [];
|
|
for (const table of tables) {
|
|
if (table.schema) {
|
|
enriched.push(table);
|
|
continue;
|
|
}
|
|
const cached = cachedTables.find((item) => item.name.toLowerCase() === table.name.toLowerCase());
|
|
if (cached?.schema) {
|
|
enriched.push({ ...table, schema: cached.schema });
|
|
continue;
|
|
}
|
|
try {
|
|
const matches = await connectionStore.listCompletionTables(
|
|
props.connectionId,
|
|
props.database,
|
|
table.name,
|
|
MAX_COMPLETION_TABLES,
|
|
);
|
|
cachedTables = [...cachedTables, ...matches];
|
|
const match = matches.find((item) => item.name.toLowerCase() === table.name.toLowerCase());
|
|
enriched.push(match?.schema ? { ...table, schema: match.schema } : table);
|
|
} catch {
|
|
enriched.push(table);
|
|
}
|
|
}
|
|
return enriched;
|
|
}
|
|
|
|
async function refreshSemanticDiagnostics() {
|
|
const currentView = view.value;
|
|
const runId = ++semanticDiagnosticRunId;
|
|
if (!currentView || !props.connectionId || props.database == null) {
|
|
setSemanticDiagnostics([]);
|
|
return;
|
|
}
|
|
|
|
const sql = currentView.state.doc.toString();
|
|
if (!sql.trim()) {
|
|
setSemanticDiagnostics([]);
|
|
return;
|
|
}
|
|
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head)) {
|
|
scheduleSemanticDiagnostics(1200);
|
|
return;
|
|
}
|
|
if (codeMirrorCompletionStatus?.(currentView.state)) {
|
|
scheduleSemanticDiagnostics(900);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const analysis = await api.analyzeSqlReferences(sql, props.formatDialect ?? props.dialect ?? "generic");
|
|
if (runId !== semanticDiagnosticRunId) return;
|
|
|
|
const tables = await enrichSemanticDiagnosticTables(analysis.tables);
|
|
await Promise.all(tables.map((table) => ensureColumnsForTable(table)));
|
|
if (runId !== semanticDiagnosticRunId) return;
|
|
|
|
const enrichedAnalysis: SqlReferenceAnalysis = { ...analysis, tables };
|
|
setSemanticDiagnostics(
|
|
buildSqlSemanticDiagnostics(enrichedAnalysis, {
|
|
tables: cachedTables,
|
|
columnsByTable: cachedColumnsByTable,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
if (runId === semanticDiagnosticRunId) {
|
|
const diagnostic = buildSqlParserErrorDiagnostic(error, sql);
|
|
setSemanticDiagnostics(diagnostic ? [diagnostic] : []);
|
|
}
|
|
}
|
|
}
|
|
|
|
function scheduleSemanticDiagnostics(delay = 500) {
|
|
if (!editorIsActive) return;
|
|
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
|
|
semanticDiagnosticTimer = setTimeout(() => {
|
|
semanticDiagnosticTimer = null;
|
|
void refreshSemanticDiagnostics();
|
|
}, delay);
|
|
}
|
|
|
|
async function formatCurrentSql() {
|
|
const currentView = view.value;
|
|
if (!currentView) return;
|
|
|
|
const selection = currentView.state.selection.main;
|
|
const formatsSelection = !selection.empty;
|
|
const from = formatsSelection ? selection.from : 0;
|
|
const to = formatsSelection ? selection.to : currentView.state.doc.length;
|
|
const source = currentView.state.sliceDoc(from, to);
|
|
if (!source.trim()) return;
|
|
|
|
try {
|
|
const formatted = await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic");
|
|
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 droppedTableReference(event: DragEvent) {
|
|
return (
|
|
activeTableReferencePayloadValue() ??
|
|
parseTableReferencePayload(event.dataTransfer?.getData(DBX_TABLE_REFERENCE_MIME))
|
|
);
|
|
}
|
|
|
|
function hasDroppedTableReference(event: DragEvent) {
|
|
return !!activeTableReferencePayloadValue() || hasTableReferencePayloadType(event.dataTransfer?.types);
|
|
}
|
|
|
|
function insertTableReferencePayload(
|
|
currentView: EditorViewType,
|
|
payload: QueryEditorTableReferencePayload,
|
|
coords?: { clientX: number; clientY: number },
|
|
): boolean {
|
|
if (props.readOnly) return false;
|
|
const insertText = tableReferenceInsertText(payload, props.databaseType);
|
|
const dropPos = coords ? currentView.posAtCoords({ x: coords.clientX, y: coords.clientY }) : null;
|
|
const selection = currentView.state.selection.main;
|
|
const from = dropPos ?? selection.from;
|
|
const to = dropPos == null && !selection.empty ? selection.to : from;
|
|
currentView.dispatch({
|
|
changes: { from, to, insert: insertText },
|
|
selection: { anchor: from + insertText.length },
|
|
scrollIntoView: true,
|
|
userEvent: "input.drop",
|
|
});
|
|
clearActiveTableReferencePayload(payload);
|
|
currentView.focus();
|
|
return true;
|
|
}
|
|
|
|
function insertDroppedTableReference(currentView: EditorViewType, event: DragEvent): boolean {
|
|
const payload = droppedTableReference(event);
|
|
if (!payload) return false;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return insertTableReferencePayload(currentView, payload, { clientX: event.clientX, clientY: event.clientY });
|
|
}
|
|
|
|
function onTableReferenceDropEvent(event: Event) {
|
|
const currentView = view.value;
|
|
if (!currentView || props.readOnly || !(event instanceof CustomEvent)) return;
|
|
const detail = event.detail as QueryEditorTableReferenceDropDetail | undefined;
|
|
if (!detail?.payload) return;
|
|
const target = document.elementFromPoint(detail.clientX, detail.clientY);
|
|
if (target instanceof Element && editorRef.value?.contains(target)) {
|
|
insertTableReferencePayload(currentView, detail.payload, detail);
|
|
}
|
|
}
|
|
|
|
function registerTableReferenceDropListener() {
|
|
if (tableReferenceDropListenerRegistered) return;
|
|
window.addEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
|
|
tableReferenceDropListenerRegistered = true;
|
|
}
|
|
|
|
function unregisterTableReferenceDropListener() {
|
|
if (!tableReferenceDropListenerRegistered) return;
|
|
window.removeEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
|
|
tableReferenceDropListenerRegistered = false;
|
|
}
|
|
|
|
let completionEpoch = 0;
|
|
let completionDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function buildCompletionResult(
|
|
items: ReturnType<typeof buildSqlCompletionItemsFromContext>,
|
|
position: number,
|
|
prefixLength: number,
|
|
fullDoc: string,
|
|
) {
|
|
if (items.length === 0) return null;
|
|
return {
|
|
from: position - prefixLength,
|
|
filter: false,
|
|
options: items.map((item) =>
|
|
(item.type === "snippet" || item.type === "function") && item.apply
|
|
? codeMirrorSnippetCompletion(item.apply, {
|
|
label: item.label,
|
|
type: item.type,
|
|
detail: item.detail,
|
|
boost: item.boost,
|
|
})
|
|
: {
|
|
label: item.label,
|
|
type: item.type,
|
|
detail: item.detail,
|
|
apply: item.apply,
|
|
boost: item.boost,
|
|
},
|
|
),
|
|
validFor: getSqlCompletionResultValidFor(fullDoc, position),
|
|
};
|
|
}
|
|
|
|
async function provideSqlCompletions(
|
|
currentState: import("@codemirror/state").EditorState,
|
|
position: number,
|
|
explicit: boolean,
|
|
) {
|
|
if (!props.connectionId) return null;
|
|
const hasDatabase = props.database != null;
|
|
|
|
const epoch = ++completionEpoch;
|
|
|
|
try {
|
|
const fullDoc = currentState.doc.toString();
|
|
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
|
|
|
|
const completionContext = getSqlCompletionContext(fullDoc, position);
|
|
|
|
if (!hasDatabase) {
|
|
const items = buildSqlCompletionItemsFromContext(completionContext, {
|
|
tables: [],
|
|
objects: [],
|
|
columnsByTable: new Map(),
|
|
schemas: [],
|
|
translations: completionTranslations.value,
|
|
snippets: settingsStore.editorSettings.snippets,
|
|
dialect: props.dialect,
|
|
});
|
|
return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc);
|
|
}
|
|
|
|
const needsAsyncData =
|
|
completionContext.suggestTables ||
|
|
completionContext.suggestRoutines ||
|
|
completionContext.exclusiveRoutineSuggestions ||
|
|
!!completionContext.qualifier ||
|
|
!!completionContext.insertTable ||
|
|
completionContext.exclusiveColumnSuggestions ||
|
|
completionContext.referencedTables.length > 0;
|
|
|
|
if (!needsAsyncData) {
|
|
const items = buildSqlCompletionItemsFromContext(completionContext, {
|
|
tables: [],
|
|
objects: [],
|
|
columnsByTable: new Map(),
|
|
schemas: [],
|
|
translations: completionTranslations.value,
|
|
snippets: settingsStore.editorSettings.snippets,
|
|
dialect: props.dialect,
|
|
});
|
|
return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc);
|
|
}
|
|
|
|
// Cancel any pending debounced completion
|
|
if (completionDebounceTimer) {
|
|
clearTimeout(completionDebounceTimer);
|
|
completionDebounceTimer = null;
|
|
}
|
|
|
|
// Debounce the full async flow and return the promise to CodeMirror.
|
|
// This prevents wasted backend calls during rapid typing while still
|
|
// showing table/column names in the first popup.
|
|
return new Promise<ReturnType<typeof buildCompletionResult>>((resolve) => {
|
|
completionDebounceTimer = setTimeout(async () => {
|
|
completionDebounceTimer = null;
|
|
if (epoch !== completionEpoch) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
try {
|
|
const result = await performAsyncCompletionWithResult(epoch, completionContext, fullDoc, position);
|
|
resolve(result);
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
}, 150);
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function performAsyncCompletionWithResult(
|
|
epoch: number,
|
|
completionContext: ReturnType<typeof getSqlCompletionContext>,
|
|
fullDoc: string,
|
|
position: number,
|
|
) {
|
|
// Handle INSERT column list: fetch columns for the target table
|
|
let insertColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
|
if (completionContext.insertTable) {
|
|
try {
|
|
const insertCols = await connectionStore.listCompletionColumns(
|
|
props.connectionId!,
|
|
props.database!,
|
|
completionContext.insertTable,
|
|
completionContext.insertSchema,
|
|
);
|
|
if (epoch !== completionEpoch) return null;
|
|
if (insertCols.length > 0) {
|
|
const insertKey = completionContext.insertSchema
|
|
? `${completionContext.insertSchema}.${completionContext.insertTable}`
|
|
: completionContext.insertTable;
|
|
insertColumnsByTable.set(insertKey, insertCols);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const shouldLoadTables =
|
|
completionContext.suggestTables ||
|
|
(!!completionContext.qualifier && !isReferencedTableQualifier(completionContext));
|
|
let tables = shouldLoadTables
|
|
? await connectionStore.listCompletionTables(
|
|
props.connectionId!,
|
|
props.database!,
|
|
completionContext.qualifier || completionContext.prefix,
|
|
MAX_COMPLETION_TABLES,
|
|
)
|
|
: cachedTables;
|
|
if (epoch !== completionEpoch) return null;
|
|
|
|
const shouldLoadObjects =
|
|
completionContext.suggestRoutines ||
|
|
completionContext.exclusiveRoutineSuggestions ||
|
|
(!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions);
|
|
let completionObjects = shouldLoadObjects
|
|
? await connectionStore.listCompletionObjects(
|
|
props.connectionId!,
|
|
props.database!,
|
|
completionContext.qualifier || completionContext.prefix,
|
|
MAX_COMPLETION_TABLES,
|
|
)
|
|
: cachedCompletionObjects;
|
|
if (epoch !== completionEpoch) return null;
|
|
|
|
if (completionContext.qualifier && completionObjects.length === 0) {
|
|
const schemaObjects = await connectionStore.listCompletionObjects(
|
|
props.connectionId!,
|
|
props.database!,
|
|
completionContext.prefix,
|
|
MAX_COMPLETION_TABLES,
|
|
completionContext.qualifier,
|
|
);
|
|
if (schemaObjects.length > 0) {
|
|
completionObjects = schemaObjects;
|
|
}
|
|
if (epoch !== completionEpoch) return null;
|
|
}
|
|
cachedCompletionObjects = mergeCompletionObjects(cachedCompletionObjects, completionObjects);
|
|
|
|
// Fetch schemas for schema completion
|
|
let schemaNames: string[] = [];
|
|
if (completionContext.suggestTables && !completionContext.qualifier && !completionContext.insertTable) {
|
|
try {
|
|
schemaNames = await connectionStore.listCompletionSchemas(props.connectionId!, props.database!);
|
|
if (epoch !== completionEpoch) return null;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// If qualifier didn't match any table names, try it as a schema name
|
|
let qualifierIsSchema = false;
|
|
if (
|
|
completionContext.qualifier &&
|
|
tables.length === 0 &&
|
|
(completionContext.suggestTables || completionContext.exclusiveColumnSuggestions)
|
|
) {
|
|
const schemaTables = await connectionStore.listCompletionTables(
|
|
props.connectionId!,
|
|
props.database!,
|
|
completionContext.prefix,
|
|
MAX_COMPLETION_TABLES,
|
|
completionContext.qualifier,
|
|
);
|
|
if (schemaTables.length > 0) {
|
|
tables = schemaTables;
|
|
qualifierIsSchema = true;
|
|
}
|
|
if (epoch !== completionEpoch) return null;
|
|
}
|
|
|
|
// Collect referenced tables — enrich with schema from filtered table lookup
|
|
let refs = completionContext.referencedTables.map((rt) => {
|
|
if (!rt.schema) {
|
|
const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase());
|
|
if (cached && cached.schema) {
|
|
return { ...rt, schema: cached.schema };
|
|
}
|
|
}
|
|
return rt;
|
|
});
|
|
const unresolvedRefs = refs.filter((rt) => !rt.schema && !rt.columns);
|
|
if (unresolvedRefs.length > 0) {
|
|
const lookupGroups = await Promise.all(
|
|
unresolvedRefs.map((rt) =>
|
|
connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20),
|
|
),
|
|
);
|
|
if (epoch !== completionEpoch) return null;
|
|
const lookupTables = lookupGroups.flat();
|
|
refs = refs.map((rt) => {
|
|
if (rt.schema || rt.columns) return rt;
|
|
const matched = lookupTables.find((table) => table.name.toLowerCase() === rt.name.toLowerCase());
|
|
return matched?.schema ? { ...rt, schema: matched.schema } : rt;
|
|
});
|
|
}
|
|
|
|
// If no referenced tables but qualifier exists, infer table from tables list
|
|
if (refs.length === 0 && completionContext.qualifier) {
|
|
const q = completionContext.qualifier.toLowerCase();
|
|
const matched = tables.filter((t) => t.name.toLowerCase() === q || t.name.toLowerCase().endsWith("." + q));
|
|
refs = matched.map((t) => ({ name: t.name, schema: t.schema }));
|
|
}
|
|
|
|
// Populate CTE columns from parsed definitions
|
|
const cteDefs = extractCteDefinitions(fullDoc);
|
|
for (const refTable of refs) {
|
|
if (refTable.columns) continue;
|
|
const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase());
|
|
if (cteDef) {
|
|
refTable.columns = cteDef.columns;
|
|
}
|
|
}
|
|
|
|
await Promise.all(
|
|
refs.map(async (refTable) => {
|
|
if (refTable.columns && refTable.columns.length > 0) return;
|
|
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
|
if (cachedColumnsByTable.has(cacheKey)) return;
|
|
try {
|
|
const columns = await connectionStore.listCompletionColumns(
|
|
props.connectionId!,
|
|
props.database!,
|
|
refTable.name,
|
|
refTable.schema,
|
|
);
|
|
if (epoch !== completionEpoch) return;
|
|
if (columns.length === 0) return;
|
|
cachedColumnsByTable.set(cacheKey, columns);
|
|
} catch (e) {
|
|
console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e);
|
|
}
|
|
}),
|
|
);
|
|
if (epoch !== completionEpoch) return null;
|
|
|
|
if (completionContext.suggestJoinConditions) {
|
|
await Promise.all(
|
|
refs.map(async (refTable) => {
|
|
if (refTable.columns && refTable.columns.length > 0) return;
|
|
await ensureForeignKeysForTable(refTable);
|
|
}),
|
|
);
|
|
if (epoch !== completionEpoch) return null;
|
|
}
|
|
|
|
// Build columnsByTable — from cache or CTE definitions
|
|
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
|
|
const foreignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
|
if (insertColumnsByTable.size > 0) {
|
|
for (const [key, cols] of insertColumnsByTable.entries()) {
|
|
columnsByTable.set(key, cols);
|
|
}
|
|
} else {
|
|
for (const refTable of refs) {
|
|
if (refTable.columns && refTable.columns.length > 0) {
|
|
const key = refTable.name;
|
|
columnsByTable.set(
|
|
key,
|
|
refTable.columns.map((name) => ({
|
|
name,
|
|
table: refTable.name,
|
|
dataType: undefined,
|
|
})),
|
|
);
|
|
continue;
|
|
}
|
|
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
|
const cached = cachedColumnsByTable.get(cacheKey);
|
|
if (cached) {
|
|
columnsByTable.set(cacheKey, cached);
|
|
}
|
|
const cachedForeignKeys = cachedForeignKeysByTable.get(cacheKey);
|
|
if (cachedForeignKeys) {
|
|
foreignKeysByTable.set(cacheKey, cachedForeignKeys);
|
|
}
|
|
}
|
|
}
|
|
|
|
const effectiveContext = qualifierIsSchema
|
|
? {
|
|
...completionContext,
|
|
qualifier: undefined,
|
|
suggestTables: true,
|
|
suggestColumns: false,
|
|
exclusiveColumnSuggestions: false,
|
|
}
|
|
: completionContext;
|
|
|
|
const items = buildSqlCompletionItemsFromContext(effectiveContext, {
|
|
tables,
|
|
objects: completionObjects,
|
|
columnsByTable,
|
|
foreignKeysByTable,
|
|
schemas: schemaNames,
|
|
translations: completionTranslations.value,
|
|
snippets: settingsStore.editorSettings.snippets,
|
|
dialect: props.dialect,
|
|
});
|
|
|
|
return buildCompletionResult(items, position, completionContext.prefix.length, fullDoc);
|
|
}
|
|
|
|
function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
|
|
if (!completionContext.qualifier) return false;
|
|
const qualifier = completionContext.qualifier.toLowerCase();
|
|
return completionContext.referencedTables.some(
|
|
(table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier,
|
|
);
|
|
}
|
|
|
|
function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) {
|
|
const merged = [...existing];
|
|
const seen = new Set(
|
|
existing.map((object) =>
|
|
`${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase(),
|
|
),
|
|
);
|
|
for (const object of incoming) {
|
|
const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
merged.push(object);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
async function refreshCompletionCache() {
|
|
cachedTables = [];
|
|
cachedCompletionObjects = [];
|
|
cachedColumnsByTable.clear();
|
|
cachedForeignKeysByTable.clear();
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!editorRef.value) return;
|
|
|
|
const [
|
|
{
|
|
EditorView,
|
|
keymap,
|
|
rectangularSelection,
|
|
hoverTooltip,
|
|
showTooltip,
|
|
Decoration,
|
|
tooltips,
|
|
lineNumbers,
|
|
highlightActiveLineGutter,
|
|
highlightSpecialChars,
|
|
drawSelection,
|
|
dropCursor,
|
|
crosshairCursor,
|
|
ViewPlugin,
|
|
},
|
|
{ EditorState, Compartment, Prec, StateEffect, StateField },
|
|
{ sql, MSSQL, MySQL, PostgreSQL, SQLDialect },
|
|
{
|
|
autocompletion,
|
|
startCompletion,
|
|
acceptCompletion,
|
|
closeBrackets,
|
|
closeBracketsKeymap,
|
|
snippetCompletion,
|
|
completionStatus,
|
|
completionKeymap,
|
|
},
|
|
{ indentMore, insertNewlineKeepIndent, history, defaultKeymap, historyKeymap },
|
|
{ bracketMatching, foldGutter, indentOnInput, syntaxHighlighting, defaultHighlightStyle, foldKeymap },
|
|
{ searchKeymap },
|
|
] = await Promise.all([
|
|
import("@codemirror/view"),
|
|
import("@codemirror/state"),
|
|
import("@codemirror/lang-sql"),
|
|
import("@codemirror/autocomplete"),
|
|
import("@codemirror/commands"),
|
|
import("@codemirror/language"),
|
|
import("@codemirror/search"),
|
|
]);
|
|
editorViewModule = { EditorView, keymap, rectangularSelection } as typeof import("@codemirror/view");
|
|
codeMirrorPrec = Prec;
|
|
codeMirrorSnippetCompletion = snippetCompletion;
|
|
fontThemeComp = new Compartment();
|
|
codeMirrorTheme = new Compartment();
|
|
wordWrapComp = new Compartment();
|
|
readOnlyComp = new Compartment();
|
|
runKeymapComp = new Compartment();
|
|
completionComp = new Compartment();
|
|
diagnosticComp = new Compartment();
|
|
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
|
|
codeMirrorCompletionStatus = completionStatus;
|
|
codeMirrorAcceptCompletion = acceptCompletion;
|
|
codeMirrorStartCompletion = startCompletion;
|
|
codeMirrorIndentMore = indentMore;
|
|
codeMirrorInsertNewlineKeepIndent = insertNewlineKeepIndent;
|
|
|
|
const diagnosticTheme = EditorView.baseTheme({
|
|
".cm-sql-error": {
|
|
textDecoration: "underline wavy var(--destructive)",
|
|
textUnderlineOffset: "3px",
|
|
},
|
|
".cm-sql-semantic-warning": {
|
|
textDecoration: "underline wavy hsl(var(--warning, 38 92% 50%))",
|
|
textUnderlineOffset: "3px",
|
|
},
|
|
});
|
|
|
|
buildSqlDiagnosticExtension = () => {
|
|
const diagnosticEffect = setSqlDiagnosticsEffect;
|
|
const buildDecorations = (state: import("@codemirror/state").EditorState) => {
|
|
const errorDecorations = sqlErrorDecorationRange(state).map((range) =>
|
|
Decoration.mark({
|
|
class: "cm-sql-error",
|
|
attributes: { title: range.message },
|
|
}).range(range.from, range.to),
|
|
);
|
|
const semanticDecorations = sqlSemanticDecorationRanges(state).map((range) =>
|
|
Decoration.mark({
|
|
class: range.severity === "error" ? "cm-sql-error" : "cm-sql-semantic-warning",
|
|
attributes: { title: range.message },
|
|
}).range(range.from, range.to),
|
|
);
|
|
return Decoration.set([...errorDecorations, ...semanticDecorations], true);
|
|
};
|
|
|
|
const field = StateField.define({
|
|
create: buildDecorations,
|
|
update(value, transaction) {
|
|
const diagnosticsChanged =
|
|
!!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect));
|
|
return transaction.docChanged || diagnosticsChanged ? buildDecorations(transaction.state) : value;
|
|
},
|
|
provide: (field) => EditorView.decorations.from(field),
|
|
});
|
|
|
|
return [field, diagnosticTheme];
|
|
};
|
|
|
|
buildSqlSignatureExtension = () =>
|
|
showTooltip.compute(["doc", "selection"], (currentState) => {
|
|
const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head);
|
|
if (!signature) return null;
|
|
return {
|
|
pos: currentState.selection.main.head,
|
|
above: false,
|
|
clip: false,
|
|
create: () => ({ dom: createSignatureDom(signature) }),
|
|
};
|
|
});
|
|
|
|
buildSqlCompletionExtension = () =>
|
|
autocompletion({
|
|
activateOnTyping: true,
|
|
override: [
|
|
async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos, context.explicit),
|
|
],
|
|
});
|
|
|
|
const ss = settingsStore.editorSettings;
|
|
|
|
const baseDialect = props.dialect === "postgres" ? PostgreSQL : props.dialect === "sqlserver" ? MSSQL : MySQL;
|
|
const extraKeywords =
|
|
"PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT";
|
|
const dialect = SQLDialect.define({
|
|
...baseDialect.spec,
|
|
keywords: (baseDialect.spec.keywords || "") + " " + extraKeywords,
|
|
});
|
|
|
|
const theme = await loadEditorTheme(ss.theme, editorThemeAppearance());
|
|
|
|
const activeLineHighlighter = ViewPlugin.fromClass(
|
|
class {
|
|
decorations: import("@codemirror/view").DecorationSet;
|
|
constructor(view: import("@codemirror/view").EditorView) {
|
|
this.decorations = this.getDeco(view);
|
|
}
|
|
update(update: import("@codemirror/view").ViewUpdate) {
|
|
if (update.docChanged || update.selectionSet) this.decorations = this.getDeco(update.view);
|
|
}
|
|
getDeco(view: import("@codemirror/view").EditorView) {
|
|
if (!view.state.selection.main.empty) return Decoration.none;
|
|
let lastLineStart = -1;
|
|
const deco: any[] = [];
|
|
for (const r of view.state.selection.ranges) {
|
|
if (!r.empty) continue;
|
|
const line = view.lineBlockAt(r.head);
|
|
if (line.from > lastLineStart) {
|
|
deco.push(Decoration.line({ class: "cm-activeLine" }).range(line.from));
|
|
lastLineStart = line.from;
|
|
}
|
|
}
|
|
return Decoration.set(deco);
|
|
}
|
|
},
|
|
{ decorations: (v) => v.decorations },
|
|
);
|
|
|
|
const state = EditorState.create({
|
|
doc: props.modelValue,
|
|
extensions: [
|
|
cmSearch({
|
|
top: true,
|
|
createPanel: () => {
|
|
const dom = document.createElement("span");
|
|
dom.style.display = "none";
|
|
return { dom };
|
|
},
|
|
}),
|
|
lineNumbers({
|
|
domEventHandlers: {
|
|
mousedown: selectSqlLineFromGutter,
|
|
},
|
|
}),
|
|
highlightActiveLineGutter(),
|
|
highlightSpecialChars(),
|
|
history(),
|
|
foldGutter(),
|
|
drawSelection(),
|
|
vscodeSelectionLayer(),
|
|
dropCursor(),
|
|
EditorState.allowMultipleSelections.of(true),
|
|
indentOnInput(),
|
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
crosshairCursor(),
|
|
activeLineHighlighter,
|
|
keymap.of([...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap]),
|
|
sql({ dialect }),
|
|
tooltips({ parent: document.body }),
|
|
completionComp.of(buildSqlCompletionExtension()),
|
|
sqlCompletionTheme(EditorView),
|
|
codeMirrorTheme.of(theme),
|
|
closeBrackets(),
|
|
bracketMatching(),
|
|
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
|
|
buildSqlSignatureExtension(),
|
|
diagnosticComp.of(buildSqlDiagnosticExtension()),
|
|
Prec.highest(
|
|
keymap.of([
|
|
...closeBracketsKeymap,
|
|
{ key: "Tab", run: handleTab },
|
|
{
|
|
key: "Escape",
|
|
run: () => {
|
|
return searchPanelRef.value?.closeSearch() ?? false;
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
runKeymapComp.of(runKeymapExtension(keymap)),
|
|
wordWrapComp.of(props.forceWordWrap || ss.wordWrap ? EditorView.lineWrapping : []),
|
|
readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]),
|
|
rectangularSelection({ eventFilter: (e: MouseEvent) => e.altKey || e.button === 1 }),
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
emit("update:modelValue", update.state.doc.toString());
|
|
scheduleSemanticDiagnostics();
|
|
let insertedText = "";
|
|
update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
|
|
insertedText += inserted.toString();
|
|
});
|
|
if (insertedText.endsWith(".")) {
|
|
startCompletion(update.view);
|
|
}
|
|
}
|
|
if (update.selectionSet || update.docChanged) {
|
|
syncContextMenuState(update.view);
|
|
emit("selectionChange", selectedSqlFromView(update.view));
|
|
emit("cursorChange", update.state.selection.main.head);
|
|
}
|
|
}),
|
|
fontThemeComp.of(
|
|
editorFontTheme(EditorView, liveFontSize.value, ss.fontFamily, {
|
|
fixedHeight: true,
|
|
scrollable: true,
|
|
}),
|
|
),
|
|
EditorView.domEventHandlers({
|
|
dragover(event) {
|
|
if (props.readOnly || !hasDroppedTableReference(event)) return false;
|
|
event.preventDefault();
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
|
return true;
|
|
},
|
|
drop(event, currentView) {
|
|
return insertDroppedTableReference(currentView, event);
|
|
},
|
|
wheel(event) {
|
|
if (!event.metaKey && !event.ctrlKey) return false;
|
|
event.preventDefault();
|
|
const next = fontSizeFromWheelDelta(liveFontSize.value, event.deltaY);
|
|
applyLiveFontSize(next);
|
|
scheduleFontSizeCommit(next);
|
|
return true;
|
|
},
|
|
mousedown: (event: MouseEvent) => {
|
|
// Click without modifier -> close column panel
|
|
if (!event.metaKey && !event.ctrlKey) {
|
|
if (event.button === 0) {
|
|
emit("closeColumnPanel");
|
|
}
|
|
return false;
|
|
}
|
|
// Only handle Ctrl/Cmd + left click
|
|
if (event.button !== 0) return false;
|
|
|
|
const currentView = view.value;
|
|
if (!currentView || !props.connectionId || props.database == null) {
|
|
return false;
|
|
}
|
|
|
|
// Use posAtCoords for accurate click position
|
|
const coords = { x: event.clientX, y: event.clientY };
|
|
const pos = currentView.posAtCoords(coords);
|
|
if (pos == null) {
|
|
return false;
|
|
}
|
|
|
|
const doc = currentView.state.doc.toString();
|
|
const identifier = extractIdentifierAt(doc, pos);
|
|
if (!identifier) {
|
|
return false;
|
|
}
|
|
if (isSqlKeyword(identifier)) {
|
|
return false;
|
|
}
|
|
|
|
// Prevent default, resolve async
|
|
event.preventDefault();
|
|
setTimeout(async () => {
|
|
try {
|
|
// Ensure table cache is populated
|
|
if (cachedTables.length === 0) {
|
|
cachedTables = await connectionStore.listCompletionTables(
|
|
props.connectionId!,
|
|
props.database!,
|
|
identifier,
|
|
MAX_COMPLETION_TABLES,
|
|
);
|
|
}
|
|
|
|
// 1. Check if it's a table name
|
|
const matchedTable = matchTable(identifier, cachedTables);
|
|
if (matchedTable) {
|
|
emit(
|
|
"clickTable",
|
|
matchedTable.schema ? `${matchedTable.schema}.${matchedTable.name}` : matchedTable.name,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 2. Parse SQL at click position to get referenced tables
|
|
const context = getSqlCompletionContext(doc, pos);
|
|
let referencedTables = context.referencedTables;
|
|
// Enrich referenced tables with schema from cachedTables
|
|
referencedTables = referencedTables.map((rt) => {
|
|
const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase());
|
|
if (cached && cached.schema && !rt.schema) {
|
|
return { ...rt, schema: cached.schema };
|
|
}
|
|
return rt;
|
|
});
|
|
|
|
// Check if identifier has a qualifier (e.g., c.card_name)
|
|
const qualifierMatch = /^(.+)\.(.+)$/.exec(identifier);
|
|
const qualifier = qualifierMatch ? qualifierMatch[1] : null;
|
|
const colName = qualifierMatch ? qualifierMatch[2] : identifier;
|
|
const colLower = colName.toLowerCase();
|
|
|
|
if (referencedTables.length === 0) {
|
|
return;
|
|
}
|
|
// 3. Fetch columns — if qualifier, only check matching table; otherwise check all
|
|
const tablesToCheck = qualifier
|
|
? referencedTables.filter(
|
|
(rt) =>
|
|
rt.alias?.toLowerCase() === qualifier.toLowerCase() ||
|
|
rt.name.toLowerCase() === qualifier.toLowerCase(),
|
|
)
|
|
: referencedTables;
|
|
|
|
if (tablesToCheck.length === 0 && qualifier) {
|
|
return;
|
|
}
|
|
|
|
const matchedCols: Array<{ name: string; table: string; schema?: string }> = [];
|
|
|
|
for (const refTable of tablesToCheck) {
|
|
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
|
|
|
// Use persistent column cache; fetch only if missing
|
|
let cols = cachedColumnsByTable.get(cacheKey);
|
|
if (!cols) {
|
|
try {
|
|
cols = await connectionStore.listCompletionColumns(
|
|
props.connectionId!,
|
|
props.database!,
|
|
refTable.name,
|
|
refTable.schema,
|
|
);
|
|
cachedColumnsByTable.set(cacheKey, cols);
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
for (const col of cols) {
|
|
if (col.name.toLowerCase() === colLower) {
|
|
matchedCols.push({
|
|
name: col.name,
|
|
table: refTable.name,
|
|
schema: col.schema || refTable.schema,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchedCols.length > 0) {
|
|
emit("clickColumn", matchedCols);
|
|
}
|
|
} catch (e) {
|
|
console.error("[DBX] Ctrl+click error:", e);
|
|
}
|
|
}, 0);
|
|
return true;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
view.value = new EditorView({ state, parent: editorRef.value });
|
|
syncContextMenuState(view.value);
|
|
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
|
|
registerTableReferenceDropListener();
|
|
|
|
cachedTables = [];
|
|
cachedCompletionObjects = [];
|
|
scheduleSemanticDiagnostics();
|
|
});
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(val) => {
|
|
if (view.value && val !== view.value.state.doc.toString()) {
|
|
view.value.dispatch({
|
|
changes: { from: 0, to: view.value.state.doc.length, insert: val },
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.formatRequestId,
|
|
(val, oldVal) => {
|
|
if (val && val !== oldVal) formatCurrentSql();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.executionError,
|
|
() => {
|
|
reconfigureDiagnostics();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.connectionId,
|
|
() => {
|
|
refreshCompletionCache();
|
|
setSemanticDiagnostics([]);
|
|
scheduleSemanticDiagnostics();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.database,
|
|
() => {
|
|
refreshCompletionCache();
|
|
setSemanticDiagnostics([]);
|
|
scheduleSemanticDiagnostics();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.forceWordWrap,
|
|
() => {
|
|
if (!view.value || !wordWrapComp) return;
|
|
view.value.dispatch({
|
|
effects: wordWrapComp.reconfigure(wordWrapExtension()),
|
|
});
|
|
},
|
|
);
|
|
|
|
// Reactively apply editor settings changes
|
|
watch(
|
|
[() => settingsStore.editorSettings, () => isDark.value],
|
|
async ([ss]) => {
|
|
if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !runKeymapComp || !editorViewModule) {
|
|
return;
|
|
}
|
|
if (!isGestureZooming.value && !zoomCommitScheduler.hasPendingCommit() && liveFontSize.value !== ss.fontSize) {
|
|
liveFontSize.value = ss.fontSize;
|
|
}
|
|
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
|
|
const themeExt = await loadEditorTheme(ss.theme, editorThemeAppearance());
|
|
view.value.dispatch({
|
|
effects: [
|
|
codeMirrorTheme.reconfigure(themeExt),
|
|
wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []),
|
|
runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)),
|
|
],
|
|
});
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
watch(
|
|
() => settingsStore.editorSettings.snippets,
|
|
() => {
|
|
completionEpoch++;
|
|
if (!view.value || !completionComp || !buildSqlCompletionExtension) return;
|
|
view.value.dispatch({
|
|
effects: completionComp.reconfigure(buildSqlCompletionExtension()),
|
|
});
|
|
if (codeMirrorCompletionStatus?.(view.value.state) === "active") {
|
|
codeMirrorStartCompletion?.(view.value);
|
|
}
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
function pauseQueryEditorBackgroundWork() {
|
|
editorIsActive = false;
|
|
semanticDiagnosticRunId++;
|
|
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
|
|
semanticDiagnosticTimer = null;
|
|
completionEpoch++;
|
|
unregisterTableReferenceDropListener();
|
|
}
|
|
|
|
function resumeQueryEditorBackgroundWork() {
|
|
editorIsActive = true;
|
|
registerTableReferenceDropListener();
|
|
scheduleSemanticDiagnostics();
|
|
}
|
|
|
|
onActivated(resumeQueryEditorBackgroundWork);
|
|
|
|
onDeactivated(pauseQueryEditorBackgroundWork);
|
|
|
|
onBeforeUnmount(() => {
|
|
pauseQueryEditorBackgroundWork();
|
|
zoomCommitScheduler.dispose();
|
|
view.value?.destroy();
|
|
});
|
|
|
|
function openSearch(): boolean {
|
|
return searchPanelRef.value?.openSearch() ?? false;
|
|
}
|
|
|
|
function openReplace(): boolean {
|
|
return searchPanelRef.value?.openReplace() ?? false;
|
|
}
|
|
|
|
function scrollCursorIntoView() {
|
|
if (!view.value || !editorViewModule) return;
|
|
const pos = view.value.state.selection.main.head;
|
|
view.value.dispatch({
|
|
effects: editorViewModule.EditorView.scrollIntoView(pos, { y: "nearest" }),
|
|
});
|
|
}
|
|
|
|
defineExpose({ openSearch, openReplace, scrollCursorIntoView });
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="h-full w-full overflow-hidden relative"
|
|
@gesturestart="onEditorGestureStart"
|
|
@gesturechange="onEditorGestureChange"
|
|
@gestureend="onEditorGestureEnd"
|
|
>
|
|
<CustomContextMenu :items="contextMenuItems" v-slot="{ onContextMenu }">
|
|
<div
|
|
ref="editorRef"
|
|
data-query-editor-root
|
|
class="h-full w-full overflow-hidden"
|
|
@contextmenu="
|
|
(e: MouseEvent) => {
|
|
if (view) syncContextMenuState(view);
|
|
onContextMenu(e);
|
|
}
|
|
"
|
|
/>
|
|
</CustomContextMenu>
|
|
<EditorSearchPanel ref="searchPanelRef" :view="view" />
|
|
</div>
|
|
</template>
|