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
@@ -635,7 +635,7 @@ function requestExecuteFromView(currentView: EditorViewType, cursorPos: number,
|
||||
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType, parameterOptions)) {
|
||||
const preferredKind = settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all";
|
||||
const candidate = candidates.find((item) => item.kind === preferredKind) ?? candidates[0];
|
||||
emitExecutionRequest(candidate.sql, options.openInNewResultTab);
|
||||
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab);
|
||||
return true;
|
||||
}
|
||||
closePicker();
|
||||
@@ -723,6 +723,22 @@ function previewStatementRange(range: { from: number; to: number } | null) {
|
||||
});
|
||||
}
|
||||
|
||||
function focusStatementRange(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) return;
|
||||
currentView.dispatch({
|
||||
selection: { anchor: from, head: to },
|
||||
effects: [setResultSourceRangeEffect.of({ from, to }), editorViewModule.EditorView.scrollIntoView(from, { y: "center" })],
|
||||
});
|
||||
currentView.focus();
|
||||
}
|
||||
|
||||
function onPickerActiveIndexChange(index: number) {
|
||||
pickerActiveIndex.value = index;
|
||||
const candidate = pickerCandidates.value[index];
|
||||
@@ -732,8 +748,9 @@ function onPickerActiveIndexChange(index: number) {
|
||||
}
|
||||
|
||||
function onPickerConfirm(candidate: SqlExecutionCandidate) {
|
||||
const currentView = view.value;
|
||||
closePicker();
|
||||
emit("execute", candidate.sql);
|
||||
emit("execute", currentView ? sqlExecutionSnapshotForRange(currentView, candidate) : candidate.sql);
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
@@ -1257,7 +1274,7 @@ function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from
|
||||
event.stopPropagation();
|
||||
// Gutter play is always scoped to the statement/command for that line, even
|
||||
// when the main editor execute action would run the full document.
|
||||
emit("execute", statementRange.sql);
|
||||
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, statementRange));
|
||||
currentView.focus();
|
||||
return true;
|
||||
}
|
||||
@@ -1566,6 +1583,16 @@ function sqlExecutionSnapshotFromView(currentView: EditorViewType): SqlExecution
|
||||
};
|
||||
}
|
||||
|
||||
function sqlExecutionSnapshotForRange(currentView: EditorViewType, range: Pick<SqlExecutionCandidate, "sql" | "from" | "to">): SqlExecutionSnapshot {
|
||||
return {
|
||||
fullSql: currentView.state.doc.toString(),
|
||||
selectedSql: range.sql,
|
||||
cursorPos: currentView.state.selection.main.head,
|
||||
selectionFrom: range.from,
|
||||
selectionTo: range.to,
|
||||
};
|
||||
}
|
||||
|
||||
function identifierRangeAt(sql: string, pos: number): { from: number; to: number; text: string } | null {
|
||||
const isIdentifierChar = (ch: string | undefined) => !!ch && /[\w$.]/.test(ch);
|
||||
if (!isIdentifierChar(sql[pos]) && !isIdentifierChar(sql[pos - 1])) return null;
|
||||
@@ -3573,7 +3600,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
eq(other: import("@codemirror/view").GutterMarker): boolean {
|
||||
return other instanceof StatementExecutionStateMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount;
|
||||
return other instanceof StatementExecutionStateMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount && other.marker.runningCount === this.marker.runningCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3586,7 +3613,14 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
eq(other: import("@codemirror/view").GutterMarker): boolean {
|
||||
return other instanceof StatementGutterMarker && other.canExecute === this.canExecute && other.marker?.status === this.marker?.status && other.marker?.successCount === this.marker?.successCount && other.marker?.errorCount === this.marker?.errorCount;
|
||||
return (
|
||||
other instanceof StatementGutterMarker &&
|
||||
other.canExecute === this.canExecute &&
|
||||
other.marker?.status === this.marker?.status &&
|
||||
other.marker?.successCount === this.marker?.successCount &&
|
||||
other.marker?.errorCount === this.marker?.errorCount &&
|
||||
other.marker?.runningCount === this.marker?.runningCount
|
||||
);
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
@@ -3601,6 +3635,7 @@ onMounted(async () => {
|
||||
|
||||
function statementExecutionMarkerTitle(marker: StatementExecutionMarker) {
|
||||
const parts = [];
|
||||
if ((marker.runningCount ?? 0) > 0) parts.push(t("editor.statementExecutionRunning", { count: marker.runningCount }));
|
||||
if (marker.successCount > 0) parts.push(t("editor.statementExecutionSucceeded", { count: marker.successCount }));
|
||||
if (marker.errorCount > 0) parts.push(t("editor.statementExecutionFailed", { count: marker.errorCount }));
|
||||
return parts.join(", ");
|
||||
@@ -4569,6 +4604,7 @@ defineExpose({
|
||||
requestExecute,
|
||||
requestExecuteInNewResultTab,
|
||||
pasteClipboardAsSqlInCondition,
|
||||
focusStatementRange,
|
||||
previewStatementRange,
|
||||
refreshCompletionCache,
|
||||
});
|
||||
@@ -4681,6 +4717,11 @@ defineExpose({
|
||||
color: rgb(4 120 87);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker--running) {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker--error) {
|
||||
background: rgb(239 68 68 / 0.1);
|
||||
color: rgb(185 28 28);
|
||||
@@ -4772,6 +4813,10 @@ defineExpose({
|
||||
background: rgb(5 150 105);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-badge--running) {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-badge--error) {
|
||||
background: rgb(220 38 38);
|
||||
}
|
||||
@@ -4782,6 +4827,16 @@ defineExpose({
|
||||
height: 75%;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-spinner) {
|
||||
animation: dbx-statement-execution-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dbx-statement-execution-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.cm-foldMarker-svg) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -70,7 +70,7 @@ import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGr
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
|
||||
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems, type ExecutionSummaryItem } from "@/lib/tabs/tabPresentation";
|
||||
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
|
||||
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { isTableDataEditable } from "@/lib/table/tableEditing";
|
||||
@@ -368,6 +368,7 @@ const activeStatementExecutionMarkers = computed(() =>
|
||||
activeEffectiveDatabaseType.value,
|
||||
props.activeTab.resultBaseSql || props.activeTab.lastExecutedSql || props.activeTab.sql,
|
||||
props.activeTab.resultEditorFingerprint ?? "",
|
||||
props.activeTab.batchSqlExecution,
|
||||
),
|
||||
);
|
||||
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
|
||||
@@ -401,6 +402,11 @@ watch(
|
||||
);
|
||||
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
|
||||
const hasExecutionSummary = computed(() => summaryItems.value.length > 0 || props.activeTab.isExecuting);
|
||||
const batchExecutionProgress = computed(() => props.activeTab.batchSqlExecution);
|
||||
const batchExecutionPercent = computed(() => {
|
||||
const progress = batchExecutionProgress.value;
|
||||
return progress?.total ? Math.round((progress.completed / progress.total) * 100) : 0;
|
||||
});
|
||||
const hasTabularResult = computed(() => {
|
||||
if (props.activeTab.result?.columns.length) return true;
|
||||
return visibleResultItems.value.length > 0;
|
||||
@@ -851,6 +857,21 @@ function selectResultItem(item: (typeof visibleResultItems.value)[number]) {
|
||||
});
|
||||
}
|
||||
|
||||
function executionSummaryItemRange(item: ExecutionSummaryItem) {
|
||||
if (typeof item.sourceFrom === "number" && typeof item.sourceTo === "number" && item.sql && props.activeTab.sql.slice(item.sourceFrom, item.sourceTo) === item.sql) {
|
||||
return { from: item.sourceFrom, to: item.sourceTo };
|
||||
}
|
||||
return item.result ? resultSourceRange(props.activeTab.sql, item.result, item.statementIndex, activeEffectiveDatabaseType.value) : undefined;
|
||||
}
|
||||
|
||||
function previewExecutionSummaryItem(item: ExecutionSummaryItem) {
|
||||
queryEditorRef.value?.previewStatementRange(executionSummaryItemRange(item) ?? null);
|
||||
}
|
||||
|
||||
function focusExecutionSummaryItem(item: ExecutionSummaryItem) {
|
||||
queryEditorRef.value?.focusStatementRange(executionSummaryItemRange(item) ?? 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;
|
||||
@@ -1296,37 +1317,68 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
|
||||
|
||||
<div v-else-if="activeOutputView === 'summary'" class="flex-1 min-h-0 overflow-auto bg-background">
|
||||
<div v-if="activeTab.isExecuting" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("executionSummary.executing") }}
|
||||
<div v-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 v-if="activeTab.isExecuting" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<template v-if="activeTab.isExecuting">{{ t("executionSummary.executing") }}</template>
|
||||
<template v-else>{{ t("executionSummary.empty") }}</template>
|
||||
</div>
|
||||
<div v-else-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("executionSummary.empty") }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="min-w-[46rem]">
|
||||
<div v-if="batchExecutionProgress" class="sticky top-0 z-10 border-b bg-background/95 px-3 py-2 backdrop-blur">
|
||||
<div class="mb-1.5 flex items-center gap-3 text-xs">
|
||||
<span class="font-medium">{{ activeTab.isExecuting ? t("executionSummary.executing") : t("executionSummary.finished") }}</span>
|
||||
<span class="tabular-nums text-muted-foreground">{{ batchExecutionProgress.completed }} / {{ batchExecutionProgress.total }}</span>
|
||||
<span class="ml-auto tabular-nums text-muted-foreground">{{ batchExecutionPercent }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-[width] duration-200" :style="{ width: `${batchExecutionPercent}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden border-b">
|
||||
<div class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div class="grid grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div>{{ t("executionSummary.statement") }}</div>
|
||||
<div>{{ t("executionSummary.type") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.rows") }}</div>
|
||||
<div>{{ t("executionSummary.sql") }}</div>
|
||||
<div>{{ t("executionSummary.status") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.affected") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.time") }}</div>
|
||||
</div>
|
||||
<div v-for="item in summaryItems" :key="item.index" class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] items-center border-b px-3 py-2 text-xs last:border-b-0">
|
||||
<div class="font-mono text-muted-foreground">#{{ item.index + 1 }}</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="inline-flex h-5 items-center rounded-full border px-2 text-[10px]" :class="item.isError ? 'border-destructive/40 bg-destructive/10 text-destructive' : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'">
|
||||
{{ item.isError ? t("executionSummary.error") : t("executionSummary.success") }}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
{{ item.hasTabularResult ? t("executionSummary.returnedTable", { count: item.returnedColumns }) : t("executionSummary.noTable") }}
|
||||
<button
|
||||
v-for="item in summaryItems"
|
||||
:key="item.statementIndex"
|
||||
type="button"
|
||||
class="grid w-full grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] items-center border-b px-3 py-2 text-left text-xs transition-colors last:border-b-0 hover:bg-muted/35 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
:title="item.error || item.sql"
|
||||
@click="previewExecutionSummaryItem(item)"
|
||||
@dblclick="focusExecutionSummaryItem(item)"
|
||||
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
|
||||
>
|
||||
<div class="font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
|
||||
<div v-if="item.error" class="mt-0.5 truncate text-[11px] text-destructive">{{ item.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border px-2 text-[10px]"
|
||||
:class="{
|
||||
'border-primary/35 bg-primary/10 text-primary': item.status === 'running',
|
||||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300': item.status === 'success',
|
||||
'border-destructive/40 bg-destructive/10 text-destructive': item.status === 'error',
|
||||
'border-border bg-muted/40 text-muted-foreground': item.status === 'pending' || item.status === 'skipped',
|
||||
'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300': item.status === 'cancelled',
|
||||
}"
|
||||
>
|
||||
<Loader2 v-if="item.status === 'running'" class="h-3 w-3 animate-spin" />
|
||||
<Check v-else-if="item.status === 'success'" class="h-3 w-3" />
|
||||
<X v-else-if="item.status === 'error'" class="h-3 w-3" />
|
||||
<SquareDashed v-else class="h-3 w-3" />
|
||||
{{ t(`executionSummary.statuses.${item.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right tabular-nums">{{ item.returnedRows.toLocaleString() }}</div>
|
||||
<div class="text-right tabular-nums">{{ item.affectedRows.toLocaleString() }}</div>
|
||||
<div class="text-right tabular-nums">{{ item.executionTimeMs }}ms</div>
|
||||
</div>
|
||||
<div class="text-right tabular-nums">{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.affectedRows.toLocaleString() }}</div>
|
||||
<div class="text-right tabular-nums">{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-3 py-2 text-[11px] text-muted-foreground">{{ t("executionSummary.navigationHint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user