From 782615586ceb65ae4634cbda8d60e4b42dc680aa Mon Sep 17 00:00:00 2001 From: JeaceLiu <31269749+JeaceLiu@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:32:50 +0800 Subject: [PATCH] feat(editor): add query editing utilities --- .gitignore | 2 +- .../components/editor/DelimitedListDialog.vue | 206 ++++++++++ .../components/editor/EditorSearchPanel.vue | 197 +++++++++- .../src/components/editor/QueryEditor.vue | 365 +++++++++++++++--- apps/desktop/src/i18n/locales/en.ts | 17 + apps/desktop/src/i18n/locales/es.ts | 17 + apps/desktop/src/i18n/locales/it.ts | 17 + apps/desktop/src/i18n/locales/ja.ts | 17 + apps/desktop/src/i18n/locales/pt-BR.ts | 17 + apps/desktop/src/i18n/locales/zh-CN.ts | 17 + apps/desktop/src/i18n/locales/zh-TW.ts | 17 + .../editor/editorSearchQuery.spec.ts | 17 +- .../editor/queryEditorTextEdits.spec.ts | 49 +++ .../src/lib/editor/editorSearchQuery.ts | 33 ++ .../src/lib/editor/queryEditorTextEdits.ts | 60 +++ 15 files changed, 989 insertions(+), 59 deletions(-) create mode 100644 apps/desktop/src/components/editor/DelimitedListDialog.vue create mode 100644 apps/desktop/src/lib/__tests__/editor/queryEditorTextEdits.spec.ts create mode 100644 apps/desktop/src/lib/editor/queryEditorTextEdits.ts diff --git a/.gitignore b/.gitignore index 880af71ae4..e9c502dbc4 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,4 @@ DBX_*_x64-portable.zip .agents/skills .pnpm-store/* .dbx-web -_dev +.npmrc diff --git a/apps/desktop/src/components/editor/DelimitedListDialog.vue b/apps/desktop/src/components/editor/DelimitedListDialog.vue new file mode 100644 index 0000000000..022a11fe2c --- /dev/null +++ b/apps/desktop/src/components/editor/DelimitedListDialog.vue @@ -0,0 +1,206 @@ + + + + + + + + + {{ t("editor.delimitedList.title") }} + + + + + + + {{ t("editor.delimitedList.columnDelimiter") }} + + + + {{ t("editor.delimitedList.resultDelimiter") }} + + + + {{ t("editor.delimitedList.quoteChar") }} + + + + {{ t("editor.delimitedList.wrapColumn") }} + + + + {{ t("editor.delimitedList.prefixText") }} + + + + {{ t("editor.delimitedList.suffixText") }} + + + + + + {{ t("editor.delimitedList.preview") }} + {{ preview || t("editor.delimitedList.emptyPreview") }} + + + + + {{ t("dangerDialog.cancel") }} + + + {{ t("grid.copy") }} + + {{ t("editor.delimitedList.confirm") }} + + + + diff --git a/apps/desktop/src/components/editor/EditorSearchPanel.vue b/apps/desktop/src/components/editor/EditorSearchPanel.vue index 23b237185d..0a037f1bab 100644 --- a/apps/desktop/src/components/editor/EditorSearchPanel.vue +++ b/apps/desktop/src/components/editor/EditorSearchPanel.vue @@ -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(); const replaceInputRef = ref(); 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, +}); @@ -272,6 +446,9 @@ defineExpose({ openSearch, openReplace, closeSearch, scheduleDocumentSearchUpdat {{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}` : t("editor.search.noResults") }} + + {{ t("editor.search.inSelection") }} + diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 01b8a4303a..e595e13e1c 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1,12 +1,13 @@ @@ -3763,6 +4035,7 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pa + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a19edcf9e8..42ddd2f8fc 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -629,8 +629,24 @@ export default { sendToAi: "Send to AI", uppercaseSelection: "Convert to uppercase", lowercaseSelection: "Convert to lowercase", + delimitedList: "Convert to delimited list", + findReplace: "Find/Replace", + deleteEmptyLines: "Delete empty lines", 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: { find: "Find", replace: "Replace", @@ -643,6 +659,7 @@ export default { nextMatch: "Next (Enter)", close: "Close (Esc)", noResults: "No results", + inSelection: "In selection", }, executionPicker: { title: "Execution Target", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c34f70b2c3..080f254c4e 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -611,6 +611,9 @@ export default withEnglishFallback({ uppercaseSelection: "Convertir a mayusculas", lowercaseSelection: "Convertir a minusculas", selectAll: "Seleccionar todo", + delimitedList: "Convertir a lista delimitada", + findReplace: "Buscar/Reemplazar", + deleteEmptyLines: "Eliminar líneas vacías", }, search: { find: "Buscar", @@ -624,6 +627,7 @@ export default withEnglishFallback({ nextMatch: "Siguiente (Enter)", close: "Cerrar (Esc)", noResults: "Sin resultados", + inSelection: "En la selección", }, executionPicker: { title: "Execution Target", @@ -634,6 +638,19 @@ export default withEnglishFallback({ }, statementExecutionSucceeded: "{count} sentencias exitosas", 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: { sql: "SQL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index de1b69f2ac..5724285fe0 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -609,6 +609,9 @@ export default withEnglishFallback({ uppercaseSelection: "Converti in maiuscolo", lowercaseSelection: "Converti in minuscolo", selectAll: "Seleziona tutto", + delimitedList: "Converti in lista delimitata", + findReplace: "Trova/Sostituisci", + deleteEmptyLines: "Elimina righe vuote", }, search: { find: "Trova", @@ -622,6 +625,7 @@ export default withEnglishFallback({ nextMatch: "Successivo (Enter)", close: "Chiudi (Esc)", noResults: "Nessun risultato", + inSelection: "Nella selezione", }, executionPicker: { title: "Execution Target", @@ -632,6 +636,19 @@ export default withEnglishFallback({ }, statementExecutionSucceeded: "{count} istruzioni eseguite con successo", 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: { sql: "SQL", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 342ba394b6..1eefcb7254 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -608,6 +608,9 @@ export default withEnglishFallback({ uppercaseSelection: "大文字に変換", lowercaseSelection: "小文字に変換", selectAll: "すべて選択", + delimitedList: "区切り文字付きリストに変換", + findReplace: "検索/置換", + deleteEmptyLines: "空行を削除", }, search: { find: "検索", @@ -621,6 +624,7 @@ export default withEnglishFallback({ nextMatch: "次へ (Enter)", close: "閉じる (Esc)", noResults: "結果なし", + inSelection: "選択範囲内", }, executionPicker: { title: "Execution Target", @@ -632,6 +636,19 @@ export default withEnglishFallback({ selectDatabaseRequired: "先にデータベースを選択してください", statementExecutionSucceeded: "{count} 件のステートメントが成功しました", statementExecutionFailed: "{count} 件のステートメントが失敗しました", + delimitedList: { + title: "区切り文字付きリストに変換", + columnDelimiter: "列区切り文字", + resultDelimiter: "結果区切り文字", + quoteChar: "文字列引用符", + wrapColumn: "列で折り返し", + prefixText: "先頭テキスト", + suffixText: "末尾テキスト", + preview: "プレビュー", + emptyPreview: "プレビューする内容がありません", + confirm: "確定", + selectFirst: "リストテキストを選択してください", + }, }, tabs: { sql: "SQL", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6c6f05c208..ffe69479f0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -610,6 +610,9 @@ export default withEnglishFallback({ uppercaseSelection: "Converter para maiúsculas", lowercaseSelection: "Converter para minúsculas", selectAll: "Selecionar tudo", + delimitedList: "Converter para lista delimitada", + findReplace: "Localizar/substituir", + deleteEmptyLines: "Excluir linhas vazias", }, search: { find: "Localizar", @@ -623,6 +626,7 @@ export default withEnglishFallback({ nextMatch: "Próximo (Enter)", close: "Fechar (Esc)", noResults: "Nenhum resultado", + inSelection: "Na seleção", }, executionPicker: { title: "Execution Target", @@ -633,6 +637,19 @@ export default withEnglishFallback({ }, statementExecutionSucceeded: "{count} instrução(ões) executada(s) com sucesso", 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: { sql: "SQL", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3c80e9811e..d50b474a91 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -631,8 +631,24 @@ export default withEnglishFallback({ sendToAi: "发送到 AI", uppercaseSelection: "转为大写", lowercaseSelection: "转为小写", + delimitedList: "转换为带分隔符的列表", + findReplace: "查找/替换", + deleteEmptyLines: "删除空行", selectAll: "全选", }, + delimitedList: { + title: "转换为带分隔符的列表", + columnDelimiter: "列分隔符", + resultDelimiter: "结果分隔符", + quoteChar: "字符串引号字符", + wrapColumn: "在列处换行", + prefixText: "前导文本", + suffixText: "尾部文本", + preview: "预览", + emptyPreview: "无可预览内容", + confirm: "确定", + selectFirst: "请选择列表文本", + }, search: { find: "查找", replace: "替换", @@ -645,6 +661,7 @@ export default withEnglishFallback({ nextMatch: "下一个 (Enter)", close: "关闭 (Esc)", noResults: "无结果", + inSelection: "选区内", }, executionPicker: { title: "执行目标", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d6dcd50879..598dd59acb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -610,6 +610,9 @@ export default withEnglishFallback({ uppercaseSelection: "轉為大寫", lowercaseSelection: "轉為小寫", selectAll: "全選", + delimitedList: "轉換為帶分隔符的清單", + findReplace: "查找/取代", + deleteEmptyLines: "刪除空行", }, search: { find: "尋找", @@ -623,6 +626,7 @@ export default withEnglishFallback({ nextMatch: "下一個 (Enter)", close: "關閉 (Esc)", noResults: "無結果", + inSelection: "選取區內", }, executionPicker: { title: "執行目標", @@ -633,6 +637,19 @@ export default withEnglishFallback({ }, statementExecutionSucceeded: "{count} 條陳述式成功", statementExecutionFailed: "{count} 條陳述式失敗", + delimitedList: { + title: "轉換為帶分隔符的清單", + columnDelimiter: "欄分隔符", + resultDelimiter: "結果分隔符", + quoteChar: "字串引號字元", + wrapColumn: "在欄位處換行", + prefixText: "前置文字", + suffixText: "尾部文字", + preview: "預覽", + emptyPreview: "無可預覽內容", + confirm: "確定", + selectFirst: "請選擇清單文字", + }, }, tabs: { sql: "SQL", diff --git a/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts b/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts index fc33d284fe..6a833d9d8c 100644 --- a/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts @@ -1,6 +1,6 @@ import { EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; -import { createEditorSearchQuery } from "@/lib/editor/editorSearchQuery"; +import { describe, expect, it, vi } from "vitest"; +import { collectEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches } from "@/lib/editor/editorSearchQuery"; function matchedText(search: string, useRegex: boolean): string[] { const state = EditorState.create({ @@ -25,4 +25,17 @@ describe("editorSearchQuery", () => { it("allows regular expression mode to match actual line breaks", () => { 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); + }); }); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorTextEdits.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorTextEdits.spec.ts new file mode 100644 index 0000000000..290e7c79a4 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorTextEdits.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/editor/editorSearchQuery.ts b/apps/desktop/src/lib/editor/editorSearchQuery.ts index 2a8ca66a63..a4983c71c0 100644 --- a/apps/desktop/src/lib/editor/editorSearchQuery.ts +++ b/apps/desktop/src/lib/editor/editorSearchQuery.ts @@ -1,3 +1,4 @@ +import type { EditorState, TransactionSpec } from "@codemirror/state"; import { SearchQuery } from "@codemirror/search"; export interface EditorSearchQueryOptions { @@ -16,3 +17,35 @@ export function createEditorSearchQuery(options: EditorSearchQueryOptions): Sear 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; +} diff --git a/apps/desktop/src/lib/editor/queryEditorTextEdits.ts b/apps/desktop/src/lib/editor/queryEditorTextEdits.ts new file mode 100644 index 0000000000..7f30256873 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorTextEdits.ts @@ -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; +}
{{ preview || t("editor.delimitedList.emptyPreview") }}