diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 4b3d0b4bf..737bbff65 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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 }, { diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7cc3729a5..1d183976e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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(() => { 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" diff --git a/apps/desktop/src/lib/sqlQuoteCaret.ts b/apps/desktop/src/lib/sqlQuoteCaret.ts new file mode 100644 index 000000000..b1cb1e67f --- /dev/null +++ b/apps/desktop/src/lib/sqlQuoteCaret.ts @@ -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; +} diff --git a/packages/app-tests/sqlQuoteCaret.test.ts b/packages/app-tests/sqlQuoteCaret.test.ts new file mode 100644 index 000000000..ccc125d09 --- /dev/null +++ b/packages/app-tests/sqlQuoteCaret.test.ts @@ -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, + ); +});