diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index ac83862217..a716f7b597 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -249,8 +249,10 @@ let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").ins let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null; let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null; +let setResultSourceRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null; let previewRangeComp: import("@codemirror/state").Compartment | null = null; let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null; +let buildResultSourceRangeExtension: (() => import("@codemirror/state").Extension) | null = null; let buildRunStatementGutterExtension: (() => import("@codemirror/state").Extension) | null = null; let indentComp: import("@codemirror/state").Compartment | null = null; let codeMirrorIndentUnit: typeof import("@codemirror/language").indentUnit | null = null; @@ -491,6 +493,33 @@ function setPreviewRange(range: { from: number; to: number } | null) { }); } +function setResultSourceRange(range: { from: number; to: number } | null) { + if (!view.value || !setResultSourceRangeEffect) return; + view.value.dispatch({ + effects: setResultSourceRangeEffect.of(range), + }); +} + +function previewStatementRange(range: { from: number; to: number } | null) { + const currentView = view.value; + if (!range || !currentView || !editorViewModule || !setResultSourceRangeEffect) { + setResultSourceRange(null); + return; + } + + const from = Math.max(0, Math.min(range.from, currentView.state.doc.length)); + const to = Math.max(from, Math.min(range.to, currentView.state.doc.length)); + if (from === to) { + setResultSourceRange(null); + return; + } + + currentView.dispatch({ + selection: { anchor: from }, + effects: [setResultSourceRangeEffect.of({ from, to }), editorViewModule.EditorView.scrollIntoView(from, { y: "center" })], + }); +} + function onPickerActiveIndexChange(index: number) { pickerActiveIndex.value = index; const candidate = pickerCandidates.value[index]; @@ -2574,8 +2603,8 @@ onMounted(async () => { if (!editorRef.value) return; const [ - { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, gutter, GutterMarker, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, ViewPlugin }, - { EditorState, EditorSelection, Compartment, Prec, StateEffect, StateField }, + { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, ViewPlugin }, + { EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField }, langSql, { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField }, { copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, history, defaultKeymap, historyKeymap }, @@ -2681,6 +2710,7 @@ onMounted(async () => { return Decoration.set([Decoration.mark({ class: "cm-db-execution-preview" }).range(range.from, range.to)]); } } + if (transaction.docChanged || transaction.selection) return Decoration.none; return decorations; }, provide: (f) => EditorView.decorations.from(f), @@ -2688,6 +2718,41 @@ onMounted(async () => { return field; }; + class ResultSourceLineNumberMarker extends GutterMarker { + elementClass = "cm-db-result-source-line-number"; + } + + const resultSourceLineNumberMarker = new ResultSourceLineNumberMarker(); + setResultSourceRangeEffect = StateEffect.define<{ from: number; to: number } | null>(); + buildResultSourceRangeExtension = () => { + const effectType = setResultSourceRangeEffect!; + const markersForRange = (state: import("@codemirror/state").EditorState, range: { from: number; to: number }) => { + const from = Math.max(0, Math.min(range.from, state.doc.length)); + const to = Math.max(from, Math.min(range.to, state.doc.length)); + const startLine = state.doc.lineAt(from); + const endLine = state.doc.lineAt(Math.max(from, to - 1)); + const markers = Array.from({ length: endLine.number - startLine.number + 1 }, (_, index) => resultSourceLineNumberMarker.range(state.doc.line(startLine.number + index).from)); + return RangeSet.of(markers); + }; + + const field = StateField.define({ + create() { + return RangeSet.empty; + }, + update(markers, transaction) { + for (const effect of transaction.effects) { + if (effect.is(effectType)) { + return effect.value ? markersForRange(transaction.state, effect.value) : RangeSet.empty; + } + } + if (transaction.docChanged || transaction.selection) return RangeSet.empty; + return markers; + }, + provide: (field) => lineNumberMarkers.from(field), + }); + return field; + }; + buildSqlSignatureExtension = () => showTooltip.compute(["doc", "selection"], (currentState) => { const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head); @@ -2878,6 +2943,7 @@ onMounted(async () => { requestTableColumns: requestInsertValueHintTableColumns, }), previewRangeComp.of(buildPreviewRangeExtension()), + buildResultSourceRangeExtension(), Prec.highest( keymap.of([ { key: "'", run: handleSqlSingleQuote }, @@ -3462,7 +3528,7 @@ function scrollCursorIntoView() { }); } -defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition }); +defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition, previewStatementRange }); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts index dec05adc45..73bfca7ab7 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { queryResultBaseSql, queryResultExecutionSql, tabularResultItems } from "@/lib/tabs/tabPresentation"; +import { middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, tabularResultItems } from "@/lib/tabs/tabPresentation"; import type { QueryTab } from "@/types/database"; function queryTab(overrides: Partial): QueryTab { @@ -53,7 +53,13 @@ describe("query result SQL selection", () => { }); describe("query result labels", () => { - it("uses source labels while keeping the full statement as the title", () => { + it("preserves both ends when shortening long source labels", () => { + expect(middleEllipsis("easy_manager_tool.tool_monitor_data_index_item")).toBe("easy_manage...index_item"); + expect(middleEllipsis("aaa.apis")).toBe("aaa.apis"); + expect(middleEllipsis("abcdef", 4)).toBe("a..."); + }); + + it("uses the full source label as the result tab tooltip", () => { const [item] = tabularResultItems([ { columns: ["id"], @@ -66,7 +72,26 @@ describe("query result labels", () => { ]); expect(item?.label).toBe("app.users"); - expect(item?.title).toBe("SELECT * FROM users"); + expect(item?.displayLabel).toBe("app.users"); + expect(item?.labelTruncated).toBe(false); + expect(item?.title).toBe("app.users"); + }); + + it("exposes a middle-shortened display label while retaining the full tooltip", () => { + const [item] = tabularResultItems([ + { + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + sourceLabel: "easy_manager_tool.tool_monitor_data_index_item", + sourceStatement: "SELECT * FROM tool_monitor_data_index_item", + }, + ]); + + expect(item?.displayLabel).toBe("easy_manage...index_item"); + expect(item?.labelTruncated).toBe(true); + expect(item?.title).toBe("easy_manager_tool.tool_monitor_data_index_item"); }); it("does not expose SQL text as a visible fallback label", () => { @@ -84,3 +109,45 @@ describe("query result labels", () => { expect(item?.title).toBe("SELECT 1"); }); }); + +describe("query result source ranges", () => { + it("uses the result index to distinguish repeated statements", () => { + const sql = "SELECT * FROM users;\nSELECT * FROM users;"; + const range = resultSourceRange(sql, { sourceStatement: "SELECT * FROM users" }, 1, "mysql"); + + expect(range).toEqual({ + from: sql.lastIndexOf("SELECT"), + to: sql.length - 1, + sql: "SELECT * FROM users", + }); + }); + + it("resolves newline-separated MongoDB commands with the Mongo shell parser", () => { + const sql = "db.model_field_group.find({})\n\ndb.model_info.find({})"; + const sourceStatement = "db.model_info.find({})"; + const range = resultSourceRange(sql, { sourceStatement }, 1, "mongodb"); + + expect(range).toEqual({ + from: sql.indexOf(sourceStatement), + to: sql.length, + sql: sourceStatement, + }); + }); + + it("resolves newline-separated Redis commands with the Redis parser", () => { + const sql = "GET first\n\nGET second"; + const sourceStatement = "GET second"; + const range = resultSourceRange(sql, { sourceStatement }, 1, "redis"); + + expect(range).toEqual({ + from: sql.indexOf(sourceStatement), + to: sql.length, + sql: sourceStatement, + }); + }); + + it("does not highlight a stale or ambiguous statement", () => { + expect(resultSourceRange("SELECT * FROM users;", { sourceStatement: "SELECT * FROM orders" }, 0, "mysql")).toBeUndefined(); + expect(resultSourceRange("SELECT * FROM users; SELECT * FROM users;", { sourceStatement: "SELECT * FROM users" }, undefined, "mysql")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index 9802f0102f..d2b2934ccd 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -1,6 +1,8 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; -import type { ConnectionConfig, QueryResult, QueryTab } from "@/types/database"; +import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand"; +import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; +import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database"; type Translate = (key: string, params?: Record) => string; export type OutputView = "result" | "summary" | "explain" | "chart"; @@ -145,10 +147,41 @@ export function queryResultStatementLabel(result: Pick 0 ? value.slice(-endLength) : ""; + return `${value.slice(0, startLength)}...${end}`; +} + export function resultSqlForGrid(tab: Pick): string { return tab.result?.sourceStatement || tab.resultBaseSql || tab.lastExecutedSql || tab.sql; } +/** + * Resolves a result's executed statement back to its current editor range. + * A stale or ambiguous source is ignored instead of highlighting a different + * statement that happens to have the same text. + */ +export function resultSourceRange(editorSql: string, result: Pick | undefined, resultIndex: number | undefined, databaseType?: DatabaseType): SqlTextRange | undefined { + const sourceStatement = result?.sourceStatement; + if (!sourceStatement) return undefined; + + const statements = databaseType === "redis" ? executableStatementRanges(editorSql, databaseType) : databaseType === "mongodb" ? splitMongoCommandRanges(editorSql).map(({ from, to, text }) => ({ from, to, sql: text })) : splitSqlStatementRanges(editorSql, databaseType); + const indexed = typeof resultIndex === "number" ? statements[resultIndex] : undefined; + if (indexed?.sql === sourceStatement) { + return { from: indexed.from, to: indexed.to, sql: indexed.sql }; + } + + const matches = statements.filter((statement) => statement.sql === sourceStatement); + if (matches.length !== 1) return undefined; + const [match] = matches; + return { from: match.from, to: match.to, sql: match.sql }; +} + export function queryResultBaseSql(tab: Pick): string { return resultSqlForGrid(tab); } @@ -157,12 +190,23 @@ export function queryResultExecutionSql(tab: Pick ({ result, index })) .filter((item) => item.result.columns.length > 0) - .map((item, ordinal) => ({ ...item, n: ordinal + 1, label: queryResultStatementLabel(item.result), title: item.result.sourceStatement })); + .map((item, ordinal) => { + const label = queryResultStatementLabel(item.result); + const displayLabel = label ? middleEllipsis(label) : undefined; + return { + ...item, + n: ordinal + 1, + label, + displayLabel, + labelTruncated: !!label && displayLabel !== label, + title: item.result.sourceLabel || item.result.sourceStatement, + }; + }); } export function activeResultRun(tab: Pick) { diff --git a/packages/app-tests/tabPresentation.test.ts b/packages/app-tests/tabPresentation.test.ts index 27294bc233..7209048279 100644 --- a/packages/app-tests/tabPresentation.test.ts +++ b/packages/app-tests/tabPresentation.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { createPinia, setActivePinia } from "pinia"; -import { activeResultRun, databaseDisplayNameForTab, executionSummaryItems, nextExecutionSummaryView, resultGridCacheKey, resultRunItems, resultSqlForGrid, tabDisplayTitle, tabModeLabel, tabularResultItems } from "../../apps/desktop/src/lib/tabs/tabPresentation.ts"; +import { activeResultRun, databaseDisplayNameForTab, executionSummaryItems, middleEllipsis, nextExecutionSummaryView, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, tabDisplayTitle, tabModeLabel, tabularResultItems } from "../../apps/desktop/src/lib/tabs/tabPresentation.ts"; import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; import type { ConnectionConfig, QueryResult, QueryTab } from "../../apps/desktop/src/types/database.ts"; @@ -184,7 +184,7 @@ test("tabular result items expose source labels when available", () => { assert.deepEqual( tabularResultItems(results).map((item) => ({ index: item.index, n: item.n, label: item.label, title: item.title })), [ - { index: 1, n: 1, label: "public.users", title: "select * from public.users" }, + { index: 1, n: 1, label: "public.users", title: "public.users" }, { index: 2, n: 2, label: undefined, title: "select id, name, email, created_at from users where active = true order by created_at desc" }, ], ); @@ -194,6 +194,43 @@ test("tabular result items expose source labels when available", () => { ); }); +test("middleEllipsis preserves the beginning and end of long source labels", () => { + assert.equal(middleEllipsis("easy_manager_tool.tool_monitor_data_index_item"), "easy_manage...index_item"); + assert.equal(middleEllipsis("aaa.apis"), "aaa.apis"); +}); + +test("resultSourceRange uses the result index for repeated SQL", () => { + const sql = "select * from users;\nselect * from users;"; + assert.deepEqual(resultSourceRange(sql, { sourceStatement: "select * from users" }, 1, "mysql"), { + from: sql.lastIndexOf("select"), + to: sql.length - 1, + sql: "select * from users", + }); + assert.equal(resultSourceRange("select * from users;", { sourceStatement: "select * from orders" }, 0, "mysql"), undefined); +}); + +test("resultSourceRange resolves newline-separated MongoDB commands", () => { + const sql = "db.model_field_group.find({})\n\ndb.model_info.find({})"; + const sourceStatement = "db.model_info.find({})"; + + assert.deepEqual(resultSourceRange(sql, { sourceStatement }, 1, "mongodb"), { + from: sql.indexOf(sourceStatement), + to: sql.length, + sql: sourceStatement, + }); +}); + +test("resultSourceRange resolves newline-separated Redis commands", () => { + const sql = "GET first\n\nGET second"; + const sourceStatement = "GET second"; + + assert.deepEqual(resultSourceRange(sql, { sourceStatement }, 1, "redis"), { + from: sql.indexOf(sourceStatement), + to: sql.length, + sql: sourceStatement, + }); +}); + test("resultSqlForGrid prefers the active result source statement", () => { const tab = queryTab({ sql: "select * from users; select * from orders",