diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.test.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.test.ts new file mode 100644 index 00000000000..bb56822e70d --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { AlertOptions } from "@superset/ui/atoms/Alert"; + +let suppressed = false; +const suppress = mock(() => { + suppressed = true; +}); + +mock.module("renderer/stores/terminal-close-confirm/store", () => ({ + useTerminalCloseConfirmStore: { + getState: () => ({ suppressed, suppress }), + }, +})); + +const { confirmClosePorts } = await import("./confirmClosePorts"); + +describe("confirmClosePorts", () => { + beforeEach(() => { + suppressed = false; + suppress.mockClear(); + }); + + it("confirms a single port with running-process copy", async () => { + let options: AlertOptions | undefined; + const showAlert = mock((nextOptions: AlertOptions) => { + options = nextOptions; + return true; + }); + + const confirmation = confirmClosePorts(1, showAlert); + + expect(options?.title).toBe("This port is still in use"); + expect(options?.description).toBe( + "Closing this port will end the process using it.", + ); + expect(options?.actions[0]?.label).toBe("Close port"); + + await options?.actions[0]?.onClick?.({ checkboxChecked: false }); + expect(await confirmation).toBe(true); + }); + + it("uses plural copy and resolves false when canceled", async () => { + let options: AlertOptions | undefined; + const showAlert = (nextOptions: AlertOptions) => { + options = nextOptions; + return true; + }; + + const confirmation = confirmClosePorts(2, showAlert); + + expect(options?.title).toBe("These ports are still in use"); + expect(options?.description).toBe( + "Closing these ports will end the processes using them.", + ); + expect(options?.actions[0]?.label).toBe("Close ports"); + + await options?.actions[1]?.onClick?.({ checkboxChecked: false }); + expect(await confirmation).toBe(false); + }); + + it("resolves false when the dialog is dismissed", async () => { + let options: AlertOptions | undefined; + const confirmation = confirmClosePorts(1, (nextOptions) => { + options = nextOptions; + return true; + }); + + options?.onDismiss?.(); + + expect(await confirmation).toBe(false); + }); + + it("persists the shared running-process suppression preference", async () => { + let options: AlertOptions | undefined; + const showAlert = mock((nextOptions: AlertOptions) => { + options = nextOptions; + return true; + }); + const confirmation = confirmClosePorts(1, showAlert); + + await options?.actions[0]?.onClick?.({ + checkboxChecked: true, + }); + expect(await confirmation).toBe(true); + expect(suppressed).toBe(true); + expect(suppress).toHaveBeenCalledTimes(1); + + expect(await confirmClosePorts(1, showAlert)).toBe(true); + expect(showAlert).toHaveBeenCalledTimes(1); + }); + + it("fails open when the alert layer is unavailable", async () => { + expect(await confirmClosePorts(1, () => false)).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.ts new file mode 100644 index 00000000000..d3074d0bbf3 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/confirmClosePorts.ts @@ -0,0 +1,50 @@ +import { type AlertOptions, alert } from "@superset/ui/atoms/Alert"; +import { useTerminalCloseConfirmStore } from "renderer/stores/terminal-close-confirm/store"; + +type ShowAlert = (options: AlertOptions) => boolean; + +/** + * Confirm before terminating the process or processes that own detected ports. + * This shares the running-process suppression preference with terminal close + * confirmations so "Don't ask again" behaves consistently. + */ +export function confirmClosePorts( + portCount: number, + showAlert: ShowAlert = alert, +): Promise { + if (portCount === 0 || useTerminalCloseConfirmStore.getState().suppressed) { + return Promise.resolve(true); + } + + const isSinglePort = portCount === 1; + + return new Promise((resolve) => { + const shown = showAlert({ + title: isSinglePort + ? "This port is still in use" + : "These ports are still in use", + description: isSinglePort + ? "Closing this port will end the process using it." + : "Closing these ports will end the processes using them.", + checkbox: { label: "Don't ask again" }, + onDismiss: () => resolve(false), + actions: [ + { + label: isSinglePort ? "Close port" : "Close ports", + variant: "destructive", + onClick: ({ checkboxChecked }) => { + if (checkboxChecked) { + useTerminalCloseConfirmStore.getState().suppress(); + } + resolve(true); + }, + }, + { label: "Cancel", variant: "ghost", onClick: () => resolve(false) }, + ], + }); + + // Match terminal-close behavior: never leave the action hanging if the + // global alert layer is unavailable. + if (!shown) resolve(true); + }); +} diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts index 8877610c53b..a4d48b069b9 100644 --- a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts @@ -1,6 +1,7 @@ import { toast } from "@superset/ui/sonner"; import { type QueryKey, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; +import { confirmClosePorts } from "./confirmClosePorts"; import { killPortTarget, type LocalPortKill, @@ -36,7 +37,9 @@ export function usePortKillActions({ }, [queryClient, refreshQueryKey]); const killPort = useCallback( - async (port: TPort): Promise => { + async (port: TPort): Promise => { + if (!(await confirmClosePorts(1))) return; + setPendingCount((count) => count + 1); try { const result = await killPortTarget(port, localKill); @@ -57,6 +60,7 @@ export function usePortKillActions({ const killPorts = useCallback( async (ports: TPort[]): Promise => { if (ports.length === 0) return []; + if (!(await confirmClosePorts(ports.length))) return []; setPendingCount((count) => count + 1); try {