diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 134f5fa07..ef0005bcd 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -84,7 +84,7 @@ import { eventToShortcut } from "@/lib/editor/keyboardShortcuts"; import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry"; import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; -import { currentStatementFrameRangeTo, visualSqlColumns } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; @@ -255,6 +255,7 @@ const editExecuteMode = ref(settingsStore.editorSettings.executeMode); const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker); const editShowStatementRunButtons = ref(settingsStore.editorSettings.showStatementRunButtons); const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame); +const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints); const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); @@ -388,6 +389,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { showExecutionTargetPicker: editShowExecutionTargetPicker.value, showStatementRunButtons: editShowStatementRunButtons.value, showCurrentStatementFrame: editShowCurrentStatementFrame.value, + showInsertValueHints: editShowInsertValueHints.value, autoAliasTables: editAutoAliasTables.value, wordWrap: editWordWrap.value, vimModeEnabled: editVimModeEnabled.value, @@ -660,6 +662,7 @@ function syncEditorSettingsDraftFromStore() { editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker; editShowStatementRunButtons.value = settingsStore.editorSettings.showStatementRunButtons; editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame; + editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints; editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editWordWrap.value = settingsStore.editorSettings.wordWrap; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; @@ -845,6 +848,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons; editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame; + editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -919,6 +923,7 @@ function resetAllDefaults() { editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons; editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame; + editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -2398,7 +2403,7 @@ function buildPreviewCurrentStatementFrameExtension(viewModule: Pick +
+
+ +

{{ t("settings.showInsertValueHintsDescription") }}

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 3de4851bb..dd33aa9dd 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -12,7 +12,8 @@ import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard"; import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget"; import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; -import { currentStatementFrameRangeTo, visualSqlColumns } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; +import { parseInsertValueHints } from "@/lib/sql/insertValueHints"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; @@ -56,6 +57,7 @@ import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestu import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; +import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from "@/lib/editor/codemirrorInsertValueHints"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; @@ -296,6 +298,7 @@ const queryEditorAppearanceSettings = computed(() => { vimModeEnabled: settings.vimModeEnabled, autoCloseBrackets: settings.autoCloseBrackets, showCurrentStatementFrame: settings.showCurrentStatementFrame, + showInsertValueHints: settings.showInsertValueHints, shortcuts: settings.shortcuts, showStatementRunButtons: settings.showStatementRunButtons, }; @@ -1098,6 +1101,51 @@ function completionCacheKey(table: { name: string; schema?: string | null }) { return schema ? `${schema}.${table.name}` : table.name; } +const pendingInsertValueHintColumnLoads = new Set(); + +function insertHintCacheKey(table: { name: string; schema?: string | null; database?: string | null }) { + if (table.database) { + return table.schema ? `${table.database}.${table.schema}.${table.name}` : `${table.database}.${table.name}`; + } + return completionCacheKey(table); +} + +function insertHintMetadataTarget(table: { name: string; schema?: string | null; database?: string | null }): { database: string; schema?: string } | null { + if (props.database == null) return null; + if (table.database) { + return { database: table.database, schema: table.schema ?? undefined }; + } + return completionMetadataTarget(table); +} + +function getInsertValueHintTableColumns(table: string, schema?: string, database?: string): string[] | undefined { + const cacheKey = insertHintCacheKey({ name: table, schema, database }); + const cached = cachedColumnsByTable.get(cacheKey); + if (!cached) return undefined; + return cached.map((column) => column.name); +} + +function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) { + if (!props.connectionId || props.database == null) return; + if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch") return; + const cacheKey = insertHintCacheKey({ name: table, schema, database }); + if (cachedColumnsByTable.has(cacheKey) || pendingInsertValueHintColumnLoads.has(cacheKey)) return; + const target = insertHintMetadataTarget({ name: table, schema, database }); + if (!target) return; + pendingInsertValueHintColumnLoads.add(cacheKey); + void connectionStore + .listCompletionColumns(props.connectionId, target.database, table, target.schema) + .then((columns) => { + cachedColumnsByTable.set(cacheKey, columns); + loadedColumnsByTable.add(cacheKey.toLowerCase()); + if (view.value) requestInsertValueHintsRefresh(view.value); + }) + .catch(() => {}) + .finally(() => { + pendingInsertValueHintColumnLoads.delete(cacheKey); + }); +} + function supportsDatabaseQualifierCompletion(): boolean { return !!props.databaseType && !isSchemaAware(props.databaseType) && !isSingleDatabase(props.databaseType); } @@ -2698,11 +2746,22 @@ onMounted(async () => { const startLine = view.state.doc.lineAt(range.from); const frameTo = currentStatementFrameTo(view, range); const endLine = view.state.doc.lineAt(Math.max(range.from, frameTo - 1)); + let insertValueHints: Array<{ from: number; column: string }> = []; + try { + if (settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch") { + insertValueHints = parseInsertValueHints(view.state.doc.sliceString(range.from, range.to), { resolveTableColumns: getInsertValueHintTableColumns }).map((hint) => ({ + ...hint, + from: hint.from + range.from, + })); + } + } catch { + insertValueHints = []; + } let maxWidth = 1; for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) { const line = view.state.doc.line(lineNumber); const lineRangeTo = Math.min(line.to, frameTo); - maxWidth = Math.max(maxWidth, visualSqlColumns(view.state.doc.sliceString(line.from, lineRangeTo))); + maxWidth = Math.max(maxWidth, visualSqlColumnsWithInlineHints(view.state.doc.sliceString(line.from, lineRangeTo), line.from, lineRangeTo, insertValueHints)); } const deco: any[] = []; @@ -2807,6 +2866,11 @@ onMounted(async () => { hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)), buildSqlSignatureExtension(), diagnosticComp.of(buildSqlDiagnosticExtension()), + createInsertValueHintsExtension({ + isEnabled: () => settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch", + getTableColumns: getInsertValueHintTableColumns, + requestTableColumns: requestInsertValueHintTableColumns, + }), previewRangeComp.of(buildPreviewRangeExtension()), Prec.highest( keymap.of([ @@ -3211,6 +3275,13 @@ watch( }, ); +watch( + () => settingsStore.editorSettings.showInsertValueHints, + () => { + if (view.value) requestInsertValueHintsRefresh(view.value); + }, +); + function pauseQueryEditorBackgroundWork() { flushEditorViewport(); flushEditorSelection(); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 64f8fd972..71fdf20bb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3336,6 +3336,8 @@ export default { showStatementRunButtonsDescription: "Show per-statement run buttons in the SQL editor gutter. Keyboard shortcuts and context menu execution still work when disabled.", showCurrentStatementFrame: "Show current statement frame", showCurrentStatementFrameDescription: "When enabled, the SQL editor draws an outline around the current executable statement; when disabled, the outline is hidden.", + showInsertValueHints: "Show INSERT value column hints", + showInsertValueHintsDescription: "When enabled, the SQL editor shows column-name hints next to each value in INSERT ... VALUES clauses.", previewStatementRunButton: "Preview statement run button", wordWrap: "Word wrap", wordWrapDescription: "Wrap long SQL lines within the editor width", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0b0490da0..e0eaad353 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3172,6 +3172,8 @@ export default withEnglishFallback({ showStatementRunButtonsDescription: "Muestra botones para ejecutar cada sentencia en el margen del editor SQL. Los atajos de teclado y el menú contextual seguirán funcionando al desactivarlo.", showCurrentStatementFrame: "Mostrar marco de la sentencia actual", showCurrentStatementFrameDescription: "Al activarlo, el editor SQL dibuja un contorno alrededor de la sentencia ejecutable actual; al desactivarlo, se oculta.", + showInsertValueHints: "Mostrar pistas de columnas en VALUES de INSERT", + showInsertValueHintsDescription: "Al activarlo, el editor SQL muestra el nombre de columna junto a cada valor en cláusulas INSERT ... VALUES.", previewStatementRunButton: "Botón de ejecución de sentencia en vista previa", wordWrap: "Ajuste de línea", wordWrapDescription: "Ajustar las líneas largas de SQL al ancho del editor", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 185df0dd8..4b1d62ebf 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3170,6 +3170,8 @@ export default withEnglishFallback({ showStatementRunButtonsDescription: "Mostra nel margine dell'editor SQL i pulsanti per eseguire ogni istruzione. Scorciatoie da tastiera e menu contestuale continuano a funzionare quando disattivati.", showCurrentStatementFrame: "Mostra cornice istruzione corrente", showCurrentStatementFrameDescription: "Se attivo, l'editor SQL disegna un contorno intorno all'istruzione eseguibile corrente; se disattivato, il contorno è nascosto.", + showInsertValueHints: "Mostra suggerimenti colonne nei VALUES di INSERT", + showInsertValueHintsDescription: "Se attivo, l'editor SQL mostra il nome della colonna accanto a ogni valore nelle clausole INSERT ... VALUES.", previewStatementRunButton: "Pulsante di esecuzione istruzione in anteprima", wordWrap: "A capo automatico", wordWrapDescription: "Incolonna le righe SQL lunghe entro la larghezza dell'editor", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 49bc11eda..0131e57e3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3160,6 +3160,8 @@ export default withEnglishFallback({ showStatementRunButtonsDescription: "SQLエディタのガターに文ごとの実行ボタンを表示します。無効にしてもキーボードショートカットとコンテキストメニューからの実行は引き続き使えます。", showCurrentStatementFrame: "現在の文の枠線を表示", showCurrentStatementFrameDescription: "有効にすると、SQLエディタで現在実行可能な文を枠線で示します。無効にすると枠線を表示しません。", + showInsertValueHints: "INSERT 値の列名ヒントを表示", + showInsertValueHintsDescription: "有効にすると、INSERT ... VALUES 句の各値の横に対応する列名ヒントを表示します。", previewStatementRunButton: "文の実行ボタンのプレビュー", wordWrap: "折り返し", wordWrapDescription: "長いSQL行をエディタ幅内で折り返します", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ae9553cc2..6a32db45f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3172,6 +3172,8 @@ export default withEnglishFallback({ showStatementRunButtonsDescription: "Mostra botões para executar cada instrução na margem do editor SQL. Atalhos de teclado e execução pelo menu de contexto continuam funcionando quando desativado.", showCurrentStatementFrame: "Mostrar moldura da instrução atual", showCurrentStatementFrameDescription: "Quando ativado, o editor SQL desenha um contorno ao redor da instrução executável atual; quando desativado, o contorno fica oculto.", + showInsertValueHints: "Mostrar dicas de colunas em VALUES de INSERT", + showInsertValueHintsDescription: "Quando ativado, o editor SQL mostra o nome da coluna ao lado de cada valor em cláusulas INSERT ... VALUES.", previewStatementRunButton: "Botão de execução de instrução na prévia", wordWrap: "Quebra de linha", wordWrapDescription: "Quebrar linhas SQL longas dentro da largura do editor", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e46d31a8c..f3670b781 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3335,6 +3335,8 @@ export default withEnglishFallback({ showStatementRunButtonsDescription: "在 SQL 编辑器左侧显示按语句执行的快捷按钮。关闭后仍可通过快捷键和右键菜单执行。", showCurrentStatementFrame: "显示当前语句外框线", showCurrentStatementFrameDescription: "开启后,在 SQL 编辑器中用外框线标出当前可执行语句;关闭后不显示外框线。", + showInsertValueHints: "显示 INSERT 值列名提示", + showInsertValueHintsDescription: "开启后,在 INSERT ... VALUES 子句的每个值旁显示对应列名提示。", previewStatementRunButton: "预览语句执行按钮", wordWrap: "自动换行", wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0081fc1d2..775d3f04b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3021,6 +3021,8 @@ export default withEnglishFallback({ showStatementRunButtonsDescription: "在 SQL 編輯器左側顯示按語句執行的快捷按鈕。關閉後仍可透過快捷鍵和右鍵選單執行。", showCurrentStatementFrame: "顯示目前語句外框線", showCurrentStatementFrameDescription: "啟用後,在 SQL 編輯器中用外框線標出目前可執行語句;關閉後不顯示外框線。", + showInsertValueHints: "顯示 INSERT 值欄位提示", + showInsertValueHintsDescription: "啟用後,在 INSERT ... VALUES 子句的每個值旁顯示對應欄位名稱提示。", previewStatementRunButton: "預覽語句執行按鈕", wordWrap: "自動換行", wordWrapDescription: "長 SQL 在編輯器寬度內自動折行顯示", diff --git a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts index 9b282102d..314e262d7 100644 --- a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { currentStatementFrameRangeTo, isWideSqlChar, visualSqlColumns } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameRangeTo, estimateInlineHintVisualColumns, isWideSqlChar, visualSqlColumns, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges"; describe("currentStatementFrameRangeTo", () => { @@ -25,3 +25,32 @@ describe("visualSqlColumns", () => { expect(isWideSqlChar("A")).toBe(false); }); }); + +describe("visualSqlColumnsWithInlineHints", () => { + it("adds estimated columns for insert-value hints on the line", () => { + const text = "VALUES (12, 'a')"; + const lineFrom = 0; + const lineTo = text.length; + const withoutHints = visualSqlColumns(text); + const withHints = visualSqlColumnsWithInlineHints(text, lineFrom, lineTo, [ + { from: 8, column: "id" }, + { from: 12, column: "name" }, + ]); + expect(withHints).toBe(withoutHints + estimateInlineHintVisualColumns("id") + estimateInlineHintVisualColumns("name")); + }); + + it("ignores hints that belong to other lines", () => { + const text = "VALUES (1)"; + expect(visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 100, column: "id" }])).toBe(visualSqlColumns(text)); + }); + + it("dedupes hints that share the same document offset", () => { + const text = "VALUES (1)"; + const once = visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 8, column: "id" }]); + const twice = visualSqlColumnsWithInlineHints(text, 0, text.length, [ + { from: 8, column: "id" }, + { from: 8, column: "id" }, + ]); + expect(twice).toBe(once); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorInsertValueHints.ts b/apps/desktop/src/lib/editor/codemirrorInsertValueHints.ts new file mode 100644 index 000000000..d955d8e3a --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorInsertValueHints.ts @@ -0,0 +1,212 @@ +import { StateEffect, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { buildInsertValueHints, expandToSqlStatementWindow, insertValueHintsNeedTableColumns, parseInsertValuesClauses, type InsertValueHint, type InsertValuesClause, type TextRange } from "@/lib/sql/insertValueHints"; + +export const refreshInsertValueHintsEffect = StateEffect.define(); + +/** Debounce hint reparse while the user is typing / holding a key. */ +const INSERT_HINT_REPARSE_DELAY_MS = 80; + +export interface InsertValueHintsExtensionOptions { + isEnabled?: () => boolean; + /** Sync cache lookup for table columns when INSERT has no explicit column list. */ + getTableColumns?: (table: string, schema?: string, database?: string) => string[] | undefined; + /** Async loader invoked when sync cache misses; should call refresh after load. */ + requestTableColumns?: (table: string, schema?: string, database?: string) => void; +} + +class InsertValueHintWidget extends WidgetType { + constructor(readonly column: string) { + super(); + } + + eq(other: InsertValueHintWidget) { + return other.column === this.column; + } + + toDOM() { + const span = document.createElement("span"); + span.className = "cm-insert-value-hint"; + span.textContent = this.column; + span.setAttribute("aria-hidden", "true"); + return span; + } + + ignoreEvent() { + return true; + } +} + +function decorationsForHints(hints: readonly InsertValueHint[]): DecorationSet { + if (hints.length === 0) return Decoration.none; + const deduped: InsertValueHint[] = []; + const seenFrom = new Set(); + for (const hint of [...hints].sort((a, b) => a.from - b.from || a.column.localeCompare(b.column))) { + if (seenFrom.has(hint.from)) continue; + seenFrom.add(hint.from); + deduped.push(hint); + } + const ranges = deduped.map((hint) => + Decoration.widget({ + widget: new InsertValueHintWidget(hint.column), + side: -1, + }).range(hint.from), + ); + return Decoration.set(ranges); +} + +function interestRanges(view: EditorView): TextRange[] { + const ranges: TextRange[] = view.visibleRanges.map((range) => ({ from: range.from, to: range.to })); + const cursor = view.state.selection.main.head; + ranges.push({ from: cursor, to: cursor }); + return ranges; +} + +function shiftClause(clause: InsertValuesClause, offset: number): InsertValuesClause { + if (offset === 0) return clause; + return { + ...clause, + span: { start: clause.span.start + offset, end: clause.span.end + offset }, + rows: clause.rows.map((row) => row.map((from) => from + offset)), + }; +} + +/** Parse INSERT hints using only local slices around interest ranges — no full-document tokenize. */ +function parseClausesNearView(view: EditorView): InsertValuesClause[] { + const doc = view.state.doc; + const clauses: InsertValuesClause[] = []; + const seenStmtStarts = new Set(); + + for (const range of interestRanges(view)) { + // Pull a bounded neighborhood from the doc instead of materializing the whole script. + const pad = 32 * 1024; + const sliceFrom = Math.max(0, range.from - pad); + const sliceTo = Math.min(doc.length, range.to + pad); + if (sliceTo <= sliceFrom) continue; + const slice = doc.sliceString(sliceFrom, sliceTo); + const window = expandToSqlStatementWindow(slice, range.from - sliceFrom, range.to - sliceFrom); + if (window.to <= window.from) continue; + const absStart = sliceFrom + window.from; + if (seenStmtStarts.has(absStart)) continue; + seenStmtStarts.add(absStart); + const stmt = slice.slice(window.from, window.to); + // Cheap reject: skip tokenize when the window clearly has no INSERT. + if (!/\binsert\b/i.test(stmt)) continue; + for (const clause of parseInsertValuesClauses(stmt)) { + clauses.push(shiftClause(clause, absStart)); + } + } + return clauses; +} + +function buildHints(view: EditorView, options: InsertValueHintsExtensionOptions): InsertValueHint[] { + const clauses = parseClausesNearView(view); + for (const clause of clauses) { + if (clause.columns !== null) continue; + const cached = options.getTableColumns?.(clause.table, clause.schema, clause.database); + if (!cached) options.requestTableColumns?.(clause.table, clause.schema, clause.database); + } + return buildInsertValueHints(clauses, { + resolveTableColumns: (table, schema, database) => options.getTableColumns?.(table, schema, database), + }); +} + +const insertValueHintsTheme = EditorView.baseTheme({ + ".cm-insert-value-hint": { + display: "inline-block", + marginRight: "0.35em", + padding: "0 0.3em", + borderRadius: "3px", + fontSize: "0.85em", + lineHeight: "1.2", + verticalAlign: "baseline", + color: "var(--cm-insert-value-hint-color, rgba(120, 120, 120, 0.95))", + backgroundColor: "var(--cm-insert-value-hint-bg, rgba(120, 120, 120, 0.18))", + pointerEvents: "none", + userSelect: "none", + fontStyle: "normal", + fontWeight: "500", + }, + "&dark .cm-insert-value-hint": { + color: "var(--cm-insert-value-hint-color, rgba(180, 180, 180, 0.9))", + backgroundColor: "var(--cm-insert-value-hint-bg, rgba(180, 180, 180, 0.16))", + }, +}); + +export function createInsertValueHintsExtension(options: InsertValueHintsExtensionOptions = {}): Extension { + const plugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + private lastEnabled = true; + private reparseTimer: ReturnType | null = null; + + constructor(view: EditorView) { + this.decorations = this.compute(view); + } + + update(update: ViewUpdate) { + const refreshed = update.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshInsertValueHintsEffect))); + const enabled = options.isEnabled?.() ?? true; + + if (!enabled) { + this.clearTimer(); + this.lastEnabled = false; + this.decorations = Decoration.none; + return; + } + + // Keep widget positions valid while a debounced reparse is pending. + if (update.docChanged) { + this.decorations = this.decorations.map(update.changes); + } + + if (refreshed || enabled !== this.lastEnabled) { + this.clearTimer(); + this.lastEnabled = enabled; + this.decorations = this.compute(update.view); + return; + } + + this.lastEnabled = enabled; + if (update.docChanged || update.viewportChanged) { + // Do not reparse on the hot path — key-repeat would block the UI. + this.scheduleReparse(update.view); + } + } + + destroy() { + this.clearTimer(); + } + + private scheduleReparse(view: EditorView) { + this.clearTimer(); + this.reparseTimer = setTimeout(() => { + this.reparseTimer = null; + // Trigger a lightweight transaction so update() runs compute once typing pauses. + view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) }); + }, INSERT_HINT_REPARSE_DELAY_MS); + } + + private clearTimer() { + if (this.reparseTimer !== null) { + clearTimeout(this.reparseTimer); + this.reparseTimer = null; + } + } + + private compute(view: EditorView): DecorationSet { + if (!(options.isEnabled?.() ?? true)) return Decoration.none; + return decorationsForHints(buildHints(view, options)); + } + }, + { decorations: (value) => value.decorations }, + ); + + return [insertValueHintsTheme, plugin]; +} + +export function requestInsertValueHintsRefresh(view: EditorView) { + view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) }); +} + +export { insertValueHintsNeedTableColumns }; diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 6043871bc..379909492 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -12,6 +12,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "showExecutionTargetPicker", "showStatementRunButtons", "showCurrentStatementFrame", + "showInsertValueHints", "autoAliasTables", "wordWrap", "vimModeEnabled", diff --git a/apps/desktop/src/lib/sql/currentStatementFrame.ts b/apps/desktop/src/lib/sql/currentStatementFrame.ts index 1b99e3873..ae23e3b8a 100644 --- a/apps/desktop/src/lib/sql/currentStatementFrame.ts +++ b/apps/desktop/src/lib/sql/currentStatementFrame.ts @@ -21,3 +21,39 @@ export function visualSqlColumns(text: string): number { export function isWideSqlChar(ch: string): boolean { return /[\u1100-\u115f\u2329\u232a\u2e80-\u303e\u3040-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/u.test(ch); } + +/** Approximate visual columns for a `.cm-insert-value-hint` widget (0.85em text + padding/margin). */ +export function estimateInlineHintVisualColumns(label: string): number { + // Slightly generous vs CSS (0.85em font + 0.3em*2 padding + 0.35em margin) so the + // statement frame does not clip past inlay hints. + return Math.max(2, Math.ceil(label.length * 0.9 + 2.2)); +} + +export interface InlineHintForFrameWidth { + from: number; + column: string; +} + +/** Document text columns plus inline widget hints that sit on `[lineFrom, lineTo)`. */ +export function visualSqlColumnsWithInlineHints(text: string, lineFrom: number, lineTo: number, hints: readonly InlineHintForFrameWidth[] = []): number { + let columns = visualSqlColumns(text); + const seen = new Set(); + for (const hint of hints) { + if (hint.from < lineFrom || hint.from >= lineTo) continue; + if (seen.has(hint.from)) continue; + seen.add(hint.from); + columns += estimateInlineHintVisualColumns(hint.column); + } + return columns; +} + +/** Measure rendered line width in CSS pixels, including inline widgets already in the DOM. */ +export function measureSqlLineWidthPx(view: { coordsAtPos: (pos: number, side?: -1 | 1) => { left: number; right: number; top: number; bottom: number } | null }, from: number, to: number): number | null { + if (to < from) return null; + const start = view.coordsAtPos(from, 1); + const end = view.coordsAtPos(to, -1); + if (!start || !end) return null; + // Ignore cross-line measurements (wrapping / mismatched sides). + if (Math.abs(start.top - end.top) > 2) return null; + return Math.max(0, end.right - start.left); +} diff --git a/apps/desktop/src/lib/sql/insertValueHints.ts b/apps/desktop/src/lib/sql/insertValueHints.ts new file mode 100644 index 000000000..39e75af63 --- /dev/null +++ b/apps/desktop/src/lib/sql/insertValueHints.ts @@ -0,0 +1,499 @@ +import { findActiveSqlStatementSpan, tokenizeSqlSemantic, tokenIsIdentifier, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens"; +import type { SqlSemanticSpan, SqlSemanticToken } from "@/lib/sql/semantic/types"; + +export interface InsertValueHint { + /** Document offset where the inlay widget is inserted (before the value expression). */ + from: number; + column: string; +} + +export interface InsertValuesClause { + table: string; + schema?: string; + /** Catalog/database qualifier for three-part names like OtherDb.dbo.Users. */ + database?: string; + /** Explicit column list, or null when `INSERT INTO t VALUES` has no column list. */ + columns: string[] | null; + /** For each VALUES row, start offsets of top-level value expressions. */ + rows: number[][]; + span: SqlSemanticSpan; +} + +export interface ParseInsertValueHintsOptions { + /** Resolve table columns when the INSERT has no explicit column list. */ + resolveTableColumns?: (table: string, schema?: string, database?: string) => string[] | undefined; +} + +export interface TextRange { + from: number; + to: number; +} + +function significantTokens(tokens: readonly SqlSemanticToken[]): SqlSemanticToken[] { + return tokens.filter((item) => item.kind !== "comment"); +} + +function statementSpans(sql: string, tokens: readonly SqlSemanticToken[]): SqlSemanticSpan[] { + const spans: SqlSemanticSpan[] = []; + let start = 0; + for (const item of tokens) { + if (item.kind !== "punctuation" || item.text !== ";" || item.depth !== 0) continue; + const span = trimSpan(sql, start, item.span.start); + if (span.end > span.start) spans.push(span); + start = item.span.end; + } + const last = trimSpan(sql, start, sql.length); + if (last.end > last.start) spans.push(last); + return spans; +} + +function trimSpan(sql: string, start: number, end: number): SqlSemanticSpan { + let from = start; + let to = end; + while (from < to && /\s/.test(sql[from] ?? "")) from += 1; + while (to > from && /\s/.test(sql[to - 1] ?? "")) to -= 1; + return { start: from, end: to }; +} + +function tokensInSpan(tokens: readonly SqlSemanticToken[], span: SqlSemanticSpan): SqlSemanticToken[] { + return tokens.filter((item) => item.span.end > span.start && item.span.start < span.end); +} + +function findWordIndex(tokens: readonly SqlSemanticToken[], word: string, from = 0): number { + const needle = word.toLowerCase(); + for (let index = from; index < tokens.length; index += 1) { + const item = tokens[index]; + if (item?.kind === "word" && item.normalized === needle) return index; + } + return -1; +} + +export function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: number): { name: string; schema?: string; database?: string; nextIndex: number } | null { + const first = tokens[startIndex]; + if (!tokenIsIdentifier(first)) return null; + const parts = [unquoteSqlSemanticIdentifier(first)]; + let index = startIndex + 1; + while (tokens[index]?.text === "." && tokenIsIdentifier(tokens[index + 1])) { + parts.push(unquoteSqlSemanticIdentifier(tokens[index + 1]!)); + index += 2; + } + if (parts.length >= 3) { + return { + database: parts[parts.length - 3], + schema: parts[parts.length - 2], + name: parts[parts.length - 1]!, + nextIndex: index, + }; + } + if (parts.length === 2) { + return { schema: parts[0], name: parts[1]!, nextIndex: index }; + } + return { name: parts[0]!, nextIndex: index }; +} + +function parseColumnList(tokens: readonly SqlSemanticToken[], openIndex: number): { columns: string[]; nextIndex: number } | null { + const open = tokens[openIndex]; + if (!open || open.text !== "(") return null; + const columns: string[] = []; + let index = openIndex + 1; + while (index < tokens.length) { + const item = tokens[index]; + if (!item) break; + if (item.text === ")" && item.depth === open.depth) { + return { columns, nextIndex: index + 1 }; + } + if (tokenIsIdentifier(item) && item.depth === open.depth + 1) { + columns.push(unquoteSqlSemanticIdentifier(item)); + index += 1; + continue; + } + index += 1; + } + return { columns, nextIndex: index }; +} + +function valueStartsInRow(tokens: readonly SqlSemanticToken[], openIndex: number): { starts: number[]; nextIndex: number } | null { + const open = tokens[openIndex]; + if (!open || open.text !== "(") return null; + const contentDepth = open.depth + 1; + const starts: number[] = []; + let expectValue = true; + let index = openIndex + 1; + + while (index < tokens.length) { + const item = tokens[index]; + if (!item) break; + if (item.text === ")" && item.depth === open.depth) { + return { starts, nextIndex: index + 1 }; + } + if (expectValue && item.depth === contentDepth) { + starts.push(item.span.start); + expectValue = false; + } + if (item.text === "," && item.depth === contentDepth) { + expectValue = true; + } + index += 1; + } + return { starts, nextIndex: index }; +} + +function parseValuesRows(tokens: readonly SqlSemanticToken[], valuesIndex: number): number[][] { + const rows: number[][] = []; + let index = valuesIndex + 1; + while (index < tokens.length) { + const item = tokens[index]; + if (!item) break; + if (item.kind === "word" && (item.normalized === "returning" || item.normalized === "on" || item.normalized === "select")) break; + if (item.text === "(") { + const row = valueStartsInRow(tokens, index); + if (!row) break; + if (row.starts.length > 0) rows.push(row.starts); + index = row.nextIndex; + continue; + } + if (item.text === ",") { + index += 1; + continue; + } + break; + } + return rows; +} + +function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanticSpan): InsertValuesClause | null { + const insertIndex = findWordIndex(tokens, "insert"); + if (insertIndex < 0) return null; + const intoIndex = findWordIndex(tokens, "into", insertIndex + 1); + if (intoIndex < 0) return null; + + const tableInfo = readQualifiedName(tokens, intoIndex + 1); + if (!tableInfo) return null; + + let index = tableInfo.nextIndex; + let columns: string[] | null = null; + + // SQL Server table hints appear between the target table and INSERT column list. + if (tokens[index]?.normalized === "with" && tokens[index + 1]?.text === "(") { + const hintList = parseColumnList(tokens, index + 1); + if (!hintList) return null; + index = hintList.nextIndex; + } + + // Optional alias between table and column list / VALUES / SELECT + if (tokenIsIdentifier(tokens[index]) && tokens[index]?.normalized !== "values" && tokens[index]?.normalized !== "select" && tokens[index]?.normalized !== "default") { + const maybeAs = tokens[index]; + if (maybeAs?.normalized === "as" && tokenIsIdentifier(tokens[index + 1])) { + index += 2; + } else if (tokens[index]?.text !== "(") { + index += 1; + } + } + + if (tokens[index]?.text === "(") { + const columnList = parseColumnList(tokens, index); + if (!columnList) return null; + columns = columnList.columns; + index = columnList.nextIndex; + } + + const valuesIndex = findWordIndex(tokens, "values", index); + const selectIndex = findWordIndex(tokens, "select", index); + if (valuesIndex < 0) return null; + if (selectIndex >= 0 && selectIndex < valuesIndex) return null; + + const rows = parseValuesRows(tokens, valuesIndex); + if (rows.length === 0) return null; + + return { + table: tableInfo.name, + schema: tableInfo.schema, + database: tableInfo.database, + columns, + rows, + span, + }; +} + +function shiftClause(clause: InsertValuesClause, offset: number): InsertValuesClause { + if (offset === 0) return clause; + return { + ...clause, + span: { start: clause.span.start + offset, end: clause.span.end + offset }, + rows: clause.rows.map((row) => row.map((from) => from + offset)), + }; +} + +/** + * Expand [from, to) to the nearest top-level statement window (quote/comment aware). + * Scans at most LOOKBACK bytes before `from` and LOOKAHEAD after `to` so large scripts + * do not pay O(document) on every keystroke. + */ +const STATEMENT_LOOKBACK = 32 * 1024; +const STATEMENT_LOOKAHEAD = 32 * 1024; + +export function expandToSqlStatementWindow(sql: string, from: number, to: number): TextRange { + const safeFrom = Math.max(0, Math.min(from, sql.length)); + const safeTo = Math.max(safeFrom, Math.min(to, sql.length)); + const scanFrom = Math.max(0, safeFrom - STATEMENT_LOOKBACK); + const scanTo = Math.min(sql.length, safeTo + STATEMENT_LOOKAHEAD); + const slice = scanFrom === 0 && scanTo === sql.length ? sql : sql.slice(scanFrom, scanTo); + const localFrom = safeFrom - scanFrom; + const localTo = safeTo - scanFrom; + const start = findStatementStart(slice, localFrom) + scanFrom; + const end = findStatementEnd(slice, Math.max(localFrom, localTo)) + scanFrom; + const trimmed = trimSpan(sql, start, Math.min(end, scanTo)); + return { from: trimmed.start, to: trimmed.end }; +} + +function findStatementStart(sql: string, pos: number): number { + let index = 0; + let start = 0; + let depth = 0; + let inLineComment = false; + let inBlockComment = false; + let quote: string | null = null; + + while (index < pos) { + const ch = sql[index] ?? ""; + const next = sql[index + 1] ?? ""; + + if (inLineComment) { + if (ch === "\n") inLineComment = false; + index += 1; + continue; + } + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false; + index += 2; + continue; + } + index += 1; + continue; + } + if (quote) { + if (ch === quote) { + if (next === quote) { + index += 2; + continue; + } + quote = null; + } + index += 1; + continue; + } + + if (ch === "-" && next === "-") { + inLineComment = true; + index += 2; + continue; + } + if (ch === "#") { + inLineComment = true; + index += 1; + continue; + } + if (ch === "/" && next === "*") { + inBlockComment = true; + index += 2; + continue; + } + if (ch === "'" || ch === '"' || ch === "`") { + quote = ch; + index += 1; + continue; + } + if (ch === "[") { + quote = "]"; + index += 1; + continue; + } + if (ch === "(") { + depth += 1; + index += 1; + continue; + } + if (ch === ")") { + depth = Math.max(0, depth - 1); + index += 1; + continue; + } + if (ch === ";" && depth === 0) { + start = index + 1; + index += 1; + continue; + } + index += 1; + } + return start; +} + +function findStatementEnd(sql: string, pos: number): number { + let index = pos; + let depth = 0; + let inLineComment = false; + let inBlockComment = false; + let quote: string | null = null; + + while (index < sql.length) { + const ch = sql[index] ?? ""; + const next = sql[index + 1] ?? ""; + + if (inLineComment) { + if (ch === "\n") inLineComment = false; + index += 1; + continue; + } + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false; + index += 2; + continue; + } + index += 1; + continue; + } + if (quote) { + if ((quote === "]" && ch === "]") || (quote !== "]" && ch === quote)) { + if (next === (quote === "]" ? "]" : quote)) { + index += 2; + continue; + } + quote = null; + } + index += 1; + continue; + } + + if (ch === "-" && next === "-") { + inLineComment = true; + index += 2; + continue; + } + if (ch === "#") { + inLineComment = true; + index += 1; + continue; + } + if (ch === "/" && next === "*") { + inBlockComment = true; + index += 2; + continue; + } + if (ch === "'" || ch === '"' || ch === "`") { + quote = ch; + index += 1; + continue; + } + if (ch === "[") { + quote = "]"; + index += 1; + continue; + } + if (ch === "(") { + depth += 1; + index += 1; + continue; + } + if (ch === ")") { + depth = Math.max(0, depth - 1); + index += 1; + continue; + } + if (ch === ";" && depth === 0) { + return index; + } + index += 1; + } + return sql.length; +} + +function mergeTextRanges(ranges: readonly TextRange[]): TextRange[] { + if (ranges.length === 0) return []; + const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to); + const merged: TextRange[] = [{ ...sorted[0]! }]; + for (let index = 1; index < sorted.length; index += 1) { + const current = sorted[index]!; + const last = merged[merged.length - 1]!; + if (current.from <= last.to) { + last.to = Math.max(last.to, current.to); + } else { + merged.push({ ...current }); + } + } + return merged; +} + +/** Parse INSERT ... VALUES clauses only inside the given document ranges (expanded to statement windows). */ +export function parseInsertValuesClausesInRanges(sql: string, ranges: readonly TextRange[]): InsertValuesClause[] { + if (!sql.trim() || ranges.length === 0) return []; + const windows = mergeTextRanges(ranges.map((range) => expandToSqlStatementWindow(sql, range.from, range.to))); + const clauses: InsertValuesClause[] = []; + for (const window of windows) { + if (window.to <= window.from) continue; + const slice = sql.slice(window.from, window.to); + for (const clause of parseInsertValuesClauses(slice)) { + clauses.push(shiftClause(clause, window.from)); + } + } + return clauses; +} + +/** Parse all INSERT ... VALUES clauses in `sql` (multi-statement aware). Prefer ranged parsing for editors. */ +export function parseInsertValuesClauses(sql: string): InsertValuesClause[] { + if (!sql.trim()) return []; + const allTokens = tokenizeSqlSemantic(sql); + const spans = statementSpans(sql, allTokens); + const clauses: InsertValuesClause[] = []; + for (const span of spans) { + const tokens = significantTokens(tokensInSpan(allTokens, span)); + const clause = parseInsertClause(tokens, span); + if (clause) clauses.push(clause); + } + return clauses; +} + +/** Build inlay hint positions from parsed clauses and optional table-column resolver. */ +export function buildInsertValueHints(clauses: readonly InsertValuesClause[], options: ParseInsertValueHintsOptions = {}): InsertValueHint[] { + const hints: InsertValueHint[] = []; + for (const clause of clauses) { + const columns = clause.columns ?? options.resolveTableColumns?.(clause.table, clause.schema, clause.database); + if (!columns || columns.length === 0) continue; + for (const row of clause.rows) { + const count = Math.min(row.length, columns.length); + for (let index = 0; index < count; index += 1) { + const from = row[index]; + const column = columns[index]; + if (from === undefined || !column) continue; + hints.push({ from, column }); + } + } + } + return hints; +} + +/** Parse SQL and return insert-value inlay hints. */ +export function parseInsertValueHints(sql: string, options: ParseInsertValueHintsOptions = {}): InsertValueHint[] { + return buildInsertValueHints(parseInsertValuesClauses(sql), options); +} + +/** Parse only the statements covering `ranges` and return insert-value inlay hints. */ +export function parseInsertValueHintsInRanges(sql: string, ranges: readonly TextRange[], options: ParseInsertValueHintsOptions = {}): InsertValueHint[] { + return buildInsertValueHints(parseInsertValuesClausesInRanges(sql, ranges), options); +} + +/** True when the document still needs table metadata for at least one INSERT without a column list. */ +export function insertValueHintsNeedTableColumns(sql: string): InsertValuesClause[] { + return parseInsertValuesClauses(sql).filter((clause) => clause.columns === null); +} + +/** Convenience: hints for the statement containing `cursor` only. */ +export function parseInsertValueHintsAtCursor(sql: string, cursor: number, options: ParseInsertValueHintsOptions = {}): InsertValueHint[] { + const tokens = tokenizeSqlSemantic(sql); + const span = findActiveSqlStatementSpan(sql, tokens, cursor); + const statementTokens = significantTokens(tokensInSpan(tokens, span)); + const clause = parseInsertClause(statementTokens, span); + if (!clause) return []; + return buildInsertValueHints([clause], options); +} diff --git a/apps/desktop/src/lib/sql/semantic/tokens.ts b/apps/desktop/src/lib/sql/semantic/tokens.ts index 7182ba976..adecd87f7 100644 --- a/apps/desktop/src/lib/sql/semantic/tokens.ts +++ b/apps/desktop/src/lib/sql/semantic/tokens.ts @@ -72,6 +72,16 @@ export function tokenizeSqlSemantic(input: string): SqlSemanticToken[] { continue; } + if (ch === "$") { + const marker = /^\$[A-Za-z_0-9]*\$/.exec(input.slice(start))?.[0]; + if (marker) { + const closing = input.indexOf(marker, start + marker.length); + index = closing < 0 ? input.length : closing + marker.length; + tokens.push(token("string", input.slice(start, index), start, index, depth, marker)); + continue; + } + } + if (ch === '"') { index = readQuoted(input, start, '"', '"'); tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, '"')); diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index db1b04c3c..30e761acd 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -18,6 +18,14 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ showCurrentStatementFrame: false }).showCurrentStatementFrame).toBe(false); }); + it("shows INSERT value column hints by default", () => { + expect(normalizeEditorSettings({}).showInsertValueHints).toBe(true); + }); + + it("preserves disabled INSERT value column hints", () => { + expect(normalizeEditorSettings({ showInsertValueHints: false }).showInsertValueHints).toBe(false); + }); + it("keeps SQL semantic diagnostics in auto mode and disabled by default", () => { const settings = normalizeEditorSettings({}); expect(settings.sqlSemanticDiagnosticsMode).toBe("auto"); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 493525a66..6c1fc8571 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -375,6 +375,7 @@ export interface EditorSettings { showExecutionTargetPicker: boolean; showStatementRunButtons: boolean; showCurrentStatementFrame: boolean; + showInsertValueHints: boolean; autoAliasTables: boolean; wordWrap: boolean; vimModeEnabled: boolean; @@ -509,6 +510,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { showExecutionTargetPicker: false, showStatementRunButtons: true, showCurrentStatementFrame: true, + showInsertValueHints: true, autoAliasTables: true, wordWrap: false, vimModeEnabled: false, @@ -743,6 +745,7 @@ export function normalizeEditorSettings(settings: Partial, exist showExecutionTargetPicker: settings.showExecutionTargetPicker ?? DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker, showStatementRunButtons: typeof settings.showStatementRunButtons === "boolean" ? settings.showStatementRunButtons : DEFAULT_EDITOR_SETTINGS.showStatementRunButtons, showCurrentStatementFrame: typeof settings.showCurrentStatementFrame === "boolean" ? settings.showCurrentStatementFrame : DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame, + showInsertValueHints: typeof settings.showInsertValueHints === "boolean" ? settings.showInsertValueHints : DEFAULT_EDITOR_SETTINGS.showInsertValueHints, autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables, wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap, vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled, @@ -985,6 +988,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.showExecutionTargetPicker !== undefined) editorSettings.value.showExecutionTargetPicker = partial.showExecutionTargetPicker; if (partial.showStatementRunButtons !== undefined) editorSettings.value.showStatementRunButtons = partial.showStatementRunButtons === true; if (partial.showCurrentStatementFrame !== undefined) editorSettings.value.showCurrentStatementFrame = partial.showCurrentStatementFrame === true; + if (partial.showInsertValueHints !== undefined) editorSettings.value.showInsertValueHints = partial.showInsertValueHints === true; if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables; if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap; if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true; diff --git a/packages/app-tests/insertValueHints.test.ts b/packages/app-tests/insertValueHints.test.ts new file mode 100644 index 000000000..9f61cbfff --- /dev/null +++ b/packages/app-tests/insertValueHints.test.ts @@ -0,0 +1,174 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { buildInsertValueHints, expandToSqlStatementWindow, parseInsertValueHints, parseInsertValueHintsInRanges, parseInsertValuesClauses } from "../../apps/desktop/src/lib/sql/insertValueHints.ts"; + +test("maps explicit column list to single-row VALUES", () => { + const sql = "INSERT INTO auth_user (id, password, last_login) VALUES (5, 'hash', NULL)"; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => ({ column: hint.column, text: sql.slice(hint.from, hint.from + 1) })), + [ + { column: "id", text: "5" }, + { column: "password", text: "'" }, + { column: "last_login", text: "N" }, + ], + ); +}); + +test("supports multi-row VALUES", () => { + const sql = "INSERT INTO users (id, name) VALUES (1, 'a'), (2, 'b')"; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => hint.column), + ["id", "name", "id", "name"], + ); + assert.equal(sql.slice(hints[0]!.from, hints[0]!.from + 1), "1"); + assert.equal(sql.slice(hints[2]!.from, hints[2]!.from + 1), "2"); +}); + +test("does not split nested parentheses inside a value", () => { + const sql = "INSERT INTO t (a, b) VALUES (COALESCE(x, y), NOW())"; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => hint.column), + ["a", "b"], + ); + assert.ok(sql.slice(hints[0]!.from).startsWith("COALESCE(x, y)")); + assert.ok(sql.slice(hints[1]!.from).startsWith("NOW()")); +}); + +test("does not split PostgreSQL dollar-quoted values", () => { + const sql = "INSERT INTO t (body, count) VALUES ($tag$hello,(world),again$tag$, 2)"; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => hint.column), + ["body", "count"], + ); + assert.ok(sql.slice(hints[0]!.from).startsWith("$tag$hello,(world),again$tag$")); + assert.equal(sql.slice(hints[1]!.from, hints[1]!.from + 1), "2"); +}); + +test("skips SQL Server table hints before the INSERT column list", () => { + const sql = "INSERT INTO dbo.Users WITH (TABLOCK) (id, name) VALUES (1, 'alice')"; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => hint.column), + ["id", "name"], + ); +}); + +test("resolves columns from table metadata when column list is omitted", () => { + const sql = "INSERT INTO users VALUES (1, 'alice')"; + const hints = parseInsertValueHints(sql, { + resolveTableColumns: (table) => (table === "users" ? ["id", "name"] : undefined), + }); + assert.deepEqual( + hints.map((hint) => hint.column), + ["id", "name"], + ); +}); + +test("returns no hints for INSERT ... SELECT", () => { + const sql = "INSERT INTO users (id, name) SELECT id, name FROM staging"; + assert.deepEqual(parseInsertValueHints(sql), []); + assert.deepEqual(parseInsertValuesClauses(sql), []); +}); + +test("caps hints when value count exceeds column count", () => { + const sql = "INSERT INTO t (a, b) VALUES (1, 2, 3)"; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => hint.column), + ["a", "b"], + ); +}); + +test("caps hints when column count exceeds value count", () => { + const sql = "INSERT INTO t (a, b, c) VALUES (1, 2)"; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => hint.column), + ["a", "b"], + ); +}); + +test("handles quoted identifiers in column list", () => { + const sql = 'INSERT INTO "User" ("Id", "Name") VALUES (1, \'x\')'; + const hints = parseInsertValueHints(sql); + assert.deepEqual( + hints.map((hint) => hint.column), + ["Id", "Name"], + ); +}); + +test("parses schema-qualified table without column list", () => { + const clauses = parseInsertValuesClauses("INSERT INTO dbo.Users VALUES (1)"); + assert.equal(clauses.length, 1); + assert.equal(clauses[0]?.table, "Users"); + assert.equal(clauses[0]?.schema, "dbo"); + assert.equal(clauses[0]?.database, undefined); + assert.equal(clauses[0]?.columns, null); +}); + +test("preserves three-part database.schema.table qualifiers", () => { + const clauses = parseInsertValuesClauses("INSERT INTO OtherDb.dbo.Users VALUES (1, 'a')"); + assert.equal(clauses.length, 1); + assert.equal(clauses[0]?.database, "OtherDb"); + assert.equal(clauses[0]?.schema, "dbo"); + assert.equal(clauses[0]?.table, "Users"); +}); + +test("preserves quoted three-part database.schema.table qualifiers", () => { + const clauses = parseInsertValuesClauses('INSERT INTO "OtherDb"."dbo"."Users" VALUES (1)'); + assert.equal(clauses[0]?.database, "OtherDb"); + assert.equal(clauses[0]?.schema, "dbo"); + assert.equal(clauses[0]?.table, "Users"); +}); + +test("routes three-part names through resolveTableColumns database argument", () => { + const sql = "INSERT INTO OtherDb.dbo.Users VALUES (1, 'a')"; + const calls: Array<{ table: string; schema?: string; database?: string }> = []; + const hints = parseInsertValueHints(sql, { + resolveTableColumns: (table, schema, database) => { + calls.push({ table, schema, database }); + if (database === "OtherDb" && schema === "dbo" && table === "Users") return ["id", "name"]; + return ["wrong_id"]; + }, + }); + assert.deepEqual(calls, [{ table: "Users", schema: "dbo", database: "OtherDb" }]); + assert.deepEqual( + hints.map((hint) => hint.column), + ["id", "name"], + ); +}); + +test("parses only statement windows covering provided ranges", () => { + const prefix = `${"SELECT 1;\n".repeat(200)}`; + const insert = "INSERT INTO t (id, name) VALUES (1, 'x');"; + const suffix = `\n${"SELECT 2;\n".repeat(200)}`; + const sql = `${prefix}${insert}${suffix}`; + const insertFrom = prefix.length; + const hints = parseInsertValueHintsInRanges(sql, [{ from: insertFrom, to: insertFrom + 10 }]); + assert.deepEqual( + hints.map((hint) => hint.column), + ["id", "name"], + ); + assert.equal(sql.slice(hints[0]!.from, hints[0]!.from + 1), "1"); +}); + +test("expandToSqlStatementWindow stops at neighboring statements", () => { + const sql = "SELECT 1; INSERT INTO t (a) VALUES (1); SELECT 2;"; + const insertAt = sql.indexOf("INSERT"); + const window = expandToSqlStatementWindow(sql, insertAt, insertAt + 6); + assert.equal(sql.slice(window.from, window.to), "INSERT INTO t (a) VALUES (1)"); +}); + +test("ignores statements that are not INSERT VALUES", () => { + const sql = "SELECT 1; UPDATE users SET name = 'a' WHERE id = 1;"; + assert.deepEqual(parseInsertValueHints(sql), []); +}); + +test("buildInsertValueHints skips unresolved tables without metadata", () => { + const clauses = parseInsertValuesClauses("INSERT INTO mystery VALUES (1, 2)"); + assert.deepEqual(buildInsertValueHints(clauses), []); +});