feat(editor): add query editing utilities
This commit is contained in:
1 parent
d689d78ab0
commit
782615586c
15 files changed
+989
-59
No files matched your search
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
||||
import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, Sparkles, Table2, TextSelect } from "@lucide/vue";
|
||||
import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, List, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||
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 SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
|
||||
import DelimitedListDialog from "./DelimitedListDialog.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||||
@@ -15,6 +16,7 @@ import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc,
|
||||
import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
|
||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
|
||||
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
|
||||
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
|
||||
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
|
||||
@@ -203,6 +205,28 @@ const pickerCandidates = ref<SqlExecutionCandidate[]>([]);
|
||||
const pickerActiveIndex = ref(0);
|
||||
const pickerAnchor = ref<{ left: number; top: number }>();
|
||||
|
||||
// Delimited list dialog state
|
||||
const delimitedListOpen = ref(false);
|
||||
const delimitedListSelectedText = ref("");
|
||||
|
||||
function openDelimitedListDialog() {
|
||||
if (props.readOnly) return;
|
||||
if (!selectedSql.value.trim()) {
|
||||
toast(t("editor.delimitedList.selectFirst"), 3000);
|
||||
return;
|
||||
}
|
||||
delimitedListSelectedText.value = selectedSql.value;
|
||||
delimitedListOpen.value = true;
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
function applyDelimitedListResult(result: string) {
|
||||
const currentView = view.value;
|
||||
if (!currentView || props.readOnly) return;
|
||||
if (!replaceSelectedEditorText(currentView, result)) return;
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
const executeContextMenuLabel = computed(() => t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"));
|
||||
|
||||
interface EditorGestureEvent extends Event {
|
||||
@@ -256,8 +280,18 @@ let codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null
|
||||
let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
|
||||
let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null;
|
||||
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
|
||||
let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
|
||||
let setResultSourceRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
|
||||
let setPreviewRangeEffect:
|
||||
| import("@codemirror/state").StateEffectType<{
|
||||
from: number;
|
||||
to: number;
|
||||
} | null>
|
||||
| null = null;
|
||||
let setResultSourceRangeEffect:
|
||||
| import("@codemirror/state").StateEffectType<{
|
||||
from: number;
|
||||
to: number;
|
||||
} | null>
|
||||
| null = null;
|
||||
let setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectType<StatementExecutionMarker[]> | null = null;
|
||||
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
|
||||
let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
@@ -404,7 +438,11 @@ function handleTab(view: EditorViewType): boolean {
|
||||
const line = state.doc.lineAt(sel.from);
|
||||
const before = line.text.slice(0, sel.from - line.from);
|
||||
if (/^\s*$/.test(before)) return codeMirrorIndentMore?.(view) ?? false;
|
||||
dispatch(state.update(state.replaceSelection(editorIndentUnit()), { userEvent: "input.type" }));
|
||||
dispatch(
|
||||
state.update(state.replaceSelection(editorIndentUnit()), {
|
||||
userEvent: "input.type",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -566,12 +604,32 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
|
||||
|
||||
const sql = currentView.state.doc.toString();
|
||||
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;
|
||||
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 });
|
||||
const parsedCandidate = queryTableCandidateAtSqlPosition({
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
schema: props.schema,
|
||||
databaseType: props.databaseType,
|
||||
sql,
|
||||
position: pos,
|
||||
});
|
||||
if (!parsedCandidate) {
|
||||
contextObjectTarget.value = null;
|
||||
return;
|
||||
@@ -648,7 +706,11 @@ function selectedRangeAtPointer(currentView: EditorViewType, event: MouseEvent)
|
||||
if (range.empty) return null;
|
||||
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
|
||||
if (pos == null || pos < range.from || pos > range.to) return null;
|
||||
return { from: range.from, to: range.to, text: currentView.state.sliceDoc(range.from, range.to) };
|
||||
return {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
text: currentView.state.sliceDoc(range.from, range.to),
|
||||
};
|
||||
}
|
||||
|
||||
function moveOrCopySelectionToPointer(currentView: EditorViewType, selection: { from: number; to: number; text: string }, event: MouseEvent) {
|
||||
@@ -754,9 +816,15 @@ function startEditorSelectionDrag(currentView: EditorViewType, event: MouseEvent
|
||||
moveOrCopySelectionToPointer(currentView, selection, upEvent);
|
||||
return;
|
||||
}
|
||||
const pos = currentView.posAtCoords({ x: upEvent.clientX, y: upEvent.clientY });
|
||||
const pos = currentView.posAtCoords({
|
||||
x: upEvent.clientX,
|
||||
y: upEvent.clientY,
|
||||
});
|
||||
if (pos != null) {
|
||||
currentView.dispatch({ selection: { anchor: pos }, userEvent: "select.pointer" });
|
||||
currentView.dispatch({
|
||||
selection: { anchor: pos },
|
||||
userEvent: "select.pointer",
|
||||
});
|
||||
currentView.focus();
|
||||
}
|
||||
};
|
||||
@@ -824,7 +892,11 @@ function convertSelectedSqlCase(mode: SelectionCaseMode): boolean {
|
||||
});
|
||||
|
||||
if (!transaction.changes.empty) {
|
||||
currentView.dispatch({ ...transaction, scrollIntoView: true, userEvent: "input" });
|
||||
currentView.dispatch({
|
||||
...transaction,
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
});
|
||||
focusEditor();
|
||||
return true;
|
||||
}
|
||||
@@ -844,7 +916,12 @@ async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
|
||||
try {
|
||||
source = await readTextFromClipboard();
|
||||
} catch (e: any) {
|
||||
toast(t("editor.exPasteClipboardReadFailed", { message: e?.message || String(e) }), 5000);
|
||||
toast(
|
||||
t("editor.exPasteClipboardReadFailed", {
|
||||
message: e?.message || String(e),
|
||||
}),
|
||||
5000,
|
||||
);
|
||||
focusEditor();
|
||||
return false;
|
||||
}
|
||||
@@ -875,6 +952,26 @@ async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteEmptyLines() {
|
||||
const currentView = view.value;
|
||||
if (!currentView || props.readOnly) return;
|
||||
|
||||
const state = currentView.state;
|
||||
const selection = state.selection.main;
|
||||
const changes = blankLineDeletionChanges(state.doc, selection);
|
||||
if (changes.length === 0) return;
|
||||
|
||||
currentView.dispatch({
|
||||
changes,
|
||||
scrollIntoView: true,
|
||||
});
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
function openFindReplaceFromContextMenu() {
|
||||
openSearch();
|
||||
}
|
||||
|
||||
function emitContextObjectAction(action: QueryContextObjectAction) {
|
||||
if (!contextObjectTarget.value) return;
|
||||
const route = queryContextObjectRoute(action, contextObjectTarget.value);
|
||||
@@ -899,15 +996,40 @@ function contextObjectMenuItem(action: QueryContextObjectAction): ContextMenuIte
|
||||
const disabled = !contextObjectTarget.value;
|
||||
switch (action) {
|
||||
case "view-data":
|
||||
return { label: t("contextMenu.viewData"), action: () => emitContextObjectAction(action), disabled, icon: Table2 };
|
||||
return {
|
||||
label: t("contextMenu.viewData"),
|
||||
action: () => emitContextObjectAction(action),
|
||||
disabled,
|
||||
icon: Table2,
|
||||
};
|
||||
case "edit-table-structure":
|
||||
return { label: t("contextMenu.editStructure"), action: () => emitContextObjectAction(action), disabled, icon: PencilRuler };
|
||||
return {
|
||||
label: t("contextMenu.editStructure"),
|
||||
action: () => emitContextObjectAction(action),
|
||||
disabled,
|
||||
icon: PencilRuler,
|
||||
};
|
||||
case "edit-view":
|
||||
return { label: t("contextMenu.editView"), action: () => emitContextObjectAction(action), disabled, icon: Pencil };
|
||||
return {
|
||||
label: t("contextMenu.editView"),
|
||||
action: () => emitContextObjectAction(action),
|
||||
disabled,
|
||||
icon: Pencil,
|
||||
};
|
||||
case "view-source":
|
||||
return { label: t("contextMenu.viewSource"), action: () => emitContextObjectAction(action), disabled, icon: Code2 };
|
||||
return {
|
||||
label: t("contextMenu.viewSource"),
|
||||
action: () => emitContextObjectAction(action),
|
||||
disabled,
|
||||
icon: Code2,
|
||||
};
|
||||
case "view-ddl":
|
||||
return { label: t("contextMenu.viewDdl"), action: () => emitContextObjectAction(action), disabled, icon: FileCode };
|
||||
return {
|
||||
label: t("contextMenu.viewDdl"),
|
||||
action: () => emitContextObjectAction(action),
|
||||
disabled,
|
||||
icon: FileCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,7 +1115,32 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
icon: CaseLower,
|
||||
shortcut: shortcuts.lowercaseSelection,
|
||||
},
|
||||
{ label: t("editor.contextMenu.selectAll"), action: selectAllSqlFromContextMenu, icon: TextSelect, shortcut: shortcuts.selectAll },
|
||||
{
|
||||
label: t("editor.contextMenu.delimitedList"),
|
||||
action: openDelimitedListDialog,
|
||||
disabled: props.readOnly || !canCopySelectedSql.value,
|
||||
icon: List,
|
||||
},
|
||||
{ label: "", separator: true },
|
||||
{
|
||||
label: t("editor.contextMenu.findReplace"),
|
||||
action: openFindReplaceFromContextMenu,
|
||||
icon: Search,
|
||||
shortcut: shortcuts.find,
|
||||
},
|
||||
{
|
||||
label: t("editor.contextMenu.deleteEmptyLines"),
|
||||
action: deleteEmptyLines,
|
||||
disabled: props.readOnly,
|
||||
icon: Trash2,
|
||||
},
|
||||
{ label: "", separator: true },
|
||||
{
|
||||
label: t("editor.contextMenu.selectAll"),
|
||||
action: selectAllSqlFromContextMenu,
|
||||
icon: TextSelect,
|
||||
shortcut: shortcuts.selectAll,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -1384,7 +1531,12 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
||||
const parts = splitQualifiedIdentifier(identifier);
|
||||
const name = parts[parts.length - 1] ?? identifier;
|
||||
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(sql, pos, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect }) : null;
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
|
||||
? buildSqlSemanticModel(sql, pos, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: props.syntaxDialect ?? props.dialect,
|
||||
})
|
||||
: null;
|
||||
const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
|
||||
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
|
||||
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
|
||||
@@ -1463,7 +1615,10 @@ function sqlErrorDecorationRange(currentState: import("@codemirror/state").Edito
|
||||
|
||||
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 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),
|
||||
@@ -1477,9 +1632,24 @@ function sqlSemanticDecorationRanges(currentState: import("@codemirror/state").E
|
||||
return semanticDiagnostics
|
||||
.map((diagnostic) => {
|
||||
const range = sqlTextSpanToRange(sql, diagnostic.span);
|
||||
return range ? { ...range, message: diagnostic.message, severity: diagnostic.severity } : null;
|
||||
return range
|
||||
? {
|
||||
...range,
|
||||
message: diagnostic.message,
|
||||
severity: diagnostic.severity,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((range): range is { from: number; to: number; message: string; severity: "error" | "warning" } => !!range);
|
||||
.filter(
|
||||
(
|
||||
range,
|
||||
): range is {
|
||||
from: number;
|
||||
to: number;
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
} => !!range,
|
||||
);
|
||||
}
|
||||
|
||||
function reconfigureDiagnostics() {
|
||||
@@ -1593,7 +1763,9 @@ async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[]):
|
||||
const targets: SqlTableReference[] = [];
|
||||
for (const table of tables) {
|
||||
if (isStatementLocalSemanticTable(table)) continue;
|
||||
const tableWithInlineColumns = table as SqlTableReference & { columns?: string[] };
|
||||
const tableWithInlineColumns = table as SqlTableReference & {
|
||||
columns?: string[];
|
||||
};
|
||||
if (tableWithInlineColumns.columns && tableWithInlineColumns.columns.length > 0) continue;
|
||||
const cacheKey = completionCacheKey(table);
|
||||
if (cachedColumnsByTable.has(cacheKey)) continue;
|
||||
@@ -1642,7 +1814,9 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
if (props.databaseType === "redis") {
|
||||
// Redis has no SQL semantics; run command-name / arity / quote / danger checks instead.
|
||||
if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) {
|
||||
scheduleSemanticDiagnostics(900, { preserveOutsideRanges: options.preserveOutsideRanges });
|
||||
scheduleSemanticDiagnostics(900, {
|
||||
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
|
||||
@@ -1653,11 +1827,15 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
return;
|
||||
}
|
||||
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
||||
scheduleSemanticDiagnostics(1200, { preserveOutsideRanges: options.preserveOutsideRanges });
|
||||
scheduleSemanticDiagnostics(1200, {
|
||||
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
||||
scheduleSemanticDiagnostics(900, { preserveOutsideRanges: options.preserveOutsideRanges });
|
||||
scheduleSemanticDiagnostics(900, {
|
||||
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1675,14 +1853,22 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
if (runId !== semanticDiagnosticRunId) return;
|
||||
|
||||
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(range.sql, semanticCursor, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect }) : null;
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
|
||||
? buildSqlSemanticModel(range.sql, semanticCursor, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: props.syntaxDialect ?? props.dialect,
|
||||
})
|
||||
: null;
|
||||
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
|
||||
const { tables, missingTables } = await enrichSemanticDiagnosticTables(semanticAnalysis.tables);
|
||||
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables);
|
||||
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
|
||||
if (runId !== semanticDiagnosticRunId) return;
|
||||
|
||||
const enrichedAnalysis: SqlReferenceAnalysis = { ...semanticAnalysis, tables };
|
||||
const enrichedAnalysis: SqlReferenceAnalysis = {
|
||||
...semanticAnalysis,
|
||||
tables,
|
||||
};
|
||||
nextDiagnostics.push(
|
||||
...offsetSqlSemanticDiagnostics(
|
||||
buildSqlSemanticDiagnostics(enrichedAnalysis, {
|
||||
@@ -1786,7 +1972,10 @@ function insertDroppedTableReference(currentView: EditorViewType, event: DragEve
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return insertTableReferencePayload(currentView, payload, { clientX: event.clientX, clientY: event.clientY });
|
||||
return insertTableReferencePayload(currentView, payload, {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
}
|
||||
|
||||
function onTableReferenceDropEvent(event: Event) {
|
||||
@@ -1960,7 +2149,9 @@ async function provideRedisCompletions(currentState: import("@codemirror/state")
|
||||
}
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const items = buildRedisCompletionItemsFromContext(completionContext, { keys });
|
||||
const items = buildRedisCompletionItemsFromContext(completionContext, {
|
||||
keys,
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
// Use the built-in filter (the default) so typing narrows the list and moves
|
||||
// the selection synchronously. `filter: false` + `validFor` are mutually
|
||||
@@ -2001,7 +2192,10 @@ async function provideMongoCompletions(currentState: import("@codemirror/state")
|
||||
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const items = buildMongoCompletionItemsFromContext(completionContext, { collections, fields });
|
||||
const items = buildMongoCompletionItemsFromContext(completionContext, {
|
||||
collections,
|
||||
fields,
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
return {
|
||||
from: completionContext.from,
|
||||
@@ -2038,7 +2232,12 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
|
||||
|
||||
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position);
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect }) : null;
|
||||
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
|
||||
? buildSqlSemanticModel(fullDoc, position, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: props.syntaxDialect ?? props.dialect,
|
||||
})
|
||||
: null;
|
||||
const completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
|
||||
|
||||
if (!hasDatabase) {
|
||||
@@ -2223,7 +2422,11 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
|
||||
if (cteDef) {
|
||||
columnsByTable.set(
|
||||
refTable.name,
|
||||
cteDef.columns.map((name) => ({ name, table: refTable.name, dataType: undefined })),
|
||||
cteDef.columns.map((name) => ({
|
||||
name,
|
||||
table: refTable.name,
|
||||
dataType: undefined,
|
||||
})),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -2361,7 +2564,11 @@ function mergeCompletionTables(existing: SqlCompletionTable[], incoming: SqlComp
|
||||
} else {
|
||||
const existing = merged[index];
|
||||
// Preserve the more specific tree type if an older metadata endpoint reports a materialized view as VIEW.
|
||||
merged[index] = { ...existing, ...table, type: mergeSqlObjectNavigationType(existing.type, table.type) };
|
||||
merged[index] = {
|
||||
...existing,
|
||||
...table,
|
||||
type: mergeSqlObjectNavigationType(existing.type, table.type),
|
||||
};
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
@@ -2693,7 +2900,11 @@ onMounted(async () => {
|
||||
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, ensureSyntaxTree },
|
||||
{ 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");
|
||||
editorViewModule = {
|
||||
EditorView,
|
||||
keymap,
|
||||
rectangularSelection,
|
||||
} as typeof import("@codemirror/view");
|
||||
codeMirrorPrec = Prec;
|
||||
codeMirrorEditorSelection = EditorSelection;
|
||||
codeMirrorSnippetCompletion = snippetCompletion;
|
||||
@@ -2779,7 +2990,10 @@ onMounted(async () => {
|
||||
return [field, diagnosticTheme];
|
||||
};
|
||||
|
||||
setPreviewRangeEffect = StateEffect.define<{ from: number; to: number } | null>();
|
||||
setPreviewRangeEffect = StateEffect.define<{
|
||||
from: number;
|
||||
to: number;
|
||||
} | null>();
|
||||
buildPreviewRangeExtension = () => {
|
||||
const effectType = setPreviewRangeEffect!;
|
||||
const field = StateField.define({
|
||||
@@ -2807,7 +3021,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
const resultSourceLineNumberMarker = new ResultSourceLineNumberMarker();
|
||||
setResultSourceRangeEffect = StateEffect.define<{ from: number; to: number } | null>();
|
||||
setResultSourceRangeEffect = StateEffect.define<{
|
||||
from: number;
|
||||
to: number;
|
||||
} | null>();
|
||||
buildResultSourceRangeExtension = () => {
|
||||
const effectType = setResultSourceRangeEffect!;
|
||||
const markersForRange = (state: import("@codemirror/state").EditorState, range: { from: number; to: number }) => {
|
||||
@@ -2934,7 +3151,10 @@ onMounted(async () => {
|
||||
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
|
||||
});
|
||||
|
||||
buildSqlLanguageExtension = () => langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType) });
|
||||
buildSqlLanguageExtension = () =>
|
||||
langSql.sql({
|
||||
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType),
|
||||
});
|
||||
buildSqlSemanticHighlightExtension = () => [
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
@@ -2956,7 +3176,12 @@ onMounted(async () => {
|
||||
}
|
||||
const tree = ensureSyntaxTree(currentView.state, windows[windows.length - 1]?.to ?? 0, 25);
|
||||
if (!tree) return Decoration.set([]);
|
||||
const ranges = windows.flatMap((window) => sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect }));
|
||||
const ranges = windows.flatMap((window) =>
|
||||
sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: props.syntaxDialect ?? props.dialect,
|
||||
}),
|
||||
);
|
||||
return Decoration.set(
|
||||
ranges.map((range) =>
|
||||
Decoration.mark({
|
||||
@@ -3044,7 +3269,14 @@ onMounted(async () => {
|
||||
const classes = ["cm-db-current-statement-line"];
|
||||
if (lineNumber === startLine.number) classes.push("cm-db-current-statement-line--first");
|
||||
if (lineNumber === endLine.number) classes.push("cm-db-current-statement-line--last");
|
||||
deco.push(Decoration.line({ class: classes.join(" "), attributes: { style: `--dbx-current-statement-frame-width: ${frameWidth};` } }).range(line.from));
|
||||
deco.push(
|
||||
Decoration.line({
|
||||
class: classes.join(" "),
|
||||
attributes: {
|
||||
style: `--dbx-current-statement-frame-width: ${frameWidth};`,
|
||||
},
|
||||
}).range(line.from),
|
||||
);
|
||||
}
|
||||
return Decoration.set(deco);
|
||||
}
|
||||
@@ -3137,6 +3369,26 @@ onMounted(async () => {
|
||||
codeMirrorTheme.of(theme),
|
||||
closeBracketsComp.of(closeBracketsExtension(initialSettings.autoCloseBrackets)),
|
||||
bracketMatching(),
|
||||
// Fix: intercept quote characters to prevent closeBrackets from
|
||||
// producing triple quotes ('''). When the cursor is immediately
|
||||
// before an auto-inserted closing quote, just skip past it.
|
||||
Prec.highest(
|
||||
EditorView.inputHandler.of((view: EditorViewType, _from: number, _to: number, text: string) => {
|
||||
if (text !== "'" && text !== '"' && text !== "`") return false;
|
||||
const pos = view.state.selection.main.head;
|
||||
const nextChar = view.state.doc.sliceString(pos, pos + 1);
|
||||
if (nextChar !== text) return false;
|
||||
// Only skip when the character ahead matches and it was auto-inserted
|
||||
// (i.e. the doc has a matching pair at this position).
|
||||
const prevChar = pos > 0 ? view.state.doc.sliceString(pos - 1, pos) : "";
|
||||
if (prevChar === text) return false; // already inside a quoted region
|
||||
view.dispatch({
|
||||
selection: { anchor: pos + 1 },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
|
||||
buildSqlSignatureExtension(),
|
||||
diagnosticComp.of(buildSqlDiagnosticExtension()),
|
||||
@@ -3166,7 +3418,9 @@ onMounted(async () => {
|
||||
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 }),
|
||||
rectangularSelection({
|
||||
eventFilter: startsQueryEditorRectangularSelection,
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
searchPanelRef.value?.scheduleDocumentSearchUpdate();
|
||||
@@ -3344,7 +3598,11 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedCols: Array<{ name: string; table: string; schema?: string }> = [];
|
||||
const matchedCols: Array<{
|
||||
name: string;
|
||||
table: string;
|
||||
schema?: string;
|
||||
}> = [];
|
||||
|
||||
for (const refTable of tablesToCheck) {
|
||||
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
||||
@@ -3385,7 +3643,9 @@ onMounted(async () => {
|
||||
|
||||
view.value = new EditorView({ state, parent: editorRef.value });
|
||||
registerEditorScrollbarPointerGuard(view.value);
|
||||
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, { passive: true });
|
||||
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
|
||||
passive: true,
|
||||
});
|
||||
restoreEditorViewport();
|
||||
syncContextMenuState(view.value);
|
||||
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
|
||||
@@ -3447,7 +3707,9 @@ watch(
|
||||
() => props.statementExecutionMarkers ?? [],
|
||||
(markers) => {
|
||||
if (!view.value || !setStatementExecutionMarkersEffect) return;
|
||||
view.value.dispatch({ effects: setStatementExecutionMarkersEffect.of(markers) });
|
||||
view.value.dispatch({
|
||||
effects: setStatementExecutionMarkersEffect.of(markers),
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
@@ -3482,7 +3744,9 @@ watch(
|
||||
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect], () => {
|
||||
executableStatementRangeCache = null;
|
||||
if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension) return;
|
||||
view.value.dispatch({ effects: [sqlLanguageComp.reconfigure(buildSqlLanguageExtension()), sqlSemanticHighlightComp.reconfigure(buildSqlSemanticHighlightExtension())] });
|
||||
view.value.dispatch({
|
||||
effects: [sqlLanguageComp.reconfigure(buildSqlLanguageExtension()), sqlSemanticHighlightComp.reconfigure(buildSqlSemanticHighlightExtension())],
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -3743,7 +4007,15 @@ function scrollCursorIntoView() {
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition, previewStatementRange, refreshCompletionCache });
|
||||
defineExpose({
|
||||
openSearch,
|
||||
openReplace,
|
||||
scrollCursorIntoView,
|
||||
requestExecute,
|
||||
pasteClipboardAsSqlInCondition,
|
||||
previewStatementRange,
|
||||
refreshCompletionCache,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -3763,6 +4035,7 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pa
|
||||
</CustomContextMenu>
|
||||
<EditorSearchPanel ref="searchPanelRef" :view="view" />
|
||||
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
|
||||
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in new issue
Block a user