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
-1
@@ -63,4 +63,4 @@ DBX_*_x64-portable.zip
|
|||||||
.agents/skills
|
.agents/skills
|
||||||
.pnpm-store/*
|
.pnpm-store/*
|
||||||
.dbx-web
|
.dbx-web
|
||||||
_dev
|
.npmrc
|
||||||
@@ -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 { 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 { 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 { ChevronUp, ChevronDown, ChevronRight, X } from "@lucide/vue";
|
||||||
import { createEditorSearchQuery } from "@/lib/editor/editorSearchQuery";
|
import { collectEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches } from "@/lib/editor/editorSearchQuery";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
view: EditorView | null;
|
view: EditorView | null;
|
||||||
@@ -26,6 +26,11 @@ const searchInputRef = ref<HTMLInputElement>();
|
|||||||
const replaceInputRef = ref<HTMLInputElement>();
|
const replaceInputRef = ref<HTMLInputElement>();
|
||||||
const matchCountLimited = ref(false);
|
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 SEARCH_UPDATE_DELAY_MS = 120;
|
||||||
const DOCUMENT_SEARCH_UPDATE_DELAY_MS = 500;
|
const DOCUMENT_SEARCH_UPDATE_DELAY_MS = 500;
|
||||||
const MATCH_COUNT_LIMIT = 1000;
|
const MATCH_COUNT_LIMIT = 1000;
|
||||||
@@ -57,13 +62,83 @@ function clearSearchQuery() {
|
|||||||
const selection = v.state.selection.main;
|
const selection = v.state.selection.main;
|
||||||
v.dispatch({
|
v.dispatch({
|
||||||
selection: EditorSelection.single(selection.head),
|
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;
|
matchCount.value = 0;
|
||||||
currentMatchIndex.value = 0;
|
currentMatchIndex.value = 0;
|
||||||
matchCountLimited.value = false;
|
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) {
|
function updateMatchInfo(autoSelect = false) {
|
||||||
const v = props.view;
|
const v = props.view;
|
||||||
if (!v || !searchText.value) {
|
if (!v || !searchText.value) {
|
||||||
@@ -73,6 +148,27 @@ function updateMatchInfo(autoSelect = false) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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({
|
const q = createEditorSearchQuery({
|
||||||
search: searchText.value,
|
search: searchText.value,
|
||||||
caseSensitive: caseSensitive.value,
|
caseSensitive: caseSensitive.value,
|
||||||
@@ -140,8 +236,21 @@ function openSearch(): boolean {
|
|||||||
const v = props.view;
|
const v = props.view;
|
||||||
if (v) {
|
if (v) {
|
||||||
cmOpenSearchPanel(v);
|
cmOpenSearchPanel(v);
|
||||||
const sel = v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to);
|
const sel = v.state.selection.main;
|
||||||
if (sel && !sel.includes("\n")) searchText.value = sel;
|
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(() => {
|
nextTick(() => {
|
||||||
searchInputRef.value?.focus();
|
searchInputRef.value?.focus();
|
||||||
@@ -166,6 +275,9 @@ function closeSearch() {
|
|||||||
searchVisible.value = false;
|
searchVisible.value = false;
|
||||||
showReplace.value = false;
|
showReplace.value = false;
|
||||||
clearDocumentSearchUpdate();
|
clearDocumentSearchUpdate();
|
||||||
|
searchScopeFrom = null;
|
||||||
|
searchScopeTo = null;
|
||||||
|
inSelectionScope.value = false;
|
||||||
const v = props.view;
|
const v = props.view;
|
||||||
if (v) {
|
if (v) {
|
||||||
clearSearchQuery();
|
clearSearchQuery();
|
||||||
@@ -177,28 +289,85 @@ function closeSearch() {
|
|||||||
function nextMatch() {
|
function nextMatch() {
|
||||||
const v = props.view;
|
const v = props.view;
|
||||||
if (!v || !searchText.value) return;
|
if (!v || !searchText.value) return;
|
||||||
cmFindNext(v);
|
if (searchScopeFrom != null) {
|
||||||
|
findInScope("next");
|
||||||
|
} else {
|
||||||
|
cmFindNext(v);
|
||||||
|
}
|
||||||
updateMatchInfo();
|
updateMatchInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevMatch() {
|
function prevMatch() {
|
||||||
const v = props.view;
|
const v = props.view;
|
||||||
if (!v || !searchText.value) return;
|
if (!v || !searchText.value) return;
|
||||||
cmFindPrevious(v);
|
if (searchScopeFrom != null) {
|
||||||
|
findInScope("prev");
|
||||||
|
} else {
|
||||||
|
cmFindPrevious(v);
|
||||||
|
}
|
||||||
updateMatchInfo();
|
updateMatchInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function doReplace() {
|
function doReplace() {
|
||||||
const v = props.view;
|
const v = props.view;
|
||||||
if (!v || !searchText.value) return;
|
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() {
|
function doReplaceAll() {
|
||||||
const v = props.view;
|
const v = props.view;
|
||||||
if (!v || !searchText.value) return;
|
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();
|
updateMatchInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +400,12 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ openSearch, openReplace, closeSearch, scheduleDocumentSearchUpdate });
|
defineExpose({
|
||||||
|
openSearch,
|
||||||
|
openReplace,
|
||||||
|
closeSearch,
|
||||||
|
scheduleDocumentSearchUpdate,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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'">
|
<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") }}
|
{{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}` : t("editor.search.noResults") }}
|
||||||
</span>
|
</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">
|
<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" />
|
<ChevronUp class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
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 { useI18n } from "vue-i18n";
|
||||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||||
import { search as cmSearch } from "@codemirror/search";
|
import { search as cmSearch } from "@codemirror/search";
|
||||||
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
||||||
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
|
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
|
||||||
|
import DelimitedListDialog from "./DelimitedListDialog.vue";
|
||||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||||
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||||
import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
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 { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||||
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
|
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
|
||||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||||
|
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
|
||||||
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
|
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
|
||||||
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
|
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
|
||||||
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
|
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
|
||||||
@@ -203,6 +205,28 @@ const pickerCandidates = ref<SqlExecutionCandidate[]>([]);
|
|||||||
const pickerActiveIndex = ref(0);
|
const pickerActiveIndex = ref(0);
|
||||||
const pickerAnchor = ref<{ left: number; top: number }>();
|
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"));
|
const executeContextMenuLabel = computed(() => t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"));
|
||||||
|
|
||||||
interface EditorGestureEvent extends Event {
|
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 codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null;
|
||||||
let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null;
|
let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null;
|
||||||
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | 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 setPreviewRangeEffect:
|
||||||
let setResultSourceRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
|
| 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 setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectType<StatementExecutionMarker[]> | null = null;
|
||||||
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
|
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
|
||||||
let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | 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 line = state.doc.lineAt(sel.from);
|
||||||
const before = line.text.slice(0, sel.from - line.from);
|
const before = line.text.slice(0, sel.from - line.from);
|
||||||
if (/^\s*$/.test(before)) return codeMirrorIndentMore?.(view) ?? false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,12 +604,32 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
|
|||||||
|
|
||||||
const sql = currentView.state.doc.toString();
|
const sql = currentView.state.doc.toString();
|
||||||
if (!props.connectionId || props.database == null) {
|
if (!props.connectionId || props.database == null) {
|
||||||
const candidate = queryTableCandidateAtSqlPosition({ connectionId: "", database: props.database ?? "", schema: props.schema, databaseType: props.databaseType, sql, position: pos });
|
const candidate = queryTableCandidateAtSqlPosition({
|
||||||
contextObjectTarget.value = candidate ? { name: candidate.tableName, database: candidate.database, schema: candidate.schema } : null;
|
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;
|
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) {
|
if (!parsedCandidate) {
|
||||||
contextObjectTarget.value = null;
|
contextObjectTarget.value = null;
|
||||||
return;
|
return;
|
||||||
@@ -648,7 +706,11 @@ function selectedRangeAtPointer(currentView: EditorViewType, event: MouseEvent)
|
|||||||
if (range.empty) return null;
|
if (range.empty) return null;
|
||||||
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
|
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
|
||||||
if (pos == null || pos < range.from || pos > range.to) return null;
|
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) {
|
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);
|
moveOrCopySelectionToPointer(currentView, selection, upEvent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pos = currentView.posAtCoords({ x: upEvent.clientX, y: upEvent.clientY });
|
const pos = currentView.posAtCoords({
|
||||||
|
x: upEvent.clientX,
|
||||||
|
y: upEvent.clientY,
|
||||||
|
});
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
currentView.dispatch({ selection: { anchor: pos }, userEvent: "select.pointer" });
|
currentView.dispatch({
|
||||||
|
selection: { anchor: pos },
|
||||||
|
userEvent: "select.pointer",
|
||||||
|
});
|
||||||
currentView.focus();
|
currentView.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -824,7 +892,11 @@ function convertSelectedSqlCase(mode: SelectionCaseMode): boolean {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!transaction.changes.empty) {
|
if (!transaction.changes.empty) {
|
||||||
currentView.dispatch({ ...transaction, scrollIntoView: true, userEvent: "input" });
|
currentView.dispatch({
|
||||||
|
...transaction,
|
||||||
|
scrollIntoView: true,
|
||||||
|
userEvent: "input",
|
||||||
|
});
|
||||||
focusEditor();
|
focusEditor();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -844,7 +916,12 @@ async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
source = await readTextFromClipboard();
|
source = await readTextFromClipboard();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(t("editor.exPasteClipboardReadFailed", { message: e?.message || String(e) }), 5000);
|
toast(
|
||||||
|
t("editor.exPasteClipboardReadFailed", {
|
||||||
|
message: e?.message || String(e),
|
||||||
|
}),
|
||||||
|
5000,
|
||||||
|
);
|
||||||
focusEditor();
|
focusEditor();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -875,6 +952,26 @@ async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
|
|||||||
return true;
|
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) {
|
function emitContextObjectAction(action: QueryContextObjectAction) {
|
||||||
if (!contextObjectTarget.value) return;
|
if (!contextObjectTarget.value) return;
|
||||||
const route = queryContextObjectRoute(action, contextObjectTarget.value);
|
const route = queryContextObjectRoute(action, contextObjectTarget.value);
|
||||||
@@ -899,15 +996,40 @@ function contextObjectMenuItem(action: QueryContextObjectAction): ContextMenuIte
|
|||||||
const disabled = !contextObjectTarget.value;
|
const disabled = !contextObjectTarget.value;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "view-data":
|
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":
|
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":
|
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":
|
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":
|
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,
|
icon: CaseLower,
|
||||||
shortcut: shortcuts.lowercaseSelection,
|
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 parts = splitQualifiedIdentifier(identifier);
|
||||||
const name = parts[parts.length - 1] ?? identifier;
|
const name = parts[parts.length - 1] ?? identifier;
|
||||||
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
|
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 semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
|
||||||
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
|
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
|
||||||
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
|
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 {
|
function sqlTextSpanToRange(sql: string, span: SqlTextSpan): { from: number; to: number } | null {
|
||||||
if (!span.start_line || !span.start_column) return 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, {
|
const to = lineColumnToOffset(sql, {
|
||||||
line: Math.max(span.end_line - 1, span.start_line - 1),
|
line: Math.max(span.end_line - 1, span.start_line - 1),
|
||||||
column: Math.max(span.end_column, span.start_column),
|
column: Math.max(span.end_column, span.start_column),
|
||||||
@@ -1477,9 +1632,24 @@ function sqlSemanticDecorationRanges(currentState: import("@codemirror/state").E
|
|||||||
return semanticDiagnostics
|
return semanticDiagnostics
|
||||||
.map((diagnostic) => {
|
.map((diagnostic) => {
|
||||||
const range = sqlTextSpanToRange(sql, diagnostic.span);
|
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() {
|
function reconfigureDiagnostics() {
|
||||||
@@ -1593,7 +1763,9 @@ async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[]):
|
|||||||
const targets: SqlTableReference[] = [];
|
const targets: SqlTableReference[] = [];
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
if (isStatementLocalSemanticTable(table)) continue;
|
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;
|
if (tableWithInlineColumns.columns && tableWithInlineColumns.columns.length > 0) continue;
|
||||||
const cacheKey = completionCacheKey(table);
|
const cacheKey = completionCacheKey(table);
|
||||||
if (cachedColumnsByTable.has(cacheKey)) continue;
|
if (cachedColumnsByTable.has(cacheKey)) continue;
|
||||||
@@ -1642,7 +1814,9 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
|||||||
if (props.databaseType === "redis") {
|
if (props.databaseType === "redis") {
|
||||||
// Redis has no SQL semantics; run command-name / arity / quote / danger checks instead.
|
// Redis has no SQL semantics; run command-name / arity / quote / danger checks instead.
|
||||||
if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) {
|
if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) {
|
||||||
scheduleSemanticDiagnostics(900, { preserveOutsideRanges: options.preserveOutsideRanges });
|
scheduleSemanticDiagnostics(900, {
|
||||||
|
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
|
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
|
||||||
@@ -1653,11 +1827,15 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
||||||
scheduleSemanticDiagnostics(1200, { preserveOutsideRanges: options.preserveOutsideRanges });
|
scheduleSemanticDiagnostics(1200, {
|
||||||
|
preserveOutsideRanges: options.preserveOutsideRanges,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1675,14 +1853,22 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
|||||||
if (runId !== semanticDiagnosticRunId) return;
|
if (runId !== semanticDiagnosticRunId) return;
|
||||||
|
|
||||||
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
|
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 semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
|
||||||
const { tables, missingTables } = await enrichSemanticDiagnosticTables(semanticAnalysis.tables);
|
const { tables, missingTables } = await enrichSemanticDiagnosticTables(semanticAnalysis.tables);
|
||||||
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables);
|
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables);
|
||||||
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
|
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
|
||||||
if (runId !== semanticDiagnosticRunId) return;
|
if (runId !== semanticDiagnosticRunId) return;
|
||||||
|
|
||||||
const enrichedAnalysis: SqlReferenceAnalysis = { ...semanticAnalysis, tables };
|
const enrichedAnalysis: SqlReferenceAnalysis = {
|
||||||
|
...semanticAnalysis,
|
||||||
|
tables,
|
||||||
|
};
|
||||||
nextDiagnostics.push(
|
nextDiagnostics.push(
|
||||||
...offsetSqlSemanticDiagnostics(
|
...offsetSqlSemanticDiagnostics(
|
||||||
buildSqlSemanticDiagnostics(enrichedAnalysis, {
|
buildSqlSemanticDiagnostics(enrichedAnalysis, {
|
||||||
@@ -1786,7 +1972,10 @@ function insertDroppedTableReference(currentView: EditorViewType, event: DragEve
|
|||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
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) {
|
function onTableReferenceDropEvent(event: Event) {
|
||||||
@@ -1960,7 +2149,9 @@ async function provideRedisCompletions(currentState: import("@codemirror/state")
|
|||||||
}
|
}
|
||||||
if (epoch !== completionEpoch) return null;
|
if (epoch !== completionEpoch) return null;
|
||||||
|
|
||||||
const items = buildRedisCompletionItemsFromContext(completionContext, { keys });
|
const items = buildRedisCompletionItemsFromContext(completionContext, {
|
||||||
|
keys,
|
||||||
|
});
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
// Use the built-in filter (the default) so typing narrows the list and moves
|
// Use the built-in filter (the default) so typing narrows the list and moves
|
||||||
// the selection synchronously. `filter: false` + `validFor` are mutually
|
// the selection synchronously. `filter: false` + `validFor` are mutually
|
||||||
@@ -2001,7 +2192,10 @@ async function provideMongoCompletions(currentState: import("@codemirror/state")
|
|||||||
|
|
||||||
if (epoch !== completionEpoch) return null;
|
if (epoch !== completionEpoch) return null;
|
||||||
|
|
||||||
const items = buildMongoCompletionItemsFromContext(completionContext, { collections, fields });
|
const items = buildMongoCompletionItemsFromContext(completionContext, {
|
||||||
|
collections,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
return {
|
return {
|
||||||
from: completionContext.from,
|
from: completionContext.from,
|
||||||
@@ -2038,7 +2232,12 @@ async function provideSqlCompletions(context: CompletionContext) {
|
|||||||
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
|
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
|
||||||
|
|
||||||
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position);
|
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;
|
const completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
|
||||||
|
|
||||||
if (!hasDatabase) {
|
if (!hasDatabase) {
|
||||||
@@ -2223,7 +2422,11 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
|
|||||||
if (cteDef) {
|
if (cteDef) {
|
||||||
columnsByTable.set(
|
columnsByTable.set(
|
||||||
refTable.name,
|
refTable.name,
|
||||||
cteDef.columns.map((name) => ({ name, table: refTable.name, dataType: undefined })),
|
cteDef.columns.map((name) => ({
|
||||||
|
name,
|
||||||
|
table: refTable.name,
|
||||||
|
dataType: undefined,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2361,7 +2564,11 @@ function mergeCompletionTables(existing: SqlCompletionTable[], incoming: SqlComp
|
|||||||
} else {
|
} else {
|
||||||
const existing = merged[index];
|
const existing = merged[index];
|
||||||
// Preserve the more specific tree type if an older metadata endpoint reports a materialized view as VIEW.
|
// 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;
|
return merged;
|
||||||
@@ -2693,7 +2900,11 @@ onMounted(async () => {
|
|||||||
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, ensureSyntaxTree },
|
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, ensureSyntaxTree },
|
||||||
{ searchKeymap },
|
{ 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")]);
|
] = 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;
|
codeMirrorPrec = Prec;
|
||||||
codeMirrorEditorSelection = EditorSelection;
|
codeMirrorEditorSelection = EditorSelection;
|
||||||
codeMirrorSnippetCompletion = snippetCompletion;
|
codeMirrorSnippetCompletion = snippetCompletion;
|
||||||
@@ -2779,7 +2990,10 @@ onMounted(async () => {
|
|||||||
return [field, diagnosticTheme];
|
return [field, diagnosticTheme];
|
||||||
};
|
};
|
||||||
|
|
||||||
setPreviewRangeEffect = StateEffect.define<{ from: number; to: number } | null>();
|
setPreviewRangeEffect = StateEffect.define<{
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
} | null>();
|
||||||
buildPreviewRangeExtension = () => {
|
buildPreviewRangeExtension = () => {
|
||||||
const effectType = setPreviewRangeEffect!;
|
const effectType = setPreviewRangeEffect!;
|
||||||
const field = StateField.define({
|
const field = StateField.define({
|
||||||
@@ -2807,7 +3021,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultSourceLineNumberMarker = new ResultSourceLineNumberMarker();
|
const resultSourceLineNumberMarker = new ResultSourceLineNumberMarker();
|
||||||
setResultSourceRangeEffect = StateEffect.define<{ from: number; to: number } | null>();
|
setResultSourceRangeEffect = StateEffect.define<{
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
} | null>();
|
||||||
buildResultSourceRangeExtension = () => {
|
buildResultSourceRangeExtension = () => {
|
||||||
const effectType = setResultSourceRangeEffect!;
|
const effectType = setResultSourceRangeEffect!;
|
||||||
const markersForRange = (state: import("@codemirror/state").EditorState, range: { from: number; to: number }) => {
|
const markersForRange = (state: import("@codemirror/state").EditorState, range: { from: number; to: number }) => {
|
||||||
@@ -2934,7 +3151,10 @@ onMounted(async () => {
|
|||||||
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
|
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 = () => [
|
buildSqlSemanticHighlightExtension = () => [
|
||||||
ViewPlugin.fromClass(
|
ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
@@ -2956,7 +3176,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
const tree = ensureSyntaxTree(currentView.state, windows[windows.length - 1]?.to ?? 0, 25);
|
const tree = ensureSyntaxTree(currentView.state, windows[windows.length - 1]?.to ?? 0, 25);
|
||||||
if (!tree) return Decoration.set([]);
|
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(
|
return Decoration.set(
|
||||||
ranges.map((range) =>
|
ranges.map((range) =>
|
||||||
Decoration.mark({
|
Decoration.mark({
|
||||||
@@ -3044,7 +3269,14 @@ onMounted(async () => {
|
|||||||
const classes = ["cm-db-current-statement-line"];
|
const classes = ["cm-db-current-statement-line"];
|
||||||
if (lineNumber === startLine.number) classes.push("cm-db-current-statement-line--first");
|
if (lineNumber === startLine.number) classes.push("cm-db-current-statement-line--first");
|
||||||
if (lineNumber === endLine.number) classes.push("cm-db-current-statement-line--last");
|
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);
|
return Decoration.set(deco);
|
||||||
}
|
}
|
||||||
@@ -3137,6 +3369,26 @@ onMounted(async () => {
|
|||||||
codeMirrorTheme.of(theme),
|
codeMirrorTheme.of(theme),
|
||||||
closeBracketsComp.of(closeBracketsExtension(initialSettings.autoCloseBrackets)),
|
closeBracketsComp.of(closeBracketsExtension(initialSettings.autoCloseBrackets)),
|
||||||
bracketMatching(),
|
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)),
|
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
|
||||||
buildSqlSignatureExtension(),
|
buildSqlSignatureExtension(),
|
||||||
diagnosticComp.of(buildSqlDiagnosticExtension()),
|
diagnosticComp.of(buildSqlDiagnosticExtension()),
|
||||||
@@ -3166,7 +3418,9 @@ onMounted(async () => {
|
|||||||
indentComp.of(indentExtension()),
|
indentComp.of(indentExtension()),
|
||||||
// Alt+drag belongs exclusively to rectangular selection. Registering the
|
// Alt+drag belongs exclusively to rectangular selection. Registering the
|
||||||
// same gesture as an added cursor preserves the previous cursor.
|
// same gesture as an added cursor preserves the previous cursor.
|
||||||
rectangularSelection({ eventFilter: startsQueryEditorRectangularSelection }),
|
rectangularSelection({
|
||||||
|
eventFilter: startsQueryEditorRectangularSelection,
|
||||||
|
}),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
searchPanelRef.value?.scheduleDocumentSearchUpdate();
|
searchPanelRef.value?.scheduleDocumentSearchUpdate();
|
||||||
@@ -3344,7 +3598,11 @@ onMounted(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedCols: Array<{ name: string; table: string; schema?: string }> = [];
|
const matchedCols: Array<{
|
||||||
|
name: string;
|
||||||
|
table: string;
|
||||||
|
schema?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (const refTable of tablesToCheck) {
|
for (const refTable of tablesToCheck) {
|
||||||
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
|
||||||
@@ -3385,7 +3643,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
view.value = new EditorView({ state, parent: editorRef.value });
|
view.value = new EditorView({ state, parent: editorRef.value });
|
||||||
registerEditorScrollbarPointerGuard(view.value);
|
registerEditorScrollbarPointerGuard(view.value);
|
||||||
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, { passive: true });
|
view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
restoreEditorViewport();
|
restoreEditorViewport();
|
||||||
syncContextMenuState(view.value);
|
syncContextMenuState(view.value);
|
||||||
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
|
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
|
||||||
@@ -3447,7 +3707,9 @@ watch(
|
|||||||
() => props.statementExecutionMarkers ?? [],
|
() => props.statementExecutionMarkers ?? [],
|
||||||
(markers) => {
|
(markers) => {
|
||||||
if (!view.value || !setStatementExecutionMarkersEffect) return;
|
if (!view.value || !setStatementExecutionMarkersEffect) return;
|
||||||
view.value.dispatch({ effects: setStatementExecutionMarkersEffect.of(markers) });
|
view.value.dispatch({
|
||||||
|
effects: setStatementExecutionMarkersEffect.of(markers),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
@@ -3482,7 +3744,9 @@ watch(
|
|||||||
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect], () => {
|
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect], () => {
|
||||||
executableStatementRangeCache = null;
|
executableStatementRangeCache = null;
|
||||||
if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension) return;
|
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(
|
watch(
|
||||||
@@ -3743,7 +4007,15 @@ function scrollCursorIntoView() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition, previewStatementRange, refreshCompletionCache });
|
defineExpose({
|
||||||
|
openSearch,
|
||||||
|
openReplace,
|
||||||
|
scrollCursorIntoView,
|
||||||
|
requestExecute,
|
||||||
|
pasteClipboardAsSqlInCondition,
|
||||||
|
previewStatementRange,
|
||||||
|
refreshCompletionCache,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -3763,6 +4035,7 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pa
|
|||||||
</CustomContextMenu>
|
</CustomContextMenu>
|
||||||
<EditorSearchPanel ref="searchPanelRef" :view="view" />
|
<EditorSearchPanel ref="searchPanelRef" :view="view" />
|
||||||
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -629,8 +629,24 @@ export default {
|
|||||||
sendToAi: "Send to AI",
|
sendToAi: "Send to AI",
|
||||||
uppercaseSelection: "Convert to uppercase",
|
uppercaseSelection: "Convert to uppercase",
|
||||||
lowercaseSelection: "Convert to lowercase",
|
lowercaseSelection: "Convert to lowercase",
|
||||||
|
delimitedList: "Convert to delimited list",
|
||||||
|
findReplace: "Find/Replace",
|
||||||
|
deleteEmptyLines: "Delete empty lines",
|
||||||
selectAll: "Select all",
|
selectAll: "Select all",
|
||||||
},
|
},
|
||||||
|
delimitedList: {
|
||||||
|
title: "Convert to Delimited List",
|
||||||
|
columnDelimiter: "Column delimiter",
|
||||||
|
resultDelimiter: "Result delimiter",
|
||||||
|
quoteChar: "Quote character",
|
||||||
|
wrapColumn: "Wrap at column",
|
||||||
|
prefixText: "Prefix text",
|
||||||
|
suffixText: "Suffix text",
|
||||||
|
preview: "Preview",
|
||||||
|
emptyPreview: "Nothing to preview",
|
||||||
|
confirm: "Confirm",
|
||||||
|
selectFirst: "Please select list text",
|
||||||
|
},
|
||||||
search: {
|
search: {
|
||||||
find: "Find",
|
find: "Find",
|
||||||
replace: "Replace",
|
replace: "Replace",
|
||||||
@@ -643,6 +659,7 @@ export default {
|
|||||||
nextMatch: "Next (Enter)",
|
nextMatch: "Next (Enter)",
|
||||||
close: "Close (Esc)",
|
close: "Close (Esc)",
|
||||||
noResults: "No results",
|
noResults: "No results",
|
||||||
|
inSelection: "In selection",
|
||||||
},
|
},
|
||||||
executionPicker: {
|
executionPicker: {
|
||||||
title: "Execution Target",
|
title: "Execution Target",
|
||||||
|
|||||||
@@ -611,6 +611,9 @@ export default withEnglishFallback({
|
|||||||
uppercaseSelection: "Convertir a mayusculas",
|
uppercaseSelection: "Convertir a mayusculas",
|
||||||
lowercaseSelection: "Convertir a minusculas",
|
lowercaseSelection: "Convertir a minusculas",
|
||||||
selectAll: "Seleccionar todo",
|
selectAll: "Seleccionar todo",
|
||||||
|
delimitedList: "Convertir a lista delimitada",
|
||||||
|
findReplace: "Buscar/Reemplazar",
|
||||||
|
deleteEmptyLines: "Eliminar líneas vacías",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
find: "Buscar",
|
find: "Buscar",
|
||||||
@@ -624,6 +627,7 @@ export default withEnglishFallback({
|
|||||||
nextMatch: "Siguiente (Enter)",
|
nextMatch: "Siguiente (Enter)",
|
||||||
close: "Cerrar (Esc)",
|
close: "Cerrar (Esc)",
|
||||||
noResults: "Sin resultados",
|
noResults: "Sin resultados",
|
||||||
|
inSelection: "En la selección",
|
||||||
},
|
},
|
||||||
executionPicker: {
|
executionPicker: {
|
||||||
title: "Execution Target",
|
title: "Execution Target",
|
||||||
@@ -634,6 +638,19 @@ export default withEnglishFallback({
|
|||||||
},
|
},
|
||||||
statementExecutionSucceeded: "{count} sentencias exitosas",
|
statementExecutionSucceeded: "{count} sentencias exitosas",
|
||||||
statementExecutionFailed: "{count} sentencias fallidas",
|
statementExecutionFailed: "{count} sentencias fallidas",
|
||||||
|
delimitedList: {
|
||||||
|
title: "Convertir a lista delimitada",
|
||||||
|
columnDelimiter: "Delimitador de columna",
|
||||||
|
resultDelimiter: "Delimitador de resultado",
|
||||||
|
quoteChar: "Carácter de comillas para cadenas",
|
||||||
|
wrapColumn: "Ajustar en columna",
|
||||||
|
prefixText: "Texto de prefijo",
|
||||||
|
suffixText: "Texto de sufijo",
|
||||||
|
preview: "Vista previa",
|
||||||
|
emptyPreview: "Sin contenido para previsualizar",
|
||||||
|
confirm: "Confirmar",
|
||||||
|
selectFirst: "Seleccione el texto de la lista",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
sql: "SQL",
|
sql: "SQL",
|
||||||
|
|||||||
@@ -609,6 +609,9 @@ export default withEnglishFallback({
|
|||||||
uppercaseSelection: "Converti in maiuscolo",
|
uppercaseSelection: "Converti in maiuscolo",
|
||||||
lowercaseSelection: "Converti in minuscolo",
|
lowercaseSelection: "Converti in minuscolo",
|
||||||
selectAll: "Seleziona tutto",
|
selectAll: "Seleziona tutto",
|
||||||
|
delimitedList: "Converti in lista delimitata",
|
||||||
|
findReplace: "Trova/Sostituisci",
|
||||||
|
deleteEmptyLines: "Elimina righe vuote",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
find: "Trova",
|
find: "Trova",
|
||||||
@@ -622,6 +625,7 @@ export default withEnglishFallback({
|
|||||||
nextMatch: "Successivo (Enter)",
|
nextMatch: "Successivo (Enter)",
|
||||||
close: "Chiudi (Esc)",
|
close: "Chiudi (Esc)",
|
||||||
noResults: "Nessun risultato",
|
noResults: "Nessun risultato",
|
||||||
|
inSelection: "Nella selezione",
|
||||||
},
|
},
|
||||||
executionPicker: {
|
executionPicker: {
|
||||||
title: "Execution Target",
|
title: "Execution Target",
|
||||||
@@ -632,6 +636,19 @@ export default withEnglishFallback({
|
|||||||
},
|
},
|
||||||
statementExecutionSucceeded: "{count} istruzioni eseguite con successo",
|
statementExecutionSucceeded: "{count} istruzioni eseguite con successo",
|
||||||
statementExecutionFailed: "{count} istruzioni fallite",
|
statementExecutionFailed: "{count} istruzioni fallite",
|
||||||
|
delimitedList: {
|
||||||
|
title: "Converti in lista delimitata",
|
||||||
|
columnDelimiter: "Delimitatore di colonna",
|
||||||
|
resultDelimiter: "Delimitatore di risultato",
|
||||||
|
quoteChar: "Carattere di citazione stringa",
|
||||||
|
wrapColumn: "A capo automatico alla colonna",
|
||||||
|
prefixText: "Testo prefisso",
|
||||||
|
suffixText: "Testo suffisso",
|
||||||
|
preview: "Anteprima",
|
||||||
|
emptyPreview: "Nessun contenuto da visualizzare",
|
||||||
|
confirm: "Conferma",
|
||||||
|
selectFirst: "Seleziona il testo della lista",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
sql: "SQL",
|
sql: "SQL",
|
||||||
|
|||||||
@@ -608,6 +608,9 @@ export default withEnglishFallback({
|
|||||||
uppercaseSelection: "大文字に変換",
|
uppercaseSelection: "大文字に変換",
|
||||||
lowercaseSelection: "小文字に変換",
|
lowercaseSelection: "小文字に変換",
|
||||||
selectAll: "すべて選択",
|
selectAll: "すべて選択",
|
||||||
|
delimitedList: "区切り文字付きリストに変換",
|
||||||
|
findReplace: "検索/置換",
|
||||||
|
deleteEmptyLines: "空行を削除",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
find: "検索",
|
find: "検索",
|
||||||
@@ -621,6 +624,7 @@ export default withEnglishFallback({
|
|||||||
nextMatch: "次へ (Enter)",
|
nextMatch: "次へ (Enter)",
|
||||||
close: "閉じる (Esc)",
|
close: "閉じる (Esc)",
|
||||||
noResults: "結果なし",
|
noResults: "結果なし",
|
||||||
|
inSelection: "選択範囲内",
|
||||||
},
|
},
|
||||||
executionPicker: {
|
executionPicker: {
|
||||||
title: "Execution Target",
|
title: "Execution Target",
|
||||||
@@ -632,6 +636,19 @@ export default withEnglishFallback({
|
|||||||
selectDatabaseRequired: "先にデータベースを選択してください",
|
selectDatabaseRequired: "先にデータベースを選択してください",
|
||||||
statementExecutionSucceeded: "{count} 件のステートメントが成功しました",
|
statementExecutionSucceeded: "{count} 件のステートメントが成功しました",
|
||||||
statementExecutionFailed: "{count} 件のステートメントが失敗しました",
|
statementExecutionFailed: "{count} 件のステートメントが失敗しました",
|
||||||
|
delimitedList: {
|
||||||
|
title: "区切り文字付きリストに変換",
|
||||||
|
columnDelimiter: "列区切り文字",
|
||||||
|
resultDelimiter: "結果区切り文字",
|
||||||
|
quoteChar: "文字列引用符",
|
||||||
|
wrapColumn: "列で折り返し",
|
||||||
|
prefixText: "先頭テキスト",
|
||||||
|
suffixText: "末尾テキスト",
|
||||||
|
preview: "プレビュー",
|
||||||
|
emptyPreview: "プレビューする内容がありません",
|
||||||
|
confirm: "確定",
|
||||||
|
selectFirst: "リストテキストを選択してください",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
sql: "SQL",
|
sql: "SQL",
|
||||||
|
|||||||
@@ -610,6 +610,9 @@ export default withEnglishFallback({
|
|||||||
uppercaseSelection: "Converter para maiúsculas",
|
uppercaseSelection: "Converter para maiúsculas",
|
||||||
lowercaseSelection: "Converter para minúsculas",
|
lowercaseSelection: "Converter para minúsculas",
|
||||||
selectAll: "Selecionar tudo",
|
selectAll: "Selecionar tudo",
|
||||||
|
delimitedList: "Converter para lista delimitada",
|
||||||
|
findReplace: "Localizar/substituir",
|
||||||
|
deleteEmptyLines: "Excluir linhas vazias",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
find: "Localizar",
|
find: "Localizar",
|
||||||
@@ -623,6 +626,7 @@ export default withEnglishFallback({
|
|||||||
nextMatch: "Próximo (Enter)",
|
nextMatch: "Próximo (Enter)",
|
||||||
close: "Fechar (Esc)",
|
close: "Fechar (Esc)",
|
||||||
noResults: "Nenhum resultado",
|
noResults: "Nenhum resultado",
|
||||||
|
inSelection: "Na seleção",
|
||||||
},
|
},
|
||||||
executionPicker: {
|
executionPicker: {
|
||||||
title: "Execution Target",
|
title: "Execution Target",
|
||||||
@@ -633,6 +637,19 @@ export default withEnglishFallback({
|
|||||||
},
|
},
|
||||||
statementExecutionSucceeded: "{count} instrução(ões) executada(s) com sucesso",
|
statementExecutionSucceeded: "{count} instrução(ões) executada(s) com sucesso",
|
||||||
statementExecutionFailed: "{count} instrução(ões) com falha",
|
statementExecutionFailed: "{count} instrução(ões) com falha",
|
||||||
|
delimitedList: {
|
||||||
|
title: "Converter para lista delimitada",
|
||||||
|
columnDelimiter: "Delimitador de coluna",
|
||||||
|
resultDelimiter: "Delimitador de resultado",
|
||||||
|
quoteChar: "Caractere de aspas para strings",
|
||||||
|
wrapColumn: "Quebrar na coluna",
|
||||||
|
prefixText: "Texto de prefixo",
|
||||||
|
suffixText: "Texto de sufixo",
|
||||||
|
preview: "Visualização",
|
||||||
|
emptyPreview: "Nada para visualizar",
|
||||||
|
confirm: "Confirmar",
|
||||||
|
selectFirst: "Selecione o texto da lista primeiro",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
sql: "SQL",
|
sql: "SQL",
|
||||||
|
|||||||
@@ -631,8 +631,24 @@ export default withEnglishFallback({
|
|||||||
sendToAi: "发送到 AI",
|
sendToAi: "发送到 AI",
|
||||||
uppercaseSelection: "转为大写",
|
uppercaseSelection: "转为大写",
|
||||||
lowercaseSelection: "转为小写",
|
lowercaseSelection: "转为小写",
|
||||||
|
delimitedList: "转换为带分隔符的列表",
|
||||||
|
findReplace: "查找/替换",
|
||||||
|
deleteEmptyLines: "删除空行",
|
||||||
selectAll: "全选",
|
selectAll: "全选",
|
||||||
},
|
},
|
||||||
|
delimitedList: {
|
||||||
|
title: "转换为带分隔符的列表",
|
||||||
|
columnDelimiter: "列分隔符",
|
||||||
|
resultDelimiter: "结果分隔符",
|
||||||
|
quoteChar: "字符串引号字符",
|
||||||
|
wrapColumn: "在列处换行",
|
||||||
|
prefixText: "前导文本",
|
||||||
|
suffixText: "尾部文本",
|
||||||
|
preview: "预览",
|
||||||
|
emptyPreview: "无可预览内容",
|
||||||
|
confirm: "确定",
|
||||||
|
selectFirst: "请选择列表文本",
|
||||||
|
},
|
||||||
search: {
|
search: {
|
||||||
find: "查找",
|
find: "查找",
|
||||||
replace: "替换",
|
replace: "替换",
|
||||||
@@ -645,6 +661,7 @@ export default withEnglishFallback({
|
|||||||
nextMatch: "下一个 (Enter)",
|
nextMatch: "下一个 (Enter)",
|
||||||
close: "关闭 (Esc)",
|
close: "关闭 (Esc)",
|
||||||
noResults: "无结果",
|
noResults: "无结果",
|
||||||
|
inSelection: "选区内",
|
||||||
},
|
},
|
||||||
executionPicker: {
|
executionPicker: {
|
||||||
title: "执行目标",
|
title: "执行目标",
|
||||||
|
|||||||
@@ -610,6 +610,9 @@ export default withEnglishFallback({
|
|||||||
uppercaseSelection: "轉為大寫",
|
uppercaseSelection: "轉為大寫",
|
||||||
lowercaseSelection: "轉為小寫",
|
lowercaseSelection: "轉為小寫",
|
||||||
selectAll: "全選",
|
selectAll: "全選",
|
||||||
|
delimitedList: "轉換為帶分隔符的清單",
|
||||||
|
findReplace: "查找/取代",
|
||||||
|
deleteEmptyLines: "刪除空行",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
find: "尋找",
|
find: "尋找",
|
||||||
@@ -623,6 +626,7 @@ export default withEnglishFallback({
|
|||||||
nextMatch: "下一個 (Enter)",
|
nextMatch: "下一個 (Enter)",
|
||||||
close: "關閉 (Esc)",
|
close: "關閉 (Esc)",
|
||||||
noResults: "無結果",
|
noResults: "無結果",
|
||||||
|
inSelection: "選取區內",
|
||||||
},
|
},
|
||||||
executionPicker: {
|
executionPicker: {
|
||||||
title: "執行目標",
|
title: "執行目標",
|
||||||
@@ -633,6 +637,19 @@ export default withEnglishFallback({
|
|||||||
},
|
},
|
||||||
statementExecutionSucceeded: "{count} 條陳述式成功",
|
statementExecutionSucceeded: "{count} 條陳述式成功",
|
||||||
statementExecutionFailed: "{count} 條陳述式失敗",
|
statementExecutionFailed: "{count} 條陳述式失敗",
|
||||||
|
delimitedList: {
|
||||||
|
title: "轉換為帶分隔符的清單",
|
||||||
|
columnDelimiter: "欄分隔符",
|
||||||
|
resultDelimiter: "結果分隔符",
|
||||||
|
quoteChar: "字串引號字元",
|
||||||
|
wrapColumn: "在欄位處換行",
|
||||||
|
prefixText: "前置文字",
|
||||||
|
suffixText: "尾部文字",
|
||||||
|
preview: "預覽",
|
||||||
|
emptyPreview: "無可預覽內容",
|
||||||
|
confirm: "確定",
|
||||||
|
selectFirst: "請選擇清單文字",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
sql: "SQL",
|
sql: "SQL",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState } from "@codemirror/state";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createEditorSearchQuery } from "@/lib/editor/editorSearchQuery";
|
import { collectEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches } from "@/lib/editor/editorSearchQuery";
|
||||||
|
|
||||||
function matchedText(search: string, useRegex: boolean): string[] {
|
function matchedText(search: string, useRegex: boolean): string[] {
|
||||||
const state = EditorState.create({
|
const state = EditorState.create({
|
||||||
@@ -25,4 +25,17 @@ describe("editorSearchQuery", () => {
|
|||||||
it("allows regular expression mode to match actual line breaks", () => {
|
it("allows regular expression mode to match actual line breaks", () => {
|
||||||
expect(matchedText(String.raw`\n`, true)).toEqual(["\n"]);
|
expect(matchedText(String.raw`\n`, true)).toEqual(["\n"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("collects every scoped match when replacement is uncapped", () => {
|
||||||
|
const state = EditorState.create({ doc: Array.from({ length: 1001 }, () => "x").join(" ") });
|
||||||
|
const query = createEditorSearchQuery({ search: "x", caseSensitive: true, useRegex: false });
|
||||||
|
const matches = collectEditorSearchMatches(query, state, 0, state.doc.length);
|
||||||
|
const dispatch = vi.fn();
|
||||||
|
|
||||||
|
expect(matches).toHaveLength(1001);
|
||||||
|
expect(collectEditorSearchMatches(query, state, 0, state.doc.length, 1000)).toHaveLength(1000);
|
||||||
|
expect(replaceEditorSearchMatches({ dispatch }, matches, () => "y")).toBe(true);
|
||||||
|
expect(dispatch).toHaveBeenCalledOnce();
|
||||||
|
expect(dispatch.mock.calls[0]?.[0].changes).toHaveLength(1001);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
|
||||||
|
|
||||||
|
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
function applyBlankLineDeletion(doc: string, from: number, to: number): string {
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc,
|
||||||
|
selection: EditorSelection.range(from, to),
|
||||||
|
});
|
||||||
|
return state.update({ changes: blankLineDeletionChanges(state.doc, state.selection.main) }).state.doc.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("replaceSelectedEditorText", () => {
|
||||||
|
it("refuses to dispatch changes for a read-only editor", () => {
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: "a,b",
|
||||||
|
selection: EditorSelection.range(0, 3),
|
||||||
|
extensions: EditorState.readOnly.of(true),
|
||||||
|
});
|
||||||
|
const dispatch = vi.fn();
|
||||||
|
|
||||||
|
expect(replaceSelectedEditorText({ state, dispatch }, "'a','b'")).toBe(false);
|
||||||
|
expect(dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables and guards delimited-list generation in QueryEditor", () => {
|
||||||
|
expect(queryEditorSource).toContain("disabled: props.readOnly || !canCopySelectedSql.value");
|
||||||
|
expect(queryEditorSource).toMatch(/function openDelimitedListDialog\(\) \{\s+if \(props\.readOnly\) return;/);
|
||||||
|
expect(queryEditorSource).toMatch(/function applyDelimitedListResult\(result: string\) \{[\s\S]*if \(!currentView \|\| props\.readOnly\) return;[\s\S]*replaceSelectedEditorText/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blankLineDeletionChanges", () => {
|
||||||
|
it("preserves one newline between adjacent non-empty SQL", () => {
|
||||||
|
expect(applyBlankLineDeletion("a\n\nb", 2, 3)).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["partial-line selection", "select a\n\nselect b", 3, 12, "select a\nselect b"],
|
||||||
|
["full-line selection", "a\n \nb", 2, 5, "a\nb"],
|
||||||
|
["document start", "\n\na", 0, 2, "a"],
|
||||||
|
["document end", "a\n\n", 2, 3, "a\n"],
|
||||||
|
])("handles %s with line-boundary edits", (_name, doc, from, to, expected) => {
|
||||||
|
expect(applyBlankLineDeletion(doc, from, to)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { EditorState, TransactionSpec } from "@codemirror/state";
|
||||||
import { SearchQuery } from "@codemirror/search";
|
import { SearchQuery } from "@codemirror/search";
|
||||||
|
|
||||||
export interface EditorSearchQueryOptions {
|
export interface EditorSearchQueryOptions {
|
||||||
@@ -16,3 +17,35 @@ export function createEditorSearchQuery(options: EditorSearchQueryOptions): Sear
|
|||||||
literal: !options.useRegex,
|
literal: !options.useRegex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditorSearchMatch {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectEditorSearchMatches(query: SearchQuery, state: EditorState, from: number, to: number, limit = Number.POSITIVE_INFINITY): EditorSearchMatch[] {
|
||||||
|
const matches: EditorSearchMatch[] = [];
|
||||||
|
const cursor = query.getCursor(state);
|
||||||
|
|
||||||
|
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||||
|
if (result.value.from >= from && result.value.to <= to) {
|
||||||
|
matches.push({ from: result.value.from, to: result.value.to });
|
||||||
|
if (matches.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceEditorSearchMatches(view: { dispatch(spec: TransactionSpec): void }, matches: readonly EditorSearchMatch[], replacement: (match: EditorSearchMatch) => string): boolean {
|
||||||
|
if (matches.length === 0) return false;
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: matches.map((match) => ({
|
||||||
|
from: match.from,
|
||||||
|
to: match.to,
|
||||||
|
insert: replacement(match),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ChangeSpec, EditorState, SelectionRange, Text, TransactionSpec } from "@codemirror/state";
|
||||||
|
|
||||||
|
export interface DispatchableEditorView {
|
||||||
|
state: EditorState;
|
||||||
|
dispatch(spec: TransactionSpec): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceSelectedEditorText(view: DispatchableEditorView, insert: string): boolean {
|
||||||
|
if (view.state.readOnly) return false;
|
||||||
|
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
if (selection.empty) return false;
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: selection.from, to: selection.to, insert },
|
||||||
|
selection: { anchor: selection.from + insert.length },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blankLineDeletionChanges(doc: Text, selection: SelectionRange): ChangeSpec[] {
|
||||||
|
if (doc.length === 0) return [];
|
||||||
|
|
||||||
|
const hasSelection = !selection.empty;
|
||||||
|
const firstLine = hasSelection ? doc.lineAt(selection.from).number : 1;
|
||||||
|
const lastSelectedPosition = hasSelection ? Math.max(selection.from, selection.to - 1) : doc.length;
|
||||||
|
const lastLine = hasSelection ? doc.lineAt(lastSelectedPosition).number : doc.lines;
|
||||||
|
const changes: ChangeSpec[] = [];
|
||||||
|
|
||||||
|
let lineNumber = firstLine;
|
||||||
|
while (lineNumber <= lastLine) {
|
||||||
|
const line = doc.line(lineNumber);
|
||||||
|
if (line.text.trim().length > 0) {
|
||||||
|
lineNumber += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstBlankLine = line;
|
||||||
|
let lastBlankLine = line;
|
||||||
|
while (lineNumber < lastLine) {
|
||||||
|
const nextLine = doc.line(lineNumber + 1);
|
||||||
|
if (nextLine.text.trim().length > 0) break;
|
||||||
|
lineNumber += 1;
|
||||||
|
lastBlankLine = nextLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the following separator so non-empty lines retain their existing newline.
|
||||||
|
if (lastBlankLine.to < doc.length) {
|
||||||
|
changes.push({ from: firstBlankLine.from, to: lastBlankLine.to + 1 });
|
||||||
|
} else if (firstBlankLine.from > 0) {
|
||||||
|
changes.push({ from: firstBlankLine.from - 1, to: lastBlankLine.to });
|
||||||
|
} else {
|
||||||
|
changes.push({ from: 0, to: doc.length });
|
||||||
|
}
|
||||||
|
lineNumber += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user