Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/desktop/src/lib/trpc/routers/ports/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ export const createPortsRouter = () => {
});
}),

kill: publicProcedure
.input(
z.object({
paneId: z.string(),
port: z.number().int().positive(),
}),
)
.mutation(({ input }): { success: boolean; error?: string } => {
return portManager.killPort(input);
}),

hasStaticConfig: publicProcedure
.input(z.object({ workspaceId: z.string() }))
.query(({ input }): { hasStatic: boolean } => {
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/main/lib/terminal/port-manager.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -399,6 +400,48 @@ class PortManager extends EventEmitter {
async forceScan(): Promise<void> {
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();
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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"
Expand Down Expand Up @@ -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.paneId != null;

return (
<Tooltip>
<TooltipTrigger asChild>
Expand All @@ -64,13 +72,23 @@ 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 text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none"
>
<LuExternalLink className="size-3" strokeWidth={STROKE_WIDTH} />
<LuExternalLink className="size-3.5" strokeWidth={STROKE_WIDTH} />
</button>
{canClose && (
<button
type="button"
onClick={handleClose}
aria-label={`Close ${port.label || `port ${port.port}`}`}
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"
>
<LuX className="size-3.5" strokeWidth={STROKE_WIDTH} />
</button>
)}
</div>
</TooltipTrigger>
<TooltipContent side="top" showArrow={false}>
<TooltipContent side="top" sideOffset={6} showArrow={false}>
<div className="text-xs space-y-1">
{port.label && <div className="font-medium">{port.label}</div>}
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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.paneId != null);

const handleCloseAll = () => {
killPorts(group.ports);
};

return (
<div>
<button
type="button"
onClick={handleWorkspaceClick}
className="text-xs px-3 py-1 truncate text-left w-full transition-colors text-muted-foreground hover:text-sidebar-foreground cursor-pointer"
>
{group.workspaceName}
</button>
<div className="group flex items-center px-3 py-1">
<button
type="button"
onClick={handleWorkspaceClick}
className="text-xs truncate text-left transition-colors text-muted-foreground hover:text-sidebar-foreground cursor-pointer"
>
{group.workspaceName}
</button>
{activePorts.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCloseAll}
className="ml-auto opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-muted/50 transition-opacity text-muted-foreground hover:text-destructive"
>
<LuX className="size-3" strokeWidth={STROKE_WIDTH} />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<p className="text-xs">Close all ports</p>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex flex-wrap gap-1 px-3">
{group.ports.map((port) => (
<MergedPortBadge key={port.port} port={port} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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.isActive || port.paneId == null) return;

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,
});
}
};

const killPorts = async (ports: MergedPort[]) => {
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({
paneId: port.paneId as string,
port: port.port,
}),
),
);

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 };
Comment thread
Kitenite marked this conversation as resolved.
}
Loading