feat(query): show live statement execution progress

This commit is contained in:
greathaoqi authored and GitHub committed 2026-07-30 00:44:10 +08:00
1 parent 1774126f76
commit ed018dbc77
27 files changed
+974 -85

No files matched your search

@@ -37,4 +37,17 @@ describe("CodeMirror statement gutter marker", () => {
expect(marker.querySelectorAll(".cm-statement-execution-badge")).toHaveLength(0);
expect(marker.getAttribute("aria-label")).toBe("1 statement failed");
});
it("renders an animated running marker", () => {
const marker = createStatementGutterMarkerDom({
canExecute: false,
executeLabel: "Execute SQL",
status: "running",
statusLabel: "1 statement running",
});
expect(marker.classList.contains("cm-statement-execution-marker--running")).toBe(true);
expect(marker.querySelector(".cm-statement-execution-spinner")).not.toBeNull();
expect(marker.getAttribute("aria-label")).toBe("1 statement running");
});
});
@@ -27,6 +27,18 @@ describe("QueryEditor execution routing", () => {
expect(executeModeBranch).toBeGreaterThan(selectionBranch);
});
it("preserves the source range when executing a current/all candidate without a manual selection", () => {
expect(queryEditorSource).toContain("emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab)");
expect(queryEditorSource).toContain("currentView ? sqlExecutionSnapshotForRange(currentView, candidate) : candidate.sql");
expect(queryEditorSource).toContain("selectionFrom: range.from");
expect(queryEditorSource).toContain("selectionTo: range.to");
});
it("preserves the source range when executing from the statement gutter", () => {
expect(queryEditorSource).toContain("emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, statementRange))");
expect(queryEditorSource).not.toContain('emit("execute", statementRange.sql)');
});
it("lets the shortcut skip the picker without affecting other execution entry points", () => {
// The picker guard must also honor the shortcut's bypass flag, otherwise Ctrl+Enter would keep popping the dialog.
expect(queryEditorSource).toContain("if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker");
+43 -1
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const translations: Record<string, string> = {
@@ -224,6 +225,47 @@ describe("query result source ranges", () => {
});
describe("statement execution markers", () => {
it("renders a live marker for a single statement", () => {
const sql = "SELECT 1";
expect(
statementExecutionMarkers(sql, undefined, "mysql", sql, "", {
executionId: "run-single",
submittedSql: sql,
editorFingerprint: sqlTextFingerprint(sql),
sourceOffset: 0,
completed: 0,
total: 1,
startedAt: 1,
items: [{ statementIndex: 0, sql, from: 0, to: sql.length, status: "running" }],
}),
).toEqual([{ from: 0, status: "running", successCount: 0, errorCount: 0, runningCount: 1 }]);
});
it("renders running and completed markers from live batch state", () => {
const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;";
const secondFrom = sql.indexOf("SELECT 2");
const thirdFrom = sql.indexOf("SELECT 3");
expect(
statementExecutionMarkers(sql, undefined, "sqlite", sql, "", {
executionId: "run-1",
submittedSql: sql,
editorFingerprint: sqlTextFingerprint(sql),
sourceOffset: 0,
completed: 1,
total: 3,
startedAt: 1,
items: [
{ statementIndex: 0, sql: "SELECT 1", from: 0, to: 8, status: "success" },
{ statementIndex: 1, sql: "SELECT 2", from: secondFrom, to: secondFrom + 8, status: "running" },
{ statementIndex: 2, sql: "SELECT 3", from: thirdFrom, to: thirdFrom + 8, status: "pending" },
],
}),
).toEqual([
{ from: 0, status: "success", successCount: 1, errorCount: 0 },
{ from: secondFrom, status: "running", successCount: 0, errorCount: 0, runningCount: 1 },
]);
});
it("projects explicit statement indexes to current editor lines", () => {
const sql = "SELECT 1;\nSELECT * FROM missing;\nSELECT 3;";
const secondFrom = sql.indexOf("SELECT *");
@@ -243,7 +285,7 @@ describe("statement execution markers", () => {
]);
});
it("omits unindexed query-level errors and single-statement executions", () => {
it("omits unindexed query-level errors and legacy single-statement results without live state", () => {
expect(statementExecutionMarkers("SELECT 1; SELECT 2;", [{ columns: ["Error"], rows: [["pool failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }], "mysql", "SELECT 1; SELECT 2;")).toEqual([]);
expect(statementExecutionMarkers("SELECT 1", [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 }], "mysql", "SELECT 1")).toEqual([]);
});
@@ -107,6 +107,43 @@ describe("tab result cache statement execution metadata", () => {
]);
});
it("preserves result-run statement execution state", () => {
const encoded = encodeTabResultSnapshot({
resultRuns: [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "SELECT 1; SELECT bad",
createdAt: 1,
batchSqlExecution: {
executionId: "exec-1",
submittedSql: "SELECT 1; SELECT bad",
editorFingerprint: "20:0123456789abcdef",
sourceOffset: 0,
completed: 2,
total: 2,
startedAt: 1,
finishedAt: 2,
items: [
{ statementIndex: 0, sql: "SELECT 1", from: 0, to: 8, status: "success", executionTimeMs: 1 },
{ statementIndex: 1, sql: "SELECT bad", from: 10, to: 20, status: "error", error: "failed", executionTimeMs: 1 },
],
},
},
],
activeResultRunId: "run-1",
cachedAt: 1,
});
expect(decodeTabResultSnapshot(encoded)?.resultRuns?.[0]?.batchSqlExecution).toMatchObject({
executionId: "exec-1",
completed: 2,
total: 2,
items: [{ status: "success" }, { status: "error", error: "failed" }],
});
});
it("restores multi-result, pagination, local-sort, and editable metadata fixtures", () => {
const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(queryResultLifecycleSnapshot()));
+22 -7
View File
@@ -863,9 +863,13 @@ export async function executeMulti(
export interface ExecuteMultiProgress {
executionId: string;
statementIndex: number;
completed: number;
total: number;
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
}
export async function executeMultiWithProgress(
@@ -885,15 +889,26 @@ export async function executeMultiWithProgress(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
executionId?: string;
},
): Promise<QueryResult[]> {
const executionId = crypto.randomUUID();
const results = await executeMulti(connectionId, database, sql, schema, executionId, options);
onProgress({
executionId,
completed: results.length,
total: results.length,
success: !results.some((result) => result.execution_error === true),
const executionId = options?.executionId ?? crypto.randomUUID();
const { executionId: _executionId, ...executeOptions } = options ?? {};
const results = await executeMulti(connectionId, database, sql, schema, executionId, executeOptions);
const total = results.length;
results.forEach((result, index) => {
const statementIndex = result.statement_index ?? index;
const success = result.execution_error !== true;
onProgress({
executionId,
statementIndex,
completed: index + 1,
total,
success,
executionTimeMs: result.execution_time_ms,
affectedRows: result.affected_rows,
error: success ? undefined : String(result.rows[0]?.[0] ?? ""),
});
});
return results;
}
+8 -9
View File
@@ -1071,9 +1071,13 @@ export async function executeMulti(
export interface ExecuteMultiProgress {
executionId: string;
statementIndex: number;
completed: number;
total: number;
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
}
export async function executeMultiWithProgress(
@@ -1093,21 +1097,16 @@ export async function executeMultiWithProgress(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
executionId?: string;
},
): Promise<QueryResult[]> {
const executionId = crypto.randomUUID();
const executionId = options?.executionId ?? crypto.randomUUID();
const { executionId: _executionId, ...invokeOptions } = options ?? {};
const unlisten = await listen<ExecuteMultiProgress>("query-batch-progress", (event) => {
if (event.payload.executionId === executionId) onProgress(event.payload);
});
try {
return await invoke("execute_multi", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
return await invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...invokeOptions });
} finally {
unlisten();
}
@@ -30,9 +30,16 @@ function createStatusIconDom(status: StatementExecutionMarkerStatus, includeCirc
svg.appendChild(circle);
}
const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
mark.setAttribute("d", status === "error" ? "m16 8-8 8m0-8 8 8" : "m7 12 3 3 7-7");
svg.appendChild(mark);
if (status === "running") {
svg.classList.add("cm-statement-execution-spinner");
const arc = document.createElementNS("http://www.w3.org/2000/svg", "path");
arc.setAttribute("d", "M21 12a9 9 0 1 1-6.22-8.56");
svg.appendChild(arc);
} else {
const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
mark.setAttribute("d", status === "error" ? "m16 8-8 8m0-8 8 8" : "m7 12 3 3 7-7");
svg.appendChild(mark);
}
return svg;
}
+75 -15
View File
@@ -4,7 +4,7 @@ import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
export type OutputView = "result" | "summary" | "explain" | "chart";
@@ -203,13 +203,14 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
return { from: match.from, to: match.to, sql: match.sql };
}
export type StatementExecutionMarkerStatus = "success" | "error";
export type StatementExecutionMarkerStatus = "running" | "success" | "error";
export interface StatementExecutionMarker {
from: number;
status: StatementExecutionMarkerStatus;
successCount: number;
errorCount: number;
runningCount?: number;
}
function lineStartOffset(sql: string, from: number): number {
@@ -222,7 +223,30 @@ function statementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange
return splitSqlStatementRanges(sql, databaseType);
}
export function statementExecutionMarkers(editorSql: string, results: QueryResult[] | undefined, databaseType?: DatabaseType, submittedSql = editorSql, executionEditorFingerprint = sqlTextFingerprint(editorSql)): StatementExecutionMarker[] {
function liveStatementExecutionMarkers(editorSql: string, batch: BatchSqlExecution): StatementExecutionMarker[] {
if (sqlTextFingerprint(editorSql) !== batch.editorFingerprint || batch.total === 0) return [];
const byLine = new Map<number, { success: number; error: number; running: number }>();
for (const item of batch.items) {
if (item.status !== "running" && item.status !== "success" && item.status !== "error") continue;
if (editorSql.slice(item.from, item.to) !== item.sql) continue;
const from = lineStartOffset(editorSql, item.from);
const current = byLine.get(from) ?? { success: 0, error: 0, running: 0 };
current[item.status] += 1;
byLine.set(from, current);
}
return [...byLine.entries()]
.sort(([a], [b]) => a - b)
.map(([from, counts]) => ({
from,
status: counts.error > 0 ? "error" : counts.running > 0 ? "running" : "success",
successCount: counts.success,
errorCount: counts.error,
...(counts.running > 0 ? { runningCount: counts.running } : {}),
}));
}
export function statementExecutionMarkers(editorSql: string, results: QueryResult[] | undefined, databaseType?: DatabaseType, submittedSql = editorSql, executionEditorFingerprint = sqlTextFingerprint(editorSql), batch?: BatchSqlExecution): StatementExecutionMarker[] {
if (batch?.items.length) return liveStatementExecutionMarkers(editorSql, batch);
if (!results?.length || sqlTextFingerprint(editorSql) !== executionEditorFingerprint) return [];
const submittedStatements = statementRanges(submittedSql, databaseType);
if (submittedStatements.length <= 1) return [];
@@ -310,8 +334,14 @@ export function nextExecutionSummaryView(currentView: OutputView, canShowResult:
}
export interface ExecutionSummaryItem {
result: QueryResult;
result?: QueryResult;
index: number;
statementIndex: number;
sql?: string;
sourceFrom?: number;
sourceTo?: number;
status: "pending" | "running" | "success" | "error" | "skipped" | "cancelled";
error?: string;
returnedColumns: number;
returnedRows: number;
affectedRows: number;
@@ -320,18 +350,48 @@ export interface ExecutionSummaryItem {
isError: boolean;
}
export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results">): ExecutionSummaryItem[] {
export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" | "batchSqlExecution">): ExecutionSummaryItem[] {
const results = tab.results?.length ? tab.results : tab.result ? [tab.result] : [];
return results.map((result, index) => ({
result,
index,
returnedColumns: result.columns.length,
returnedRows: result.rows.length,
affectedRows: result.affected_rows,
executionTimeMs: result.execution_time_ms,
hasTabularResult: result.columns.length > 0,
isError: result.columns.includes("Error"),
}));
if (tab.batchSqlExecution?.items.length) {
return tab.batchSqlExecution.items.map((item, index) => {
const result = results.find((candidate, resultIndex) => (candidate.statement_index ?? resultIndex) === item.statementIndex);
return {
result,
index,
statementIndex: item.statementIndex,
sql: item.sql,
sourceFrom: item.from,
sourceTo: item.to,
status: item.status,
error: item.error,
returnedColumns: result?.columns.length ?? 0,
returnedRows: result?.rows.length ?? 0,
affectedRows: item.affectedRows ?? result?.affected_rows ?? 0,
executionTimeMs: item.executionTimeMs ?? result?.execution_time_ms ?? 0,
hasTabularResult: (result?.columns.length ?? 0) > 0,
isError: item.status === "error",
};
});
}
return results.map((result, index) => {
const isError = result.execution_error === true || result.columns.includes("Error");
return {
result,
index,
statementIndex: result.statement_index ?? index,
sql: result.sourceStatement,
sourceFrom: result.sourceFrom,
sourceTo: result.sourceTo,
status: isError ? "error" : "success",
error: isError ? String(result.rows[0]?.[0] ?? "") : undefined,
returnedColumns: result.columns.length,
returnedRows: result.rows.length,
affectedRows: result.affected_rows,
executionTimeMs: result.execution_time_ms,
hasTabularResult: result.columns.length > 0,
isError,
};
});
}
export function tabModeLabel(tab: QueryTab, t: Translate): string {