feat(query): show live statement execution progress
This commit is contained in:
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
@@ -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()));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user