feat(grid): add stop button to loading and refresh overlay

This commit is contained in:
zipg authored and GitHub committed 2026-09-24 19:13:32 +08:00
1 parent 124d7ae6cc
commit 05a72de255
6 files changed
+225 -29

No files matched your search

+12 -27
View File
@@ -63,6 +63,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import DataGridBusyOverlay from "@/components/grid/DataGridBusyOverlay.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import LightDropdownMenu from "@/components/ui/LightDropdownMenu.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
@@ -98,7 +99,6 @@ import { shouldNavigateFromTableInfoColumnClick } from "@/lib/table/tableInfoCol
import { tableInfoTabForDrawerToggle } from "@/lib/table/tableInfoTabPreference";
import { findTableStatistics } from "@/lib/dataGrid/tableInfoOverview";
import * as api from "@/lib/backend/api";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
import { createColumnDrafts } from "@/lib/table/tableStructureEditorState";
@@ -553,6 +553,15 @@ interface DataGridProps {
queryEditabilityReason?: QueryEditabilityReason;
allowInsertRows?: boolean;
allowDeleteRows?: boolean;
/**
* Offers a stop action in the busy overlay. Query and data tabs pass this
* while an execution with an id is running, so the elapsed pill can also be
* used to end a slow load/refresh (#9979-adjacent feedback). Loaders that
* call the backend directly cannot be cancelled and keep it off.
*/
showCancel?: boolean;
cancelling?: boolean;
cancelDisabled?: boolean;
}
const props = withDefaults(defineProps<DataGridProps>(), {
@@ -600,6 +609,7 @@ const emit = defineEmits<{
"update:orderByInput": [value: string];
"local-column-filters-change": [value: Record<string, string[]>];
changeQueryTimeout: [connectionId: string];
cancel: [];
}>();
const autoRefresh = useDataGridAutoRefresh({
@@ -3180,11 +3190,6 @@ const canFetchNextInfiniteScrollSegment = computed(() =>
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const pageJumpBusy = computed(() => !!props.pageJumpProgress && props.pageJumpProgress.totalRequests > 1);
const pageJumpProgressPercent = computed(() => {
const progress = props.pageJumpProgress;
if (!progress || progress.totalRequests <= 0) return 0;
return Math.min(100, Math.round((progress.completedRequests / progress.totalRequests) * 100));
});
/** Automatic background counts keep rows interactive; explicit count navigation still blocks the surface. */
const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || manualTotalRowCountLoading.value || pageJumpBusy.value);
const gridPaginationBusy = computed(() => gridSurfaceBusy.value || totalRowCountBusy.value);
@@ -13646,27 +13651,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
</div>
<div v-if="gridSurfaceBusy" class="absolute inset-0 z-20 flex items-center justify-center" :class="pageJumpProgress ? 'bg-background/35 backdrop-blur-[1px]' : 'bg-background/50'">
<div v-if="pageJumpProgress" class="w-72 max-w-[calc(100%-2rem)] rounded-lg border bg-background/95 p-3.5 shadow-lg">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Loader2 class="h-4 w-4 animate-spin" />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage }) }}</div>
<div class="mt-0.5 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span>{{ t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests }) }}</span>
<span class="shrink-0 tabular-nums">{{ formatElapsedSeconds(loadingElapsed) }}s</span>
</div>
</div>
</div>
<div class="mt-3 h-1.5 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="pageJumpProgress.totalRequests" :aria-valuenow="pageJumpProgress.completedRequests">
<div class="h-full rounded-full bg-primary transition-[width] duration-200 ease-out" :style="{ width: `${pageJumpProgressPercent}%` }" />
</div>
</div>
<div v-else class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span class="tabular-nums">{{ formatElapsedSeconds(loadingElapsed) }}s</span>
</div>
<DataGridBusyOverlay :elapsed-ms="loadingElapsed" :page-jump-progress="pageJumpProgress" :show-cancel="showCancel" :cancelling="cancelling" :cancel-disabled="cancelDisabled" @cancel="emit('cancel')" />
</div>
</template>
</div>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from "vue";
import { Loader2, Square } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import type { QueryPageJumpProgress } from "@/types/database";
const props = withDefaults(
defineProps<{
/** Elapsed time of the current busy surface, in milliseconds. */
elapsedMs: number;
/** Multi-request page jump progress; renders the larger progress card. */
pageJumpProgress?: QueryPageJumpProgress;
/**
* Shows the stop action. Off by default: loaders that call the backend
* directly and cannot be cancelled keep the plain elapsed pill.
*/
showCancel?: boolean;
cancelling?: boolean;
cancelDisabled?: boolean;
}>(),
{
pageJumpProgress: undefined,
showCancel: false,
cancelling: false,
cancelDisabled: false,
},
);
const emit = defineEmits<{
cancel: [];
}>();
const { t } = useI18n();
const pageJumpProgressPercent = computed(() => {
const progress = props.pageJumpProgress;
if (!progress) return 0;
const total = Math.max(1, progress.totalRequests);
return Math.max(0, Math.min(100, (progress.completedRequests / total) * 100));
});
</script>
<template>
<div v-if="pageJumpProgress" class="w-72 max-w-[calc(100%-2rem)] rounded-lg border bg-background/95 p-3.5 shadow-lg">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Loader2 class="h-4 w-4 animate-spin" />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage }) }}</div>
<div class="mt-0.5 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span>{{ t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests }) }}</span>
<span class="shrink-0 tabular-nums">{{ formatElapsedSeconds(elapsedMs) }}s</span>
</div>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="pageJumpProgress.totalRequests" :aria-valuenow="pageJumpProgress.completedRequests">
<div class="h-full rounded-full bg-primary transition-[width] duration-200 ease-out" :style="{ width: `${pageJumpProgressPercent}%` }" />
</div>
<Button v-if="showCancel" variant="destructive" size="sm" class="data-grid-loading-cancel-button h-6 shrink-0 gap-1 px-2 text-xs" :disabled="cancelDisabled || cancelling" @click="emit('cancel')">
<Loader2 v-if="cancelling" class="h-3 w-3 animate-spin" />
<Square v-else class="h-3 w-3 fill-current" />
{{ t(cancelling ? "common.stopping" : "toolbar.stopQuery") }}
</Button>
</div>
</div>
<div v-else class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span class="tabular-nums">{{ formatElapsedSeconds(elapsedMs) }}s</span>
<Button v-if="showCancel" variant="destructive" size="sm" class="data-grid-loading-cancel-button h-6 shrink-0 gap-1 px-2 text-xs" :disabled="cancelDisabled || cancelling" @click="emit('cancel')">
<Loader2 v-if="cancelling" class="h-3 w-3 animate-spin" />
<Square v-else class="h-3 w-3 fill-current" />
{{ t(cancelling ? "common.stopping" : "toolbar.stopQuery") }}
</Button>
</div>
</template>
@@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { dispatch, findAll, findOne, hostText, mountComponent, type HostNode } from "./vueHostHarness";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", async () => {
const { createPassthroughStub } = await import("./vueHostHarness");
const icon = createPassthroughStub("Icon", "i");
return { Loader2: icon, Square: icon };
});
vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHostHarness")).createPassthroughStub("Button", "button") }));
import DataGridBusyOverlay from "@/components/grid/DataGridBusyOverlay.vue";
const findCancelButton = (root: HostNode) => findAll(root, (node) => node.props["data-stub"] === "Button");
describe("DataGridBusyOverlay", () => {
it("shows the elapsed seconds without a stop action by default", () => {
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 12_340 });
expect(hostText(mounted.root)).toContain("12.34s");
expect(findCancelButton(mounted.root)).toHaveLength(0);
});
it("formats long waits and guards non-finite elapsed values", async () => {
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 312_450 });
expect(hostText(mounted.root)).toContain("312.45s");
await mounted.setProps({ elapsedMs: Number.NaN });
expect(hostText(mounted.root)).toContain("0.00s");
});
it("stops the running load from the elapsed pill", () => {
const cancel = vi.fn();
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 1_000, showCancel: true, onCancel: cancel });
const button = findCancelButton(mounted.root)[0];
expect(hostText(button)).toContain("toolbar.stopQuery");
expect(button.props.disabled).toBe(false);
dispatch(button, "click");
expect(cancel).toHaveBeenCalledTimes(1);
});
it("switches to the cancelling state and blocks repeat clicks", () => {
const cancel = vi.fn();
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 900, showCancel: true, cancelling: true, onCancel: cancel });
const button = findCancelButton(mounted.root)[0];
expect(hostText(button)).toContain("common.stopping");
expect(button.props.disabled).toBe(true);
dispatch(button, "click");
expect(cancel).not.toHaveBeenCalled();
});
it("honours an explicit cancel-disabled state", () => {
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 900, showCancel: true, cancelDisabled: true });
expect(findCancelButton(mounted.root)[0].props.disabled).toBe(true);
});
it("keeps the page-jump card, its progress bar, and the stop action together", () => {
const mounted = mountComponent(DataGridBusyOverlay, {
elapsedMs: 45_000,
showCancel: true,
pageJumpProgress: { completedRequests: 3, totalRequests: 12, targetPage: 40 },
});
expect(hostText(mounted.root)).toContain("grid.pageJumpLoading");
expect(hostText(mounted.root)).toContain("grid.pageJumpProgress");
expect(hostText(mounted.root)).toContain("45.00s");
const progressbar = findOne(mounted.root, (node) => node.props.role === "progressbar");
expect(progressbar.props["aria-valuenow"]).toBe(3);
expect(progressbar.props["aria-valuemax"]).toBe(12);
expect(findCancelButton(mounted.root)).toHaveLength(1);
});
it("hides the stop action on the page-jump card for callers that cannot cancel", () => {
const mounted = mountComponent(DataGridBusyOverlay, {
elapsedMs: 45_000,
pageJumpProgress: { completedRequests: 1, totalRequests: 9, targetPage: 3 },
});
expect(findCancelButton(mounted.root)).toHaveLength(0);
});
});
@@ -140,7 +140,7 @@ import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps } from "@/compone
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridRowNumberMode, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey, shouldShowCancelAction } from "@/lib/sql/queryExecutionState";
import { sqlErrorDisplayPosition, sqlErrorEditorOffset, logSqlErrorPosition } from "@/lib/sql/errorPosition";
import {
databaseDisplayNameForTab,
@@ -2317,6 +2317,10 @@ defineExpose({
"
:all-export-results="allResultExportSheets"
:export-file-base-name="activeQueryResultExportBaseName"
:show-cancel="shouldShowCancelAction(activeTab)"
:cancelling="activeTab.isCancelling"
:cancel-disabled="!canCancelQueryExecution(activeTab)"
@cancel="emit('cancel', activeTab.id)"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@@ -2742,6 +2746,10 @@ defineExpose({
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
:export-file-base-name="activeTab.title"
:show-cancel="shouldShowCancelAction(activeTab)"
:cancelling="activeTab.isCancelling"
:cancel-disabled="!canCancelQueryExecution(activeTab)"
@cancel="emit('cancel', activeTab.id)"
@update:where-input="(v: string) => (activeTab.whereInput = v)"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isActiveResultLoading, type QueryResultLoadingStateLike } from "@/lib/sql/queryExecutionState";
import { canCancelQueryExecution, isActiveResultLoading, shouldShowCancelAction, type QueryResultLoadingStateLike } from "@/lib/sql/queryExecutionState";
function state(overrides: Partial<QueryResultLoadingStateLike> = {}): QueryResultLoadingStateLike {
return {
@@ -33,3 +33,26 @@ describe("active query result loading", () => {
expect(isActiveResultLoading(state({ isExecuting: true, activeResultRunId: "run-a" }))).toBe(true);
});
});
describe("stop action visibility", () => {
it("hides the action when nothing is running", () => {
expect(shouldShowCancelAction(state())).toBe(false);
expect(shouldShowCancelAction(state({ executionId: "exec-1" }))).toBe(false);
});
it("hides the action while an execution has no cancellable id yet", () => {
expect(shouldShowCancelAction(state({ isExecuting: true }))).toBe(false);
});
it("keeps the action visible while a cancel request is in flight", () => {
const cancelling = state({ isExecuting: true, executionId: "exec-1", isCancelling: true });
expect(shouldShowCancelAction(cancelling)).toBe(true);
// The button must switch to its cancelling state instead of being pressable again.
expect(canCancelQueryExecution(cancelling)).toBe(false);
});
it("allows only one cancel request for an idle execution", () => {
expect(shouldShowCancelAction(state({ isExecuting: true, executionId: "exec-1" }))).toBe(true);
expect(canCancelQueryExecution(state({ isExecuting: true, executionId: "exec-1" }))).toBe(true);
});
});
@@ -26,3 +26,16 @@ export function isActiveResultLoading(state: QueryResultLoadingStateLike): boole
export function queryExecutionLabelKey(state: Pick<QueryExecutionStateLike, "isCancelling">): "common.loading" | "common.stopping" {
return state.isCancelling ? "common.stopping" : "common.loading";
}
/**
* Whether the stop action should be visible for a running execution.
*
* Unlike `canCancelQueryExecution` this stays true while a cancel request is in
* flight, so the control keeps its place and only switches to the cancelling
* state instead of disappearing. Executions without an id (for example the
* metadata/preparation window of a data-tab reload) have nothing to cancel, so
* the caller must not offer the action there.
*/
export function shouldShowCancelAction(state: QueryExecutionStateLike): boolean {
return state.isExecuting && !!state.executionId;
}