fix(editor): skip right quote on repeated single-quote input

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipg
2026-07-09 18:25:27 +08:00
committed by GitHub
co-authored by zipg
parent 5dfefbc0f5
commit 24370135eb
3 changed files with 59 additions and 6 deletions
@@ -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;
}
@@ -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");
});
});
+10
View File
@@ -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;