feat(editor): add query editing utilities

This commit is contained in:
JeaceLiu authored and GitHub committed 2026-07-17 16:32:50 +08:00
1 parent d689d78ab0
commit 782615586c
15 files changed
+989 -59

No files matched your search

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { List, Copy } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
selectedText: string;
}>();
const emit = defineEmits<{
confirm: [result: string];
}>();
const STORAGE_KEY = "dbx:delimited-list-settings";
interface DelimitedListSettings {
columnDelimiter: string;
resultDelimiter: string;
quoteChar: string;
wrapColumn: number;
prefixText: string;
suffixText: string;
}
const DEFAULT_SETTINGS: DelimitedListSettings = {
columnDelimiter: "\\n",
resultDelimiter: ",",
quoteChar: "'",
wrapColumn: 80,
prefixText: "",
suffixText: "",
};
function loadSettings(): DelimitedListSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
/* ignore */
}
return { ...DEFAULT_SETTINGS };
}
const saved = loadSettings();
const columnDelimiter = ref(saved.columnDelimiter);
const resultDelimiter = ref(saved.resultDelimiter);
const quoteChar = ref(saved.quoteChar);
const wrapColumn = ref(saved.wrapColumn);
const prefixText = ref(saved.prefixText);
const suffixText = ref(saved.suffixText);
function saveSettings() {
try {
const settings: DelimitedListSettings = {
columnDelimiter: columnDelimiter.value,
resultDelimiter: resultDelimiter.value,
quoteChar: quoteChar.value,
wrapColumn: wrapColumn.value,
prefixText: prefixText.value,
suffixText: suffixText.value,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
/* ignore */
}
}
// Persist settings whenever any value changes
watch([columnDelimiter, resultDelimiter, quoteChar, wrapColumn, prefixText, suffixText], saveSettings);
function unescapeDelimiter(raw: string): string {
return raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
}
const preview = computed(() => {
if (!props.selectedText.trim()) return "";
return buildDelimitedList(props.selectedText);
});
function buildDelimitedList(text: string): string {
const colDelim = unescapeDelimiter(columnDelimiter.value);
const resDelim = resultDelimiter.value;
const quote = quoteChar.value;
const maxCol = wrapColumn.value;
const prefix = prefixText.value;
const suffix = suffixText.value;
// Split the input text by column delimiter
const items = text
.split(colDelim)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (items.length === 0) return "";
// Wrap each item with quote and prefix/suffix
const wrapped = items.map((item) => `${quote}${prefix}${item}${suffix}${quote}`);
// Build lines respecting wrapColumn limit
const lines: string[] = [];
let currentLine = "";
for (let i = 0; i < wrapped.length; i++) {
const piece = wrapped[i];
const separator = i > 0 ? resDelim : "";
if (i === 0) {
currentLine = piece;
} else if (currentLine.length + separator.length + piece.length > maxCol && currentLine.length > 0) {
// Start a new line beginning with the result delimiter
lines.push(currentLine + resDelim);
currentLine = piece;
} else {
currentLine += separator + piece;
}
}
if (currentLine.length > 0) {
lines.push(currentLine);
}
return lines.join("\n");
}
function confirm() {
const result = buildDelimitedList(props.selectedText);
open.value = false;
emit("confirm", result);
}
async function copyPreview() {
try {
await copyToClipboard(preview.value);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[620px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<List class="h-5 w-5 text-primary" />
{{ t("editor.delimitedList.title") }}
</DialogTitle>
</DialogHeader>
<div class="grid gap-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-1.5">
<Label class="text-xs">{{ t("editor.delimitedList.columnDelimiter") }}</Label>
<Input v-model="columnDelimiter" class="h-8 font-mono text-xs" />
</div>
<div class="grid gap-1.5">
<Label class="text-xs">{{ t("editor.delimitedList.resultDelimiter") }}</Label>
<Input v-model="resultDelimiter" class="h-8 font-mono text-xs" />
</div>
<div class="grid gap-1.5">
<Label class="text-xs">{{ t("editor.delimitedList.quoteChar") }}</Label>
<Input v-model="quoteChar" class="h-8 font-mono text-xs" />
</div>
<div class="grid gap-1.5">
<Label class="text-xs">{{ t("editor.delimitedList.wrapColumn") }}</Label>
<Input v-model.number="wrapColumn" type="number" class="h-8 font-mono text-xs" min="1" />
</div>
<div class="grid gap-1.5">
<Label class="text-xs">{{ t("editor.delimitedList.prefixText") }}</Label>
<Input v-model="prefixText" class="h-8 font-mono text-xs" />
</div>
<div class="grid gap-1.5">
<Label class="text-xs">{{ t("editor.delimitedList.suffixText") }}</Label>
<Input v-model="suffixText" class="h-8 font-mono text-xs" />
</div>
</div>
<div class="grid gap-1.5">
<Label class="text-xs">{{ t("editor.delimitedList.preview") }}</Label>
<pre class="max-h-48 min-w-0 overflow-auto rounded-md bg-muted px-3 py-2 text-xs font-mono whitespace-pre">{{ preview || t("editor.delimitedList.emptyPreview") }}</pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="outline" @click="copyPreview" :disabled="!preview">
<Copy class="mr-1.5 h-4 w-4" />
{{ t("grid.copy") }}
</Button>
<Button @click="confirm" :disabled="!preview">{{ t("editor.delimitedList.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -5,7 +5,7 @@ import type { EditorView } from "@codemirror/view";
import { EditorSelection } from "@codemirror/state";
import { setSearchQuery, openSearchPanel as cmOpenSearchPanel, findNext as cmFindNext, findPrevious as cmFindPrevious, replaceNext as cmReplaceNext, replaceAll as cmReplaceAll } from "@codemirror/search";
import { ChevronUp, ChevronDown, ChevronRight, X } from "@lucide/vue";
import { createEditorSearchQuery } from "@/lib/editor/editorSearchQuery";
import { collectEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches } from "@/lib/editor/editorSearchQuery";
const props = defineProps<{
view: EditorView | null;
@@ -26,6 +26,11 @@ const searchInputRef = ref<HTMLInputElement>();
const replaceInputRef = ref<HTMLInputElement>();
const matchCountLimited = ref(false);
// Scoped search: restrict find/replace to the original selection range
let searchScopeFrom: number | null = null;
let searchScopeTo: number | null = null;
const inSelectionScope = ref(false);
const SEARCH_UPDATE_DELAY_MS = 120;
const DOCUMENT_SEARCH_UPDATE_DELAY_MS = 500;
const MATCH_COUNT_LIMIT = 1000;
@@ -57,13 +62,83 @@ function clearSearchQuery() {
const selection = v.state.selection.main;
v.dispatch({
selection: EditorSelection.single(selection.head),
effects: setSearchQuery.of(createEditorSearchQuery({ search: "", caseSensitive: false, useRegex: false })),
effects: setSearchQuery.of(
createEditorSearchQuery({
search: "",
caseSensitive: false,
useRegex: false,
}),
),
});
matchCount.value = 0;
currentMatchIndex.value = 0;
matchCountLimited.value = false;
}
/**
* Get replacement text for a regex match, supporting capture groups.
*/
function computeReplacementForMatch(v: EditorView, matchFrom: number, matchTo: number): string {
if (!useRegex.value) return replaceText.value;
const matchedText = v.state.sliceDoc(matchFrom, matchTo);
try {
const re = new RegExp(searchText.value, caseSensitive.value ? "" : "i");
const m = re.exec(matchedText);
if (!m) return replaceText.value;
return replaceText.value.replace(/\$(\d+|&)/g, (_, ref) => {
if (ref === "&") return m[0];
const idx = parseInt(ref, 10);
return idx < m.length ? (m[idx] ?? "") : `$${ref}`;
});
} catch {
return replaceText.value;
}
}
/**
* Collect all search matches within the scoped range.
*/
function collectScopedMatches(v: EditorView, limit = MATCH_COUNT_LIMIT) {
if (searchScopeFrom == null || searchScopeTo == null) return null;
const q = createEditorSearchQuery({
search: searchText.value,
caseSensitive: caseSensitive.value,
useRegex: useRegex.value,
});
if (!q.valid) return null;
return collectEditorSearchMatches(q, v.state, searchScopeFrom, searchScopeTo, limit);
}
/**
* Find next/previous match within the scoped range.
* Returns true if a match was found.
*/
function findInScope(direction: "next" | "prev"): boolean {
const v = props.view;
if (!v || !searchText.value || searchScopeFrom == null || searchScopeTo == null) return false;
const matches = collectScopedMatches(v);
if (!matches || matches.length === 0) return false;
const cursor = v.state.selection.main.head;
let target: { from: number; to: number } | null = null;
if (direction === "next") {
target = matches.find((m) => m.from >= cursor) ?? matches.find((m) => m.from >= searchScopeFrom!) ?? null;
} else {
const before = matches.filter((m) => m.to <= cursor);
target = before.length > 0 ? before[before.length - 1] : matches[matches.length - 1];
}
if (target) {
v.dispatch({
selection: EditorSelection.range(target.from, target.to),
scrollIntoView: true,
});
return true;
}
return false;
}
function updateMatchInfo(autoSelect = false) {
const v = props.view;
if (!v || !searchText.value) {
@@ -73,6 +148,27 @@ function updateMatchInfo(autoSelect = false) {
return;
}
try {
// Scoped: use custom find logic
if (searchScopeFrom != null && searchScopeTo != null) {
if (autoSelect) findInScope("next");
const matches = collectScopedMatches(v);
if (!matches) {
matchCount.value = 0;
currentMatchIndex.value = 0;
matchCountLimited.value = false;
return;
}
const count = matches.length;
matchCount.value = count;
matchCountLimited.value = count >= MATCH_COUNT_LIMIT;
const selFrom = v.state.selection.main.from;
const selTo = v.state.selection.main.to;
const idx = matches.findIndex((m) => m.from === selFrom && m.to === selTo);
currentMatchIndex.value = idx >= 0 ? idx + 1 : count > 0 ? 1 : 0;
return;
}
// Full document: use CodeMirror built-in
const q = createEditorSearchQuery({
search: searchText.value,
caseSensitive: caseSensitive.value,
@@ -140,8 +236,21 @@ function openSearch(): boolean {
const v = props.view;
if (v) {
cmOpenSearchPanel(v);
const sel = v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to);
if (sel && !sel.includes("\n")) searchText.value = sel;
const sel = v.state.selection.main;
const selText = v.state.sliceDoc(sel.from, sel.to);
if (selText && !selText.includes("\n")) {
searchText.value = selText;
}
// Set scope when there's a multi-line selection
if (!sel.empty && selText.includes("\n")) {
searchScopeFrom = sel.from;
searchScopeTo = sel.to;
inSelectionScope.value = true;
} else {
searchScopeFrom = null;
searchScopeTo = null;
inSelectionScope.value = false;
}
}
nextTick(() => {
searchInputRef.value?.focus();
@@ -166,6 +275,9 @@ function closeSearch() {
searchVisible.value = false;
showReplace.value = false;
clearDocumentSearchUpdate();
searchScopeFrom = null;
searchScopeTo = null;
inSelectionScope.value = false;
const v = props.view;
if (v) {
clearSearchQuery();
@@ -177,28 +289,85 @@ function closeSearch() {
function nextMatch() {
const v = props.view;
if (!v || !searchText.value) return;
cmFindNext(v);
if (searchScopeFrom != null) {
findInScope("next");
} else {
cmFindNext(v);
}
updateMatchInfo();
}
function prevMatch() {
const v = props.view;
if (!v || !searchText.value) return;
cmFindPrevious(v);
if (searchScopeFrom != null) {
findInScope("prev");
} else {
cmFindPrevious(v);
}
updateMatchInfo();
}
function doReplace() {
const v = props.view;
if (!v || !searchText.value) return;
cmReplaceNext(v);
updateMatchInfo();
if (searchScopeFrom != null && searchScopeTo != null) {
// Scoped replace: find and replace next match within scope
const sel = v.state.selection.main;
const q = createEditorSearchQuery({
search: searchText.value,
caseSensitive: caseSensitive.value,
useRegex: useRegex.value,
});
if (!q.valid) return;
const iter = q.getCursor(v.state);
let r = iter.next();
let target: { from: number; to: number } | null = null;
while (!r.done) {
if (r.value.from >= searchScopeFrom && r.value.to <= searchScopeTo && r.value.from >= sel.from) {
target = { from: r.value.from, to: r.value.to };
break;
}
r = iter.next();
}
if (target) {
const insertText = computeReplacementForMatch(v, target.from, target.to);
const tr = v.state.changeByRange((range) => {
if (range.from === target!.from && range.to === target!.to) {
return {
changes: { from: target!.from, to: target!.to, insert: insertText },
range: EditorSelection.range(target!.from, target!.from + insertText.length),
};
}
return { range };
});
v.dispatch(tr);
// Map scope through changes
searchScopeFrom = tr.changes.mapPos(searchScopeFrom);
searchScopeTo = tr.changes.mapPos(searchScopeTo);
} else {
// Wrap around to start of scope
findInScope("next");
}
} else {
cmReplaceNext(v);
}
updateMatchInfo(true);
}
function doReplaceAll() {
const v = props.view;
if (!v || !searchText.value) return;
cmReplaceAll(v);
if (searchScopeFrom != null && searchScopeTo != null) {
// Scoped replace all: collect matches and replace
const matches = collectScopedMatches(v, Number.POSITIVE_INFINITY);
if (!matches || matches.length === 0) return;
replaceEditorSearchMatches(v, matches, (match) => computeReplacementForMatch(v, match.from, match.to));
} else {
cmReplaceAll(v);
}
updateMatchInfo();
}
@@ -231,7 +400,12 @@ onBeforeUnmount(() => {
}
});
defineExpose({ openSearch, openReplace, closeSearch, scheduleDocumentSearchUpdate });
defineExpose({
openSearch,
openReplace,
closeSearch,
scheduleDocumentSearchUpdate,
});
</script>
<template>
@@ -272,6 +446,9 @@ defineExpose({ openSearch, openReplace, closeSearch, scheduleDocumentSearchUpdat
<span class="min-w-[3.4rem] shrink-0 text-center text-xs" :class="searchText && matchCount === 0 ? 'text-destructive' : 'text-muted-foreground'">
{{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}` : t("editor.search.noResults") }}
</span>
<span v-if="inSelectionScope" class="shrink-0 rounded bg-accent px-1 py-0.5 text-[10px] font-medium text-muted-foreground" :title="t('editor.search.inSelection')">
{{ t("editor.search.inSelection") }}
</span>
<button class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.prevMatch')" @click="prevMatch">
<ChevronUp class="h-4 w-4" />
</button>
@@ -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>