feat(editor): add query editing utilities

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

No files matched your search

@@ -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);
});
});
@@ -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";
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;
}
@@ -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;
}