fix(sidebar): keep destructive table operations cancellable

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-20 15:14:45 +08:00
1 parent dd77817c7e
commit 89e0f1734b
20 files changed
+627 -25

No files matched your search

@@ -33,6 +33,8 @@ const props = withDefaults(
suppressToggleLabel?: string;
loading?: boolean;
closeOnConfirm?: boolean;
cancelable?: boolean;
cancelRunningLoading?: boolean;
}>(),
{
sql: "",
@@ -45,11 +47,14 @@ const props = withDefaults(
suppressToggleLabel: "",
loading: false,
closeOnConfirm: true,
cancelable: false,
cancelRunningLoading: false,
},
);
const emit = defineEmits<{
confirm: [];
"cancel-running": [];
}>();
const code = computed(() => props.details || props.sql);
@@ -119,7 +124,11 @@ async function copyFullCode() {
</div>
<DialogFooter>
<Button variant="outline" :disabled="loading" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button v-if="loading && cancelable" variant="outline" :disabled="cancelRunningLoading" @click="$emit('cancel-running')">
<Loader2 v-if="cancelRunningLoading" class="h-3.5 w-3.5 animate-spin" />
{{ t("dangerDialog.cancelRunning") }}
</Button>
<Button v-else variant="outline" :disabled="loading" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" class="gap-1.5" :disabled="loading" @click="onConfirm">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
{{ confirmLabel || t("dangerDialog.confirm") }}
@@ -20,7 +20,7 @@ vi.mock("@/lib/common/clipboard", () => ({
const mountedApps: App[] = [];
async function mountDialog(sql: string) {
async function mountDialog(sql: string, extraProps: Record<string, unknown> = {}, listeners: Record<string, (...args: any[]) => void> = {}) {
const state = reactive({ open: true });
const container = document.createElement("div");
document.body.append(container);
@@ -30,6 +30,8 @@ async function mountDialog(sql: string) {
h(DangerConfirmDialog, {
open: state.open,
sql,
...extraProps,
...listeners,
"onUpdate:open": (value: boolean) => {
state.open = value;
},
@@ -106,3 +108,51 @@ describe("DangerConfirmDialog SQL preview", () => {
expect(copyToClipboard).toHaveBeenCalledWith(sql);
});
});
describe("DangerConfirmDialog running/cancel footer state", () => {
function footerButtons() {
const footer = document.body.querySelectorAll("button");
return Array.from(footer);
}
it("keeps the default Cancel button unchanged when cancelable is not set (existing callers)", async () => {
await mountDialog("DROP TABLE users;", { loading: true });
const buttons = footerButtons();
const cancelButton = buttons.find((button) => button.textContent?.trim() === "Cancel");
expect(cancelButton).toBeDefined();
expect(cancelButton?.disabled).toBe(true);
expect(buttons.some((button) => button.textContent?.trim() === "Cancel Query")).toBe(false);
});
it("shows an active Cancel Query button while loading when cancelable is true", async () => {
const onCancelRunning = vi.fn();
await mountDialog("DELETE FROM big_orders;", { loading: true, cancelable: true }, { onCancelRunning });
const buttons = footerButtons();
const cancelRunningButton = buttons.find((button) => button.textContent?.trim() === "Cancel Query");
expect(cancelRunningButton).toBeDefined();
expect(cancelRunningButton?.disabled).toBe(false);
cancelRunningButton?.click();
await nextTick();
expect(onCancelRunning).toHaveBeenCalledOnce();
});
it("disables the Cancel Query button while the cancel itself is in flight", async () => {
await mountDialog("DELETE FROM big_orders;", { loading: true, cancelable: true, cancelRunningLoading: true });
const buttons = footerButtons();
const cancelRunningButton = buttons.find((button) => button.textContent?.trim() === "Cancel Query");
expect(cancelRunningButton?.disabled).toBe(true);
});
it("does not show the Cancel Query button when cancelable is true but not loading", async () => {
await mountDialog("DELETE FROM big_orders;", { loading: false, cancelable: true });
const buttons = footerButtons();
expect(buttons.some((button) => button.textContent?.trim() === "Cancel Query")).toBe(false);
expect(buttons.some((button) => button.textContent?.trim() === "Cancel")).toBe(true);
});
});
@@ -62,7 +62,7 @@ import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import { resetSidebarTreeDialogState } from "./sidebarTreeDialogState";
import { resetSidebarTreeDialogState, sidebarDangerRunningExecutionId } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleNacosNamespacesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
@@ -103,6 +103,7 @@ const sidebarContextMenuTarget = ref<SidebarActionTarget | null>(null);
const sidebarDangerDialogRequest = ref<SidebarDangerDialogRequest | null>(null);
const sidebarDangerDialogOpen = ref(false);
const sidebarDangerDialogConfirming = ref(false);
const sidebarDangerDialogCancelling = ref(false);
const sidebarTreeItemDialogController = ref<Record<string, any> | null>(null);
const sidebarInstallExtensionTarget = ref<TreeNode | null>(null);
const sidebarInstallExtensionDialogRef = ref<InstanceType<typeof InstallExtensionDialog> | null>(null);
@@ -1594,6 +1595,11 @@ function openSidebarContextMenu(event: MouseEvent, node: TreeNode, openContextMe
function openSidebarDangerDialog(request: SidebarDangerDialogRequest) {
sidebarDangerDialogRequest.value = request;
sidebarDangerDialogConfirming.value = false;
// Defense in depth: sidebarDangerDialogCancelling is a singleton shared
// across every danger dialog. It should already settle on its own (see
// confirmCancelWithRetryAndTimeout), but a fresh dialog must never inherit
// a stuck "cancelling" state from a previous one.
sidebarDangerDialogCancelling.value = false;
sidebarDangerDialogOpen.value = true;
}
@@ -1604,9 +1610,43 @@ async function confirmSidebarDangerDialog() {
sidebarDangerDialogConfirming.value = true;
try {
await request.confirm();
sidebarDangerDialogOpen.value = false;
} finally {
// A danger operation that hit a client-observed timeout is kept alive
// (still cancellable) rather than settled outright — sidebarDangerRunningExecutionId
// stays populated in that case, so keep the dialog "loading" (Cancel
// Query still live, manual dismiss blocked) instead of closing on a
// stale timeout result. The watcher below finishes the job once the
// execution actually settles.
if (!sidebarDangerRunningExecutionId.value) {
sidebarDangerDialogConfirming.value = false;
sidebarDangerDialogOpen.value = false;
}
}
}
// Finishes closing a danger dialog left open past a client-observed timeout
// once the deferred execution is actually confirmed cancelled — see
// confirmSidebarDangerDialog above.
watch(sidebarDangerRunningExecutionId, (value) => {
if (!value && sidebarDangerDialogConfirming.value) {
sidebarDangerDialogConfirming.value = false;
sidebarDangerDialogOpen.value = false;
}
});
async function cancelSidebarDangerDialogRunning() {
const request = sidebarDangerDialogRequest.value;
if (!request?.cancelRunning || sidebarDangerDialogCancelling.value) return;
sidebarDangerDialogCancelling.value = true;
try {
await request.cancelRunning();
} catch (error: any) {
// Current cancelRunning implementations already swallow their own
// rejections; this is a defensive fallback so the user still gets
// feedback if a future implementation throws instead.
toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000);
} finally {
sidebarDangerDialogCancelling.value = false;
}
}
@@ -2433,7 +2473,10 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:confirm-label="sidebarDangerDialogRequest.confirmLabel"
:loading="sidebarDangerDialogConfirming || sidebarDangerDialogRequest.loading"
:close-on-confirm="false"
:cancelable="!!sidebarDangerDialogRequest.cancelRunning"
:cancel-running-loading="sidebarDangerDialogCancelling"
@confirm="confirmSidebarDangerDialog"
@cancel-running="cancelSidebarDangerDialogRunning"
>
<template #options>
<div v-if="sidebarDangerDialogConfirming && sidebarDangerDialogRequest.progress" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
@@ -68,6 +68,8 @@ import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/backend/api";
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { uuid } from "@/lib/common/utils";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
@@ -175,6 +177,7 @@ import {
fallbackCreateDatabaseCharset,
sidebarTreeDialogOwner,
sidebarDangerTarget,
sidebarDangerRunningCancel,
sidebarFormTarget,
showDeleteConfirm,
showDropTableConfirm,
@@ -2490,15 +2493,29 @@ function openRenameObjectDialog() {
showRenameObjectDialog.value = true;
}
async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options: { database?: string; schema?: string; executeAsScript?: boolean } = {}) {
async function executeTreeNodeSqlWithProductionGuard(
node: Pick<TreeNode, "connectionId" | "database" | "schema">,
sql: string,
options: { database?: string; schema?: string; executeAsScript?: boolean; executionId?: string; isCancelledBeforeDispatch?: () => boolean; markDispatched?: () => void } = {},
) {
if (!node.connectionId) return undefined;
const database = options.database ?? node.database ?? "";
const config = connectionStore.getConfig(node.connectionId);
const timeoutSecs = queryTimeoutSecsForConnection(config, settingsStore.editorSettings.globalQueryTimeoutSecs);
const executionId = options.executionId ?? uuid();
return executeWithProductionSqlGuard({
connection: connectionStore.getConfig(node.connectionId),
connection: config,
database,
sql,
source: t("production.sourceSidebar"),
execute: () => (options.executeAsScript ? api.executeScript(node.connectionId!, database, sql, options.schema ?? node.schema) : api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema)),
execute: () => {
// The caller may have observed a cancel click while we were still
// waiting on connection setup or the production-safety confirmation
// above — if so, never actually dispatch the SQL to the backend.
if (options.isCancelledBeforeDispatch?.()) throw new Error("Operation cancelled before it was sent to the database.");
options.markDispatched?.();
return options.executeAsScript ? api.executeScript(node.connectionId!, database, sql, options.schema ?? node.schema) : api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema, executionId, { timeoutSecs });
},
});
}
@@ -3965,6 +3982,8 @@ routeDangerDialog(showDropTableConfirm, () =>
},
}
: undefined,
closeOnConfirm: false,
cancelRunning: () => sidebarDangerRunningCancel.value?.(),
confirm: confirmDropTable,
}),
);
@@ -3997,6 +4016,8 @@ routeDangerDialog(showEmptyTableConfirm, () =>
return emptyTablePreviewSql.value;
},
confirmLabel: t("contextMenu.emptyTable"),
closeOnConfirm: false,
cancelRunning: () => sidebarDangerRunningCancel.value?.(),
confirm: confirmEmptyTable,
}),
);
@@ -4032,6 +4053,8 @@ routeDangerDialog(showTruncateTableConfirm, () =>
},
}
: undefined,
closeOnConfirm: false,
cancelRunning: () => sidebarDangerRunningCancel.value?.(),
confirm: confirmTruncateTable,
}),
);
@@ -14,6 +14,8 @@ export const fallbackCreateDatabaseCharset = fallbackCreateDatabaseCharsetMetada
export const sidebarTreeDialogOwner = shallowRef<symbol | null>(null);
export const sidebarDangerTarget = shallowRef<TreeNode | null>(null);
export const sidebarDangerRunningExecutionId = ref<string>("");
export const sidebarDangerRunningCancel = ref<(() => void | Promise<void>) | null>(null);
export const sidebarFormTarget = shallowRef<TreeNode | null>(null);
export const connectionDeleteTargetSnapshot = ref<ConnectionDeleteTarget[]>([]);
export const connectionGroupDeleteTargetSnapshot = ref<ConnectionGroupDeleteTarget[]>([]);