diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index a7a169391e..aac03b8f26 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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(), { @@ -600,6 +609,7 @@ const emit = defineEmits<{ "update:orderByInput": [value: string]; "local-column-filters-change": [value: Record]; 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
-
-
-
- -
-
-
{{ t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage }) }}
-
- {{ t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests }) }} - {{ formatElapsedSeconds(loadingElapsed) }}s -
-
-
-
-
-
-
-
- - {{ formatElapsedSeconds(loadingElapsed) }}s -
+
diff --git a/apps/desktop/src/components/grid/DataGridBusyOverlay.vue b/apps/desktop/src/components/grid/DataGridBusyOverlay.vue new file mode 100644 index 0000000000..77daf5fdc7 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridBusyOverlay.vue @@ -0,0 +1,79 @@ + + + diff --git a/apps/desktop/src/components/grid/__tests__/DataGridBusyOverlay.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridBusyOverlay.spec.ts new file mode 100644 index 0000000000..b2ec1660dd --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridBusyOverlay.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index fb3eab8087..d5e81313f5 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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) => 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) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)" diff --git a/apps/desktop/src/lib/__tests__/sql/queryExecutionState.spec.ts b/apps/desktop/src/lib/__tests__/sql/queryExecutionState.spec.ts index e41b17e85c..8746289166 100644 --- a/apps/desktop/src/lib/__tests__/sql/queryExecutionState.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/queryExecutionState.spec.ts @@ -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 { 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); + }); +}); diff --git a/apps/desktop/src/lib/sql/queryExecutionState.ts b/apps/desktop/src/lib/sql/queryExecutionState.ts index 8aaf6a1ccc..d6a8874aa4 100644 --- a/apps/desktop/src/lib/sql/queryExecutionState.ts +++ b/apps/desktop/src/lib/sql/queryExecutionState.ts @@ -26,3 +26,16 @@ export function isActiveResultLoading(state: QueryResultLoadingStateLike): boole export function queryExecutionLabelKey(state: Pick): "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; +}