fix(editor): update SQL search escaping and match counts

This commit is contained in:
zipg 2026-07-10 12:27:41 +08:00 committed by GitHub
parent f89392de82
commit 77a5943729
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 75 additions and 7 deletions

View File

@ -3,8 +3,9 @@ import { ref, nextTick, onBeforeUnmount, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { EditorView } from "@codemirror/view";
import { EditorSelection } from "@codemirror/state";
import { SearchQuery, 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 { createEditorSearchQuery } from "@/lib/editor/editorSearchQuery";
const props = defineProps<{
view: EditorView | null;
@ -26,17 +27,25 @@ const replaceInputRef = ref<HTMLInputElement>();
const matchCountLimited = ref(false);
const SEARCH_UPDATE_DELAY_MS = 120;
const DOCUMENT_SEARCH_UPDATE_DELAY_MS = 500;
const MATCH_COUNT_LIMIT = 1000;
let searchUpdateTimer: ReturnType<typeof setTimeout> | null = null;
let documentSearchUpdateTimer: ReturnType<typeof setTimeout> | null = null;
function clearDocumentSearchUpdate() {
if (!documentSearchUpdateTimer) return;
clearTimeout(documentSearchUpdateTimer);
documentSearchUpdateTimer = null;
}
function dispatchSearchQuery() {
const v = props.view;
if (!v) return;
const q = new SearchQuery({
const q = createEditorSearchQuery({
search: searchText.value,
caseSensitive: caseSensitive.value,
regexp: useRegex.value,
useRegex: useRegex.value,
replace: replaceText.value,
});
v.dispatch({ effects: setSearchQuery.of(q) });
@ -48,7 +57,7 @@ function clearSearchQuery() {
const selection = v.state.selection.main;
v.dispatch({
selection: EditorSelection.single(selection.head),
effects: setSearchQuery.of(new SearchQuery({ search: "" })),
effects: setSearchQuery.of(createEditorSearchQuery({ search: "", caseSensitive: false, useRegex: false })),
});
matchCount.value = 0;
currentMatchIndex.value = 0;
@ -64,10 +73,10 @@ function updateMatchInfo(autoSelect = false) {
return;
}
try {
const q = new SearchQuery({
const q = createEditorSearchQuery({
search: searchText.value,
caseSensitive: caseSensitive.value,
regexp: useRegex.value,
useRegex: useRegex.value,
});
if (!q.valid) {
matchCount.value = 0;
@ -101,6 +110,7 @@ function updateMatchInfo(autoSelect = false) {
}
function scheduleSearchUpdate(autoSelect = false) {
clearDocumentSearchUpdate();
if (searchUpdateTimer) {
clearTimeout(searchUpdateTimer);
searchUpdateTimer = null;
@ -116,6 +126,15 @@ function scheduleSearchUpdate(autoSelect = false) {
}, SEARCH_UPDATE_DELAY_MS);
}
function scheduleDocumentSearchUpdate() {
if (!searchVisible.value || !searchText.value) return;
clearDocumentSearchUpdate();
documentSearchUpdateTimer = setTimeout(() => {
documentSearchUpdateTimer = null;
updateMatchInfo();
}, DOCUMENT_SEARCH_UPDATE_DELAY_MS);
}
function openSearch(): boolean {
searchVisible.value = true;
const v = props.view;
@ -146,6 +165,7 @@ function closeSearch() {
const wasVisible = searchVisible.value;
searchVisible.value = false;
showReplace.value = false;
clearDocumentSearchUpdate();
const v = props.view;
if (v) {
clearSearchQuery();
@ -204,13 +224,14 @@ watch(replaceText, () => {
});
onBeforeUnmount(() => {
clearDocumentSearchUpdate();
if (searchUpdateTimer) {
clearTimeout(searchUpdateTimer);
searchUpdateTimer = null;
}
});
defineExpose({ openSearch, openReplace, closeSearch });
defineExpose({ openSearch, openReplace, closeSearch, scheduleDocumentSearchUpdate });
</script>
<template>

View File

@ -2827,6 +2827,7 @@ onMounted(async () => {
rectangularSelection({ eventFilter: (e: MouseEvent) => e.altKey || e.button === 1 }),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
searchPanelRef.value?.scheduleDocumentSearchUpdate();
if (isEditorComposing(update.view)) {
pendingImeModelEmit = true;
completionEpoch++;

View File

@ -0,0 +1,28 @@
import { EditorState } from "@codemirror/state";
import { describe, expect, it } from "vitest";
import { createEditorSearchQuery } from "@/lib/editor/editorSearchQuery";
function matchedText(search: string, useRegex: boolean): string[] {
const state = EditorState.create({
doc: String.raw`SELECT '\n' AS escaped;
SELECT 1 AS actual_line_break;`,
});
const cursor = createEditorSearchQuery({ search, caseSensitive: false, useRegex }).getCursor(state);
const matches: string[] = [];
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push(state.sliceDoc(result.value.from, result.value.to));
}
return matches;
}
describe("editorSearchQuery", () => {
it("treats escape sequences literally in normal search mode", () => {
expect(matchedText(String.raw`\n`, false)).toEqual([String.raw`\n`]);
});
it("allows regular expression mode to match actual line breaks", () => {
expect(matchedText(String.raw`\n`, true)).toEqual(["\n"]);
});
});

View File

@ -0,0 +1,18 @@
import { SearchQuery } from "@codemirror/search";
export interface EditorSearchQueryOptions {
search: string;
replace?: string;
caseSensitive: boolean;
useRegex: boolean;
}
export function createEditorSearchQuery(options: EditorSearchQueryOptions): SearchQuery {
return new SearchQuery({
search: options.search,
replace: options.replace ?? "",
caseSensitive: options.caseSensitive,
regexp: options.useRegex,
literal: !options.useRegex,
});
}