fix(sidebar): keep destructive table operations cancellable
This commit is contained in:
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[]>([]);
|
||||
|
||||
Reference in new issue
Block a user