From 24370135eb3db28ab708fb37c37678e85899570e Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 9 Jul 2026 18:25:27 +0800 Subject: [PATCH] fix(editor): skip right quote on repeated single-quote input Co-authored-by: zipg <4047349+zipg@users.noreply.github.com> --- .../src/components/editor/QueryEditor.vue | 27 ++++++++++++++---- .../lib/__tests__/sql/sqlQuoteCaret.spec.ts | 28 +++++++++++++++++++ apps/desktop/src/lib/sql/sqlQuoteCaret.ts | 10 +++++++ 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sql/sqlQuoteCaret.spec.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index e6cde957ac..3f614a96e2 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -15,6 +15,7 @@ import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, import { currentStatementFrameRangeTo, visualSqlColumns } from "@/lib/sql/currentStatementFrame"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; +import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; import { formatMongoShellText } from "@/lib/mongo/mongoFormatter"; import { useConnectionStore, COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -432,14 +433,28 @@ function requestExecuteFromView(currentView: EditorViewType, cursorPos: number, return true; } +function sqlSingleQuoteKeyActionAt(state: EditorViewType["state"], position: number) { + return resolveSqlSingleQuoteKeyAction({ + previousChar: position > 0 ? state.doc.sliceString(position - 1, position) : "", + nextChar: position < state.doc.length ? state.doc.sliceString(position, position + 1) : "", + autoCloseBrackets: settingsStore.editorSettings.autoCloseBrackets, + }); +} + function handleSqlSingleQuote(view: EditorViewType): boolean { const { state } = view; - if (state.readOnly) return false; - if (state.selection.ranges.some((range) => !range.empty || range.from === 0 || state.doc.sliceString(range.from - 1, range.from) !== "'")) return false; - const transaction = state.changeByRange((range) => ({ - changes: { from: range.from, insert: "'" }, - range, - })); + const EditorSelection = codeMirrorEditorSelection; + if (state.readOnly || !EditorSelection) return false; + if (state.selection.ranges.some((range) => !range.empty)) return false; + if (state.selection.ranges.some((range) => sqlSingleQuoteKeyActionAt(state, range.from) === "pass")) return false; + const transaction = state.changeByRange((range) => { + const nextRange = EditorSelection.cursor(range.from + 1); + if (sqlSingleQuoteKeyActionAt(state, range.from) !== "insertEscapedQuote") return { range: nextRange }; + return { + changes: { from: range.from, insert: "'" }, + range: nextRange, + }; + }); view.dispatch(transaction, { userEvent: "input.type" }); return true; } diff --git a/apps/desktop/src/lib/__tests__/sql/sqlQuoteCaret.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlQuoteCaret.spec.ts new file mode 100644 index 0000000000..85a72dcb2d --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlQuoteCaret.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; + +describe("resolveSqlSingleQuoteKeyAction", () => { + it("skips over the auto-inserted closing single quote", () => { + expect(resolveSqlSingleQuoteKeyAction({ previousChar: "'", nextChar: "'" })).toBe("skipClosingQuote"); + }); + + it("keeps escaped quote insertion when there is no closing quote to skip", () => { + expect(resolveSqlSingleQuoteKeyAction({ previousChar: "'", nextChar: "" })).toBe("insertEscapedQuote"); + }); + + it("lets CodeMirror handle ordinary single quote input", () => { + expect(resolveSqlSingleQuoteKeyAction({ previousChar: "a", nextChar: "" })).toBe("pass"); + }); + + it("does not skip an existing string opening quote after whitespace", () => { + expect(resolveSqlSingleQuoteKeyAction({ previousChar: " ", nextChar: "'" })).toBe("pass"); + }); + + it("does not skip an existing string opening quote at the start of the document", () => { + expect(resolveSqlSingleQuoteKeyAction({ previousChar: "", nextChar: "'" })).toBe("pass"); + }); + + it("does not intercept input when auto-close brackets is disabled", () => { + expect(resolveSqlSingleQuoteKeyAction({ previousChar: "'", nextChar: "'", autoCloseBrackets: false })).toBe("pass"); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlQuoteCaret.ts b/apps/desktop/src/lib/sql/sqlQuoteCaret.ts index b1cb1e67fc..60809ea144 100644 --- a/apps/desktop/src/lib/sql/sqlQuoteCaret.ts +++ b/apps/desktop/src/lib/sql/sqlQuoteCaret.ts @@ -4,6 +4,16 @@ export interface SqlSingleQuoteCaretOptions { selectionStart: number | null | undefined; } +export type SqlSingleQuoteKeyAction = "pass" | "skipClosingQuote" | "insertEscapedQuote"; + +export function resolveSqlSingleQuoteKeyAction(options: { previousChar?: string; nextChar?: string; autoCloseBrackets?: boolean }): SqlSingleQuoteKeyAction { + const { previousChar = "", nextChar = "", autoCloseBrackets = true } = options; + if (!autoCloseBrackets) return "pass"; + if (previousChar === "'" && nextChar === "'") return "skipClosingQuote"; + if (previousChar === "'") return "insertEscapedQuote"; + return "pass"; +} + export function insertedSqlSingleQuoteAtCaret(options: SqlSingleQuoteCaretOptions): boolean { const { previousValue, nextValue, selectionStart } = options; if (typeof selectionStart !== "number" || selectionStart < 1) return false;