feat(query): show result source ranges

This commit is contained in:
二丫讲梵
2026-07-13 18:52:14 +08:00
committed by GitHub
parent d1cce3ed1e
commit 6f5dffb279
5 changed files with 248 additions and 26 deletions
@@ -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<SqlSemanticDiagnostic[]> | 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 });
</script>
<template>
@@ -3495,6 +3561,15 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pa
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35));
}
:deep(.cm-lineNumbers .cm-db-result-source-line-number) {
color: rgb(126 34 206) !important;
font-weight: 700;
}
:global(.dark) :deep(.cm-lineNumbers .cm-db-result-source-line-number) {
color: rgb(216 180 254) !important;
}
:deep(.cm-db-current-statement-line) {
position: relative;
}
@@ -62,7 +62,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, resultGridCacheKey, resultRunItems, resultSqlForGrid, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { databaseDisplayNameForTab, executionSummaryItems, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { isTableDataEditable } from "@/lib/table/tableEditing";
@@ -729,6 +729,14 @@ function toggleResultAutoSave() {
toast(t(enabled ? "tabs.autoKeepResultsEnabled" : "tabs.autoKeepResultsDisabled"), 2500);
}
function selectResultItem(item: (typeof visibleResultItems.value)[number]) {
queryStore.setActiveResultIndex(props.activeTab.id, item.index);
emit("update:activeOutputView", "result");
nextTick(() => {
queryEditorRef.value?.previewStatementRange(resultSourceRange(props.activeTab.sql, item.result, item.index, activeEffectiveDatabaseType.value) ?? null);
});
}
function handleModRTarget(target: Element): boolean {
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
@@ -852,20 +860,11 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
<div class="result-tab-scrollbar__thumb" :style="resultTabsScrollbarThumbStyle" />
</div>
<div ref="resultTabsScrollerRef" class="result-tab-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :style="resultTabsScrollerStyle" @scroll="updateResultTabsScrollbar" @wheel="onResultTabsWheel">
<Button
v-for="item in visibleResultItems"
:key="item.index"
size="sm"
:variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'"
class="h-6 max-w-48 shrink-0 overflow-hidden text-ellipsis whitespace-nowrap px-2 text-xs"
:title="item.title || item.label || t('tabs.resultN', { n: item.n })"
@click="
queryStore.setActiveResultIndex(activeTab.id, item.index);
emit('update:activeOutputView', 'result');
"
>
{{ item.label || t("tabs.resultN", { n: item.n }) }}
</Button>
<LightTooltip v-for="item in visibleResultItems" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :disabled="!item.labelTruncated && !(!item.label && item.title)" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'" class="h-6 min-w-0 max-w-48 shrink-0 px-2 text-xs" :aria-label="item.label || t('tabs.resultN', { n: item.n })" @click="selectResultItem(item)">
<span class="block min-w-0 max-w-44 whitespace-nowrap">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
</div>
</div>
</template>
+70 -3
View File
@@ -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>): 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();
});
});
+47 -3
View File
@@ -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, unknown>) => string;
export type OutputView = "result" | "summary" | "explain" | "chart";
@@ -145,10 +147,41 @@ export function queryResultStatementLabel(result: Pick<QueryResult, "sourceLabel
return result.sourceLabel;
}
export function middleEllipsis(value: string, maxLength = 24): string {
if (value.length <= maxLength) return value;
if (maxLength <= 3) return ".".repeat(Math.max(0, maxLength));
const visibleLength = maxLength - 3;
const startLength = Math.ceil(visibleLength / 2);
const endLength = Math.floor(visibleLength / 2);
const end = endLength > 0 ? value.slice(-endLength) : "";
return `${value.slice(0, startLength)}...${end}`;
}
export function resultSqlForGrid(tab: Pick<QueryTab, "result" | "resultBaseSql" | "lastExecutedSql" | "sql">): 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<QueryResult, "sourceStatement"> | 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<QueryTab, "result" | "resultBaseSql" | "lastExecutedSql" | "sql">): string {
return resultSqlForGrid(tab);
}
@@ -157,12 +190,23 @@ export function queryResultExecutionSql(tab: Pick<QueryTab, "result" | "resultBa
return tab.resultSortedSql || resultSqlForGrid(tab);
}
export function tabularResultItems(results: QueryResult[] | undefined): { result: QueryResult; index: number; n: number; label?: string; title?: string }[] {
export function tabularResultItems(results: QueryResult[] | undefined): { result: QueryResult; index: number; n: number; label?: string; displayLabel?: string; labelTruncated: boolean; title?: string }[] {
if (!results) return [];
return results
.map((result, index) => ({ 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<QueryTab, "resultRuns" | "activeResultRunId">) {
+39 -2
View File
@@ -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",