diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts index da8b797d07..514f008b03 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts @@ -2,7 +2,7 @@ import { EditorSelection, EditorState } from "@codemirror/state"; import type { EditorView, ViewUpdate } from "@codemirror/view"; import { describe, expect, it } from "vitest"; import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection"; -import { sqlStringContentRangeAt, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges"; +import { sqlStringContentRangeAt, trimSqlStringWildcardBoundaries, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges"; function eventAt(position: number, assoc: -1 | 1 = 1, overrides: Partial = {}): MouseEvent { return { @@ -136,17 +136,38 @@ describe("query editor SQL string mouse selection", () => { expect(reverse?.anchor).toBeGreaterThan(reverse?.head ?? Number.POSITIVE_INFINITY); }); - it("maps the start selection and semantic analysis across document changes", () => { - const doc = "select 'alpha-beta'"; - const state = EditorState.create({ doc, selection: EditorSelection.cursor(doc.length) }); - const fixture = fakeView(state); - const start = eventAt(doc.indexOf("alpha")); - const style = createQueryEditorStringMouseSelection(fixture.view, start); - const transaction = state.update({ changes: { from: 0, insert: "-- " } }); - fixture.setState(transaction.state); - style?.update({ docChanged: true, changes: transaction.changes, startState: state, state: transaction.state, view: fixture.view } as ViewUpdate); + it("skips leading and trailing LIKE wildcards when double-clicking a string", () => { + const cases: Array<{ doc: string; needle: string; expected: string }> = [ + { doc: "select '%识别通配符%'", needle: "识别通配符", expected: "识别通配符" }, + { doc: "select 'name' like '%foo%'", needle: "foo", expected: "foo" }, + { doc: "select '%leading'", needle: "leading", expected: "leading" }, + { doc: "select 'trailing_'", needle: "trailing", expected: "trailing" }, + { doc: "select '__both__'", needle: "both", expected: "both" }, + { doc: "select '%%multi%%'", needle: "multi", expected: "multi" }, + ]; - const selection = style?.get(eventAt(doc.indexOf("alpha") + 3), false, false); - expect(selection && transaction.state.sliceDoc(selection.main.from, selection.main.to)).toBe("alpha-beta"); + for (const value of cases) { + const state = EditorState.create({ doc: value.doc }); + const start = eventAt(value.doc.indexOf(value.needle)); + const selection = createQueryEditorStringMouseSelection(fakeView(state).view, start)?.get(start, false, false); + expect(selection && value.doc.slice(selection.main.from, selection.main.to)).toBe(value.expected); + } + }); + + it("keeps a wildcard-only string intact so the double click still selects something", () => { + const doc = "select '%%'"; + const state = EditorState.create({ doc }); + const start = eventAt(doc.indexOf("%%") + 1); + const selection = createQueryEditorStringMouseSelection(fakeView(state).view, start)?.get(start, false, false); + expect(selection && doc.slice(selection.main.from, selection.main.to)).toBe("%%"); + }); + + it("trimSqlStringWildcardBoundaries shrinks past boundary wildcards only", () => { + const doc = "%a_b%"; + expect(trimSqlStringWildcardBoundaries(doc, { from: 0, to: doc.length })).toEqual({ from: 1, to: 4 }); + // Inner underscore/percent are preserved; only the boundaries are trimmed. + expect(doc.slice(1, 4)).toBe("a_b"); + // A range with no boundary wildcards is returned unchanged. + expect(trimSqlStringWildcardBoundaries("abc", { from: 0, to: 3 })).toEqual({ from: 0, to: 3 }); }); }); diff --git a/apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts b/apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts index a614bbd508..799b7d3532 100644 --- a/apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts +++ b/apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts @@ -1,6 +1,6 @@ import { EditorSelection, findClusterBreak, type EditorState, type SelectionRange } from "@codemirror/state"; import type { EditorView, MouseSelectionStyle, ViewUpdate } from "@codemirror/view"; -import { analyzeSqlSemanticSelectionRanges, sqlStringContentRangeAt, type SqlSemanticSelectionAnalysis, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges"; +import { analyzeSqlSemanticSelectionRanges, sqlStringContentRangeAt, trimSqlStringWildcardBoundaries, type SqlSemanticSelectionAnalysis, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges"; export interface QueryEditorStringMouseSelectionOptions extends SqlSemanticSelectionOptions { language?: "sql" | "text"; @@ -41,7 +41,9 @@ function groupAt(state: EditorState, position: PointerPosition): SelectionRange function semanticOrDefaultRange(state: EditorState, position: PointerPosition, options: SqlSemanticSelectionOptions, analysis: SqlSemanticSelectionAnalysis): SelectionRange { const semantic = sqlStringContentRangeAt(state.doc.toString(), position.pos, position.assoc, options, analysis); - return semantic ? EditorSelection.range(semantic.from, semantic.to) : groupAt(state, position); + if (!semantic) return groupAt(state, position); + const trimmed = trimSqlStringWildcardBoundaries(state.doc.toString(), semantic); + return EditorSelection.range(trimmed.from, trimmed.to); } export function createQueryEditorStringMouseSelection(view: EditorView, event: MouseEvent, options: QueryEditorStringMouseSelectionOptions = {}): MouseSelectionStyle | null { @@ -51,7 +53,8 @@ export function createQueryEditorStringMouseSelection(view: EditorView, event: M const semantic = sqlStringContentRangeAt(view.state.doc.toString(), start.pos, start.assoc, options, analysis); if (!semantic) return null; - let startRange = EditorSelection.range(semantic.from, semantic.to); + const trimmed = trimSqlStringWildcardBoundaries(view.state.doc.toString(), semantic); + let startRange = EditorSelection.range(trimmed.from, trimmed.to); let startSelection = view.state.selection; return { update(update: ViewUpdate) { diff --git a/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts b/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts index 3bd59a23b3..abc6a857eb 100644 --- a/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts +++ b/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts @@ -420,6 +420,22 @@ export function sqlStringContentRangeAt(doc: string, position: number, assoc: -1 return range && range.from <= character && character < range.to ? range : null; } +// SQL LIKE wildcards that a double-click on a string literal should skip so the +// selection lands on the searchable word instead of `%…%`/`_…_` (issue #8129). +const SQL_STRING_WILDCARD_CHARACTERS = new Set(["%", "_"]); + +// Shrinks a string-content range past leading/trailing LIKE wildcards. Used only +// by the double-click selection path, not the semantic expand-selection index, so +// widening still reaches the full quoted content. Returns the original range when +// it has no wildcard boundary or is entirely wildcards (nothing meaningful left). +export function trimSqlStringWildcardBoundaries(doc: string, range: SemanticSelectionRange): SemanticSelectionRange { + let from = range.from; + let to = range.to; + while (from < to && SQL_STRING_WILDCARD_CHARACTERS.has(doc[from] ?? "")) from += 1; + while (to > from && SQL_STRING_WILDCARD_CHARACTERS.has(doc[to - 1] ?? "")) to -= 1; + return from < to ? { from, to } : range; +} + function findStatement(context: SemanticSelectionContext, analysis: SqlSemanticSelectionAnalysis): PreparedStatementWindow | undefined { let low = 0; let high = analysis.statements.length;