From 8f4d34f97d3332e110d2cf52e1bd319adc810fa5 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 17 Jan 2026 16:12:11 -0800 Subject: [PATCH 1/7] feat(desktop): add ability to close ports from ports list Add close buttons to the ports sidebar section allowing users to kill processes listening on specific ports directly from the UI. --- .../src/lib/trpc/routers/ports/ports.ts | 18 ++++++++ .../MergedPortBadge/MergedPortBadge.tsx | 22 +++++++++- .../WorkspacePortGroup/WorkspacePortGroup.tsx | 43 ++++++++++++++++--- .../PortsList/hooks/useKillPort.ts | 36 ++++++++++++++++ 4 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 43684314c96..21202ba348e 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -1,3 +1,4 @@ +import process from "node:process"; import { workspaces } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; @@ -43,6 +44,23 @@ export const createPortsRouter = () => { }); }), + kill: publicProcedure + .input(z.object({ pid: z.number() })) + .mutation(({ input }): { success: boolean; error?: string } => { + try { + process.kill(input.pid, "SIGTERM"); + return { success: true }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + console.error( + `[ports/kill] Failed to kill process ${input.pid}:`, + message, + ); + return { success: false, error: message }; + } + }), + hasStaticConfig: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }): { hasStatic: boolean } => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 9283261dfb7..e5d8a27362e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -1,10 +1,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; -import { LuExternalLink } from "react-icons/lu"; +import { LuExternalLink, LuX } from "react-icons/lu"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { MergedPort } from "shared/types"; import { STROKE_WIDTH } from "../../../constants"; +import { useKillPort } from "../../hooks/useKillPort"; interface MergedPortBadgeProps { port: MergedPort; @@ -14,6 +15,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { const navigate = useNavigate(); const setActiveTab = useTabsStore((s) => s.setActiveTab); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const { killPort } = useKillPort(); const portNumberColor = port.isActive ? "text-muted-foreground" @@ -48,6 +50,12 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { window.open(`http://localhost:${port.port}`, "_blank"); }; + const handleClose = () => { + killPort(port); + }; + + const canClose = port.isActive && port.pid != null; + return ( @@ -64,10 +72,20 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { type="button" onClick={handleOpenInBrowser} aria-label={`Open ${port.label || `port ${port.port}`} in browser`} - className="opacity-0 group-hover:opacity-100 pr-1 transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none" + className="opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none" > + {canClose && ( + + )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx index 2786d89baaa..4015c66068c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx @@ -1,5 +1,9 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; +import { LuX } from "react-icons/lu"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { STROKE_WIDTH } from "../../../constants"; +import { useKillPort } from "../../hooks/useKillPort"; import type { MergedWorkspaceGroup } from "../../hooks/usePortsData"; import { MergedPortBadge } from "../MergedPortBadge"; @@ -9,20 +13,45 @@ interface WorkspacePortGroupProps { export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { const navigate = useNavigate(); + const { killPorts } = useKillPort(); const handleWorkspaceClick = () => { navigateToWorkspace(group.workspaceId, navigate); }; + const activePorts = group.ports.filter((p) => p.isActive && p.pid != null); + + const handleCloseAll = () => { + killPorts(group.ports); + }; + return (
- +
+ + {activePorts.length > 0 && ( + + + + + +

Close all ports

+
+
+ )} +
{group.ports.map((port) => ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts new file mode 100644 index 00000000000..c2f42fced11 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts @@ -0,0 +1,36 @@ +import { toast } from "@superset/ui/sonner"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { MergedPort } from "shared/types"; + +export function useKillPort() { + const killMutation = electronTrpc.ports.kill.useMutation(); + + const killPort = async (port: MergedPort) => { + if (port.pid == null) return; + + const result = await killMutation.mutateAsync({ pid: port.pid }); + if (!result.success) { + toast.error(`Failed to close port ${port.port}`, { + description: result.error, + }); + } + }; + + const killPorts = async (ports: MergedPort[]) => { + const portsToKill = ports.filter((p) => p.isActive && p.pid != null); + if (portsToKill.length === 0) return; + + const results = await Promise.all( + portsToKill.map((port) => + killMutation.mutateAsync({ pid: port.pid as number }), + ), + ); + + const failed = results.filter((r) => !r.success); + if (failed.length > 0) { + toast.error(`Failed to close ${failed.length} port(s)`); + } + }; + + return { killPort, killPorts, isPending: killMutation.isPending }; +} From e09ef2d119ba20271555f8fe7f57faaacd45e423 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 17 Jan 2026 16:17:17 -0800 Subject: [PATCH 2/7] style(desktop): add hover states to port action buttons Add text-muted-foreground base color and hover states to external link (hover:text-primary) and close (hover:text-destructive) buttons. --- .../PortsList/components/MergedPortBadge/MergedPortBadge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index e5d8a27362e..c3dd3a5ac18 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -72,7 +72,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { type="button" onClick={handleOpenInBrowser} aria-label={`Open ${port.label || `port ${port.port}`} in browser`} - className="opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none" + className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none" > @@ -81,7 +81,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { type="button" onClick={handleClose} aria-label={`Close ${port.label || `port ${port.port}`}`} - className="opacity-0 group-hover:opacity-100 pr-1 transition-opacity hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none" + className="opacity-0 group-hover:opacity-100 pr-1 transition-opacity text-muted-foreground hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none" > From 3b85a1cead3c54280974c8f9aa6741277f8e3598 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 17 Jan 2026 16:17:36 -0800 Subject: [PATCH 3/7] style(desktop): increase port action button size for easier clicking --- .../PortsList/components/MergedPortBadge/MergedPortBadge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index c3dd3a5ac18..b5b7dbe712d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -74,7 +74,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { aria-label={`Open ${port.label || `port ${port.port}`} in browser`} className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none" > - + {canClose && ( )}
From c0e3ba85aaef20dbfd5f3ae7e56d91a0a57d8667 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 17 Jan 2026 16:18:36 -0800 Subject: [PATCH 4/7] style(desktop): add spacing between port tooltip and target --- .../PortsList/components/MergedPortBadge/MergedPortBadge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index b5b7dbe712d..429fc39efdd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -88,7 +88,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { )}
- +
{port.label &&
{port.label}
}
Date: Sat, 17 Jan 2026 16:21:50 -0800 Subject: [PATCH 5/7] fix(desktop): validate PID as positive integer to prevent invalid process signalling --- apps/desktop/src/lib/trpc/routers/ports/ports.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 21202ba348e..86458ddd97b 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -45,7 +45,7 @@ export const createPortsRouter = () => { }), kill: publicProcedure - .input(z.object({ pid: z.number() })) + .input(z.object({ pid: z.number().int().positive() })) .mutation(({ input }): { success: boolean; error?: string } => { try { process.kill(input.pid, "SIGTERM"); From 83e261187bed8dce5aa81550c6ba048bac052a09 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 17 Jan 2026 16:25:20 -0800 Subject: [PATCH 6/7] refactor(desktop): use safer port killing via portManager - Move process.kill logic to portManager.killPort() - Accept paneId + port instead of raw PID from client - Verify PID is not the terminal shell before killing - Only kill processes tracked in our ports map --- .../src/lib/trpc/routers/ports/ports.ts | 21 +++------ .../src/main/lib/terminal/port-manager.ts | 47 +++++++++++++++++++ .../MergedPortBadge/MergedPortBadge.tsx | 2 +- .../WorkspacePortGroup/WorkspacePortGroup.tsx | 2 +- .../PortsList/hooks/useKillPort.ts | 14 ++++-- 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 86458ddd97b..d14c822c991 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -1,4 +1,3 @@ -import process from "node:process"; import { workspaces } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; @@ -45,20 +44,14 @@ export const createPortsRouter = () => { }), kill: publicProcedure - .input(z.object({ pid: z.number().int().positive() })) + .input( + z.object({ + paneId: z.string(), + port: z.number().int().positive(), + }), + ) .mutation(({ input }): { success: boolean; error?: string } => { - try { - process.kill(input.pid, "SIGTERM"); - return { success: true }; - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error"; - console.error( - `[ports/kill] Failed to kill process ${input.pid}:`, - message, - ); - return { success: false, error: message }; - } + return portManager.killPort(input); }), hasStaticConfig: publicProcedure diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index bdd0fd5cac1..e8c9c7d63a7 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import process from "node:process"; import type { DetectedPort } from "shared/types"; import { getListeningPortsForPids, getProcessTree } from "./port-scanner"; import type { TerminalSession } from "./types"; @@ -399,6 +400,52 @@ class PortManager extends EventEmitter { async forceScan(): Promise { await this.scanAllSessions(); } + + /** + * Safely kill a process listening on a tracked port. + * Only kills if: + * - The port is tracked by us + * - The PID is not the terminal's shell PID (only child processes) + */ + killPort({ + paneId, + port, + }: { + paneId: string; + port: number; + }): { success: boolean; error?: string } { + const key = this.makeKey(paneId, port); + const detectedPort = this.ports.get(key); + + if (!detectedPort) { + return { success: false, error: "Port not found in tracked ports" }; + } + + // Get the terminal's shell PID to ensure we don't kill it + const session = this.sessions.get(paneId); + const daemonSession = this.daemonSessions.get(paneId); + const shellPid = session?.session.pty.pid ?? daemonSession?.pid; + + if (shellPid != null && detectedPort.pid === shellPid) { + return { + success: false, + error: "Cannot kill the terminal shell process", + }; + } + + try { + process.kill(detectedPort.pid, "SIGTERM"); + return { success: true }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + console.error( + `[PortManager] Failed to kill process ${detectedPort.pid}:`, + message, + ); + return { success: false, error: message }; + } + } } export const portManager = new PortManager(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 429fc39efdd..ca14cda322b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -54,7 +54,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { killPort(port); }; - const canClose = port.isActive && port.pid != null; + const canClose = port.isActive && port.paneId != null; return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx index 4015c66068c..2079ff93e2b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx @@ -19,7 +19,7 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { navigateToWorkspace(group.workspaceId, navigate); }; - const activePorts = group.ports.filter((p) => p.isActive && p.pid != null); + const activePorts = group.ports.filter((p) => p.isActive && p.paneId != null); const handleCloseAll = () => { killPorts(group.ports); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts index c2f42fced11..58705b8a653 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts @@ -6,9 +6,12 @@ export function useKillPort() { const killMutation = electronTrpc.ports.kill.useMutation(); const killPort = async (port: MergedPort) => { - if (port.pid == null) return; + if (!port.isActive || port.paneId == null) return; - const result = await killMutation.mutateAsync({ pid: port.pid }); + const result = await killMutation.mutateAsync({ + paneId: port.paneId, + port: port.port, + }); if (!result.success) { toast.error(`Failed to close port ${port.port}`, { description: result.error, @@ -17,12 +20,15 @@ export function useKillPort() { }; const killPorts = async (ports: MergedPort[]) => { - const portsToKill = ports.filter((p) => p.isActive && p.pid != null); + const portsToKill = ports.filter((p) => p.isActive && p.paneId != null); if (portsToKill.length === 0) return; const results = await Promise.all( portsToKill.map((port) => - killMutation.mutateAsync({ pid: port.pid as number }), + killMutation.mutateAsync({ + paneId: port.paneId as string, + port: port.port, + }), ), ); From 4dcfa4919fb995d19e0f81dab84232f767dea143 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 17 Jan 2026 16:28:19 -0800 Subject: [PATCH 7/7] fix lint --- apps/desktop/src/main/lib/terminal/port-manager.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index e8c9c7d63a7..5267a7a98f2 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -407,13 +407,10 @@ class PortManager extends EventEmitter { * - The port is tracked by us * - The PID is not the terminal's shell PID (only child processes) */ - killPort({ - paneId, - port, - }: { - paneId: string; - port: number; - }): { success: boolean; error?: string } { + killPort({ paneId, port }: { paneId: string; port: number }): { + success: boolean; + error?: string; + } { const key = this.makeKey(paneId, port); const detectedPort = this.ports.get(key); @@ -437,8 +434,7 @@ class PortManager extends EventEmitter { process.kill(detectedPort.pid, "SIGTERM"); return { success: true }; } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error"; + const message = error instanceof Error ? error.message : "Unknown error"; console.error( `[PortManager] Failed to kill process ${detectedPort.pid}:`, message,