feat: add SQL single quote caret pairing

This commit is contained in:
Spencer.Chang 2026-06-23 21:14:15 +08:00 committed by GitHub
parent 0c8bf24977
commit aed2192d8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 134 additions and 0 deletions

View File

@ -350,6 +350,18 @@ function requestExecute() {
return true;
}
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,
}));
view.dispatch(transaction, { userEvent: "input.type" });
return true;
}
function executionPickerAnchor(currentView: EditorViewType, cursorPos: number, candidateCount: number): { left: number; top: number } | undefined {
const cursorRect = currentView.coordsAtPos(cursorPos);
const rootRect = editorRef.value?.getBoundingClientRect();
@ -1933,6 +1945,7 @@ onMounted(async () => {
previewRangeComp.of(buildPreviewRangeExtension()),
Prec.highest(
keymap.of([
{ key: "'", run: handleSqlSingleQuote },
...closeBracketsKeymap,
{ key: "Tab", run: handleTab },
{

View File

@ -141,6 +141,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import type { DataGridSortDirection } from "@/lib/dataGridSort";
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory } from "@/lib/dataGridConditionHistory";
import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCaret } from "@/lib/sqlQuoteCaret";
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
@ -453,6 +454,7 @@ const orderBySuggestionPosition = ref({ left: 0, top: 0 });
const orderByInput = ref(props.initialOrderByInput ?? "");
const hasOrderByInput = computed(() => orderByInput.value.trim().length > 0);
const whereFilterInput = ref(props.initialWhereInput ?? "");
let previousWhereFilterInputValue = whereFilterInput.value;
const hasWhereFilterInput = computed(() => whereFilterInput.value.trim().length > 0);
const conditionHistoryScope = computed(() => ({
connectionId: props.connectionId,
@ -1327,9 +1329,38 @@ function deleteWhereHistorySuggestion(value: string) {
whereSuggestionIndex.value = whereSuggestions.value.length ? Math.min(whereSuggestionIndex.value, whereSuggestions.value.length - 1) : -1;
}
function onWhereFilterInput(event: Event) {
const input = event.target instanceof HTMLInputElement ? event.target : null;
if (!input) return;
const nextValue = input.value;
if (
insertedSqlSingleQuoteAtCaret({
previousValue: previousWhereFilterInputValue,
nextValue,
selectionStart: input.selectionStart,
})
) {
const caret = input.selectionStart ?? nextValue.length;
const pairedValue = `${nextValue.slice(0, caret)}'${nextValue.slice(caret)}`;
whereFilterInput.value = pairedValue;
previousWhereFilterInputValue = pairedValue;
nextTick(() => input.setSelectionRange(caret, caret));
return;
}
const nextCaret = caretPositionInsideInsertedSqlSingleQuotes({
previousValue: previousWhereFilterInputValue,
nextValue,
selectionStart: input.selectionStart,
});
previousWhereFilterInputValue = nextValue;
if (nextCaret == null) return;
nextTick(() => input.setSelectionRange(nextCaret, nextCaret));
}
watch(whereFilterInput, (val) => {
emit("update:whereInput", currentWhereInput() ?? "");
persistStructuredFilterState();
previousWhereFilterInputValue = val;
whereSuggestions.value = [];
if (!props.tableMeta?.columns?.length) return;
const trimmed = val.trim();
@ -6603,6 +6634,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
spellcheck="false"
class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60"
placeholder=""
@input="onWhereFilterInput"
@keydown="onWhereFilterKeydown"
@focus="showWhereHistorySuggestions"
@click="updateWhereSuggestionPosition"

View File

@ -0,0 +1,26 @@
export interface SqlSingleQuoteCaretOptions {
previousValue: string;
nextValue: string;
selectionStart: number | null | undefined;
}
export function insertedSqlSingleQuoteAtCaret(options: SqlSingleQuoteCaretOptions): boolean {
const { previousValue, nextValue, selectionStart } = options;
if (typeof selectionStart !== "number" || selectionStart < 1) return false;
if (nextValue.charAt(selectionStart - 1) !== "'") return false;
return nextValue.slice(0, selectionStart - 1) + nextValue.slice(selectionStart) === previousValue;
}
export function caretPositionInsideInsertedSqlSingleQuotes(options: SqlSingleQuoteCaretOptions): number | null {
const { previousValue, nextValue, selectionStart } = options;
if (typeof selectionStart !== "number" || selectionStart < 2) return null;
if (nextValue.slice(selectionStart - 2, selectionStart) !== "''") return null;
const singleQuoteInsertPrevious = nextValue.slice(0, selectionStart - 1) + nextValue.slice(selectionStart);
if (singleQuoteInsertPrevious === previousValue) return selectionStart - 1;
const pairInsertPrevious = nextValue.slice(0, selectionStart - 2) + nextValue.slice(selectionStart);
if (pairInsertPrevious === previousValue) return selectionStart - 1;
return null;
}

View File

@ -0,0 +1,63 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCaret } from "../../apps/desktop/src/lib/sqlQuoteCaret.ts";
test("detects a newly typed SQL single quote", () => {
assert.equal(
insertedSqlSingleQuoteAtCaret({
previousValue: "name = ",
nextValue: "name = '",
selectionStart: 8,
}),
true,
);
assert.equal(
insertedSqlSingleQuoteAtCaret({
previousValue: "name = '",
nextValue: "name = ''",
selectionStart: 9,
}),
true,
);
});
test("moves the caret between SQL single quotes after typing the closing quote", () => {
assert.equal(
caretPositionInsideInsertedSqlSingleQuotes({
previousValue: "name = '",
nextValue: "name = ''",
selectionStart: 9,
}),
8,
);
});
test("moves the caret between pasted SQL single quotes", () => {
assert.equal(
caretPositionInsideInsertedSqlSingleQuotes({
previousValue: "name = ",
nextValue: "name = ''",
selectionStart: 9,
}),
8,
);
});
test("keeps the caret unchanged for ordinary quote edits", () => {
assert.equal(
caretPositionInsideInsertedSqlSingleQuotes({
previousValue: "name = ",
nextValue: "name = '",
selectionStart: 8,
}),
null,
);
assert.equal(
caretPositionInsideInsertedSqlSingleQuotes({
previousValue: "name = 'a",
nextValue: "name = 'a'",
selectionStart: 10,
}),
null,
);
});