diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 49725d9ac66..d4432be08a9 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -2,13 +2,9 @@ import { workspaces } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; -import { - hasStaticPortsConfig, - loadStaticPorts, - staticPortsWatcher, -} from "main/lib/static-ports"; +import { loadStaticPorts } from "main/lib/static-ports"; import { portManager } from "main/lib/terminal/port-manager"; -import type { DetectedPort, StaticPort } from "shared/types"; +import type { DetectedPort, EnrichedPort } from "shared/types"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getWorkspacePath } from "../workspaces/utils/worktree"; @@ -17,10 +13,41 @@ type PortEvent = | { type: "add"; port: DetectedPort } | { type: "remove"; port: DetectedPort }; +function getLabelsForPath(worktreePath: string): Map | null { + const result = loadStaticPorts(worktreePath); + if (!result.exists || result.error || !result.ports) return null; + + const labels = new Map(); + for (const p of result.ports) { + labels.set(p.port, p.label); + } + return labels; +} + export const createPortsRouter = () => { return router({ - getAll: publicProcedure.query(() => { - return portManager.getAllPorts(); + getAll: publicProcedure.query((): EnrichedPort[] => { + const detectedPorts = portManager.getAllPorts(); + + const labelCache = new Map | null>(); + + return detectedPorts.map((port) => { + if (!labelCache.has(port.workspaceId)) { + const ws = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, port.workspaceId)) + .get(); + const wsPath = ws ? getWorkspacePath(ws) : null; + labelCache.set( + port.workspaceId, + wsPath ? getLabelsForPath(wsPath) : null, + ); + } + + const labels = labelCache.get(port.workspaceId); + return { ...port, label: labels?.get(port.port) ?? null }; + }); }), subscribe: publicProcedure.subscription(() => { @@ -55,164 +82,5 @@ export const createPortsRouter = () => { return portManager.killPort(input); }, ), - - hasStaticConfig: publicProcedure - .input(z.object({ workspaceId: z.string() })) - .query(({ input }): { hasStatic: boolean } => { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, input.workspaceId)) - .get(); - - if (!workspace) { - return { hasStatic: false }; - } - - const workspacePath = getWorkspacePath(workspace); - if (!workspacePath) { - return { hasStatic: false }; - } - - return { hasStatic: hasStaticPortsConfig(workspacePath) }; - }), - - getStatic: publicProcedure - .input(z.object({ workspaceId: z.string() })) - .query( - ({ input }): { ports: StaticPort[] | null; error: string | null } => { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, input.workspaceId)) - .get(); - - if (!workspace) { - return { ports: null, error: "Workspace not found" }; - } - - const workspacePath = getWorkspacePath(workspace); - if (!workspacePath) { - return { ports: null, error: "Workspace path not found" }; - } - - const result = loadStaticPorts(workspacePath); - - if (!result.exists) { - return { ports: null, error: null }; - } - - if (result.error) { - return { ports: null, error: result.error }; - } - - const portsWithWorkspace: StaticPort[] = - result.ports?.map((p) => ({ - ...p, - workspaceId: input.workspaceId, - })) ?? []; - - return { ports: portsWithWorkspace, error: null }; - }, - ), - - getAllStatic: publicProcedure.query( - (): { - ports: StaticPort[]; - errors: Array<{ workspaceId: string; error: string }>; - } => { - const allWorkspaces = localDb.select().from(workspaces).all(); - const allPorts: StaticPort[] = []; - const errors: Array<{ workspaceId: string; error: string }> = []; - - for (const workspace of allWorkspaces) { - const workspacePath = getWorkspacePath(workspace); - if (!workspacePath) continue; - - const result = loadStaticPorts(workspacePath); - - if (!result.exists) continue; - - if (result.error) { - errors.push({ workspaceId: workspace.id, error: result.error }); - continue; - } - - if (result.ports) { - const portsWithWorkspace = result.ports.map((p) => ({ - ...p, - workspaceId: workspace.id, - })); - allPorts.push(...portsWithWorkspace); - } - } - - return { ports: allPorts, errors }; - }, - ), - - subscribeStatic: publicProcedure - .input(z.object({ workspaceId: z.string() })) - .subscription(({ input }) => { - return observable<{ type: "change" }>((emit) => { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, input.workspaceId)) - .get(); - - if (!workspace) { - return () => {}; - } - - const workspacePath = getWorkspacePath(workspace); - if (!workspacePath) { - return () => {}; - } - - staticPortsWatcher.watch(input.workspaceId, workspacePath); - - const onChange = (changedWorkspaceId: string) => { - if (changedWorkspaceId === input.workspaceId) { - emit.next({ type: "change" }); - } - }; - - staticPortsWatcher.on("change", onChange); - - return () => { - staticPortsWatcher.off("change", onChange); - staticPortsWatcher.unwatch(input.workspaceId); - }; - }); - }), - - subscribeAllStatic: publicProcedure.subscription(() => { - return observable<{ type: "change"; workspaceId: string }>((emit) => { - const allWorkspaces = localDb.select().from(workspaces).all(); - const watchedIds: string[] = []; - - for (const workspace of allWorkspaces) { - const workspacePath = getWorkspacePath(workspace); - if (!workspacePath) continue; - - staticPortsWatcher.watch(workspace.id, workspacePath); - watchedIds.push(workspace.id); - } - - const onChange = (changedWorkspaceId: string) => { - emit.next({ type: "change", workspaceId: changedWorkspaceId }); - }; - - staticPortsWatcher.on("change", onChange); - - return () => { - staticPortsWatcher.off("change", onChange); - for (const id of watchedIds) { - staticPortsWatcher.unwatch(id); - } - }; - }); - }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/.test-origin-head-1771066473561-mrg02kb762j b/apps/desktop/src/lib/trpc/routers/workspaces/utils/.test-origin-head-1771066473561-mrg02kb762j new file mode 160000 index 00000000000..308fdfc6363 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/.test-origin-head-1771066473561-mrg02kb762j @@ -0,0 +1 @@ +Subproject commit 308fdfc6363086e38bc6c7ee75c1ea7fb3e1414b diff --git a/apps/desktop/src/main/lib/static-ports/index.ts b/apps/desktop/src/main/lib/static-ports/index.ts index d437a2c59d7..1a92de7ae40 100644 --- a/apps/desktop/src/main/lib/static-ports/index.ts +++ b/apps/desktop/src/main/lib/static-ports/index.ts @@ -1,2 +1 @@ -export { hasStaticPortsConfig, loadStaticPorts } from "./loader"; -export { staticPortsWatcher } from "./watcher"; +export { loadStaticPorts } from "./loader"; 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 6162cd84ce6..47e70ca539a 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 @@ -3,12 +3,12 @@ import { useNavigate } from "@tanstack/react-router"; 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 type { EnrichedPort } from "shared/types"; import { STROKE_WIDTH } from "../../../constants"; import { useKillPort } from "../../hooks/useKillPort"; interface MergedPortBadgeProps { - port: MergedPort; + port: EnrichedPort; } export function MergedPortBadge({ port }: MergedPortBadgeProps) { @@ -17,30 +17,25 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const { killPort } = useKillPort(); - const portNumberColor = port.isActive - ? "text-muted-foreground" - : "text-muted-foreground/80"; - const displayContent = port.label ? ( <> {port.label}{" "} - + {port.port} ) : ( - {port.port} + {port.port} ); - const canJumpToTerminal = port.isActive && port.paneId; + const canJumpToTerminal = !!port.paneId; const handleClick = () => { - if (!canJumpToTerminal || !port.paneId) return; + if (!port.paneId) return; const pane = useTabsStore.getState().panes[port.paneId]; if (!pane) return; - // Navigate to workspace, then focus the pane navigateToWorkspace(port.workspaceId, navigate); setActiveTab(port.workspaceId, pane.tabId); setFocusedPane(pane.tabId, port.paneId); @@ -54,8 +49,6 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { killPort(port); }; - const canClose = port.isActive && port.paneId != null; - return ( @@ -76,16 +69,14 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { > - {canClose && ( - - )} + @@ -96,20 +87,16 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { > localhost:{port.port} - {port.isActive && ( - <> - {(port.processName || port.pid != null) && ( -
- {port.processName} - {port.pid != null && ` (pid ${port.pid})`} -
- )} - {canJumpToTerminal && ( -
- Click to open workspace -
- )} - + {(port.processName || port.pid != null) && ( +
+ {port.processName} + {port.pid != null && ` (pid ${port.pid})`} +
+ )} + {canJumpToTerminal && ( +
+ Click to open workspace +
)}
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 effad7f73cc..4c9e5e3fdb9 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 @@ -4,11 +4,11 @@ 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 type { WorkspacePortGroup as WorkspacePortGroupType } from "../../hooks/usePortsData"; import { MergedPortBadge } from "../MergedPortBadge"; interface WorkspacePortGroupProps { - group: MergedWorkspaceGroup; + group: WorkspacePortGroupType; } export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { @@ -19,8 +19,6 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { navigateToWorkspace(group.workspaceId, navigate); }; - const activePorts = group.ports.filter((p) => p.isActive && p.paneId != null); - const handleCloseAll = () => { killPorts(group.ports); }; @@ -35,22 +33,20 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { > {group.workspaceName} - {activePorts.length > 0 && ( - - - - - -

Close all ports

-
-
- )} + + + + + +

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 index 58705b8a653..8d3b75cd259 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 @@ -1,13 +1,11 @@ import { toast } from "@superset/ui/sonner"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import type { MergedPort } from "shared/types"; +import type { EnrichedPort } 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 killPort = async (port: EnrichedPort) => { const result = await killMutation.mutateAsync({ paneId: port.paneId, port: port.port, @@ -19,14 +17,13 @@ export function useKillPort() { } }; - const killPorts = async (ports: MergedPort[]) => { - const portsToKill = ports.filter((p) => p.isActive && p.paneId != null); - if (portsToKill.length === 0) return; + const killPorts = async (ports: EnrichedPort[]) => { + if (ports.length === 0) return; const results = await Promise.all( - portsToKill.map((port) => + ports.map((port) => killMutation.mutateAsync({ - paneId: port.paneId as string, + paneId: port.paneId, port: port.port, }), ), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts index fdc22e1be76..b299b2824b9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -1,52 +1,33 @@ -import { toast } from "@superset/ui/sonner"; -import { useEffect, useMemo, useRef } from "react"; +import { useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { usePortsStore } from "renderer/stores"; -import type { MergedPort } from "shared/types"; -import { mergePorts } from "../utils"; +import type { EnrichedPort } from "shared/types"; -export interface MergedWorkspaceGroup { +/** Matches the port scanner's scan cycle in port-manager.ts */ +const PORTS_REFETCH_INTERVAL_MS = 2500; + +export interface WorkspacePortGroup { workspaceId: string; workspaceName: string; - ports: MergedPort[]; + ports: EnrichedPort[]; } export function usePortsData() { const { data: allWorkspaces } = electronTrpc.workspaces.getAll.useQuery(); - const ports = usePortsStore((s) => s.ports); - const setPorts = usePortsStore((s) => s.setPorts); - const addPort = usePortsStore((s) => s.addPort); - const removePort = usePortsStore((s) => s.removePort); const utils = electronTrpc.useUtils(); - const { data: allStaticPortsData } = - electronTrpc.ports.getAllStatic.useQuery(); + const { data: detectedPorts } = electronTrpc.ports.getAll.useQuery( + undefined, + { refetchInterval: PORTS_REFETCH_INTERVAL_MS }, + ); - // Subscribe to all static port changes across all workspaces - electronTrpc.ports.subscribeAllStatic.useSubscription(undefined, { + electronTrpc.ports.subscribe.useSubscription(undefined, { onData: () => { - utils.ports.getAllStatic.invalidate(); + utils.ports.getAll.invalidate(); }, }); - const { data: initialPorts } = electronTrpc.ports.getAll.useQuery(); - - useEffect(() => { - if (initialPorts) { - setPorts(initialPorts); - } - }, [initialPorts, setPorts]); - - electronTrpc.ports.subscribe.useSubscription(undefined, { - onData: (event) => { - if (event.type === "add") { - addPort(event.port); - } else if (event.type === "remove") { - removePort(event.port.paneId, event.port.port); - } - }, - }); + const ports = detectedPorts ?? []; const workspaceNames = useMemo(() => { if (!allWorkspaces) return {}; @@ -59,66 +40,31 @@ export function usePortsData() { ); }, [allWorkspaces]); - // Prevent showing duplicate error toasts on re-renders - const shownErrorsRef = useRef>(new Set()); + const workspacePortGroups = useMemo(() => { + const groupMap = new Map(); - useEffect(() => { - const errors = allStaticPortsData?.errors ?? []; - for (const { workspaceId, error } of errors) { - const errorKey = `${workspaceId}:${error}`; - if (!shownErrorsRef.current.has(errorKey)) { - shownErrorsRef.current.add(errorKey); - const workspaceName = - workspaceNames[workspaceId] || "Unknown workspace"; - toast.error(`Failed to load ports.json in ${workspaceName}`, { - description: error, - }); + for (const port of ports) { + const existing = groupMap.get(port.workspaceId); + if (existing) { + existing.push(port); + } else { + groupMap.set(port.workspaceId, [port]); } } - }, [allStaticPortsData?.errors, workspaceNames]); - const allWorkspaceIds = useMemo(() => { - const ids = new Set(); - - for (const port of allStaticPortsData?.ports ?? []) { - ids.add(port.workspaceId); - } - - for (const port of ports) { - ids.add(port.workspaceId); + const groups: WorkspacePortGroup[] = []; + for (const [workspaceId, wsPorts] of groupMap) { + groups.push({ + workspaceId, + workspaceName: workspaceNames[workspaceId] || "Unknown", + ports: wsPorts.sort((a, b) => a.port - b.port), + }); } - return Array.from(ids); - }, [allStaticPortsData?.ports, ports]); - - const workspacePortGroups = useMemo(() => { - const allStaticPorts = allStaticPortsData?.ports ?? []; - - const groups: MergedWorkspaceGroup[] = allWorkspaceIds.map( - (workspaceId) => { - const staticPortsForWorkspace = allStaticPorts.filter( - (p) => p.workspaceId === workspaceId, - ); - - const merged = mergePorts({ - staticPorts: staticPortsForWorkspace, - dynamicPorts: ports, - workspaceId, - }); - - return { - workspaceId, - workspaceName: workspaceNames[workspaceId] || "Unknown", - ports: merged, - }; - }, + return groups.sort((a, b) => + a.workspaceName.localeCompare(b.workspaceName), ); - - // Remove workspaces with no active ports and sort alphabetically - return groups - .filter((g) => g.ports.length > 0) - .sort((a, b) => a.workspaceName.localeCompare(b.workspaceName)); - }, [allWorkspaceIds, allStaticPortsData?.ports, ports, workspaceNames]); + }, [ports, workspaceNames]); const totalPortCount = workspacePortGroups.reduce( (sum, g) => sum + g.ports.length, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/index.ts deleted file mode 100644 index de0001109e9..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { mergePorts } from "./merge-ports"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/merge-ports.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/merge-ports.ts deleted file mode 100644 index c5fdd7bcdd3..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/merge-ports.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { DetectedPort, MergedPort, StaticPort } from "shared/types"; - -/** - * Merge static port configuration with dynamically detected ports. - * - * Logic: - * 1. Only show ports that are actively in use (detected by the port scanner) - * 2. For active ports matching a static port number: apply the label from config - * 3. For active ports not in static config: show as dynamic-only entries - * 4. Sort by port number - */ -export function mergePorts({ - staticPorts, - dynamicPorts, - workspaceId, -}: { - staticPorts: StaticPort[]; - dynamicPorts: DetectedPort[]; - workspaceId: string; -}): MergedPort[] { - const workspaceDynamicPorts = dynamicPorts.filter( - (p) => p.workspaceId === workspaceId, - ); - - const staticByPort = new Map(staticPorts.map((p) => [p.port, p])); - const merged: MergedPort[] = []; - - for (const dynamic of workspaceDynamicPorts) { - const staticPort = staticByPort.get(dynamic.port); - merged.push({ - port: dynamic.port, - workspaceId, - label: staticPort?.label ?? null, - isActive: true, - pid: dynamic.pid, - processName: dynamic.processName, - paneId: dynamic.paneId, - address: dynamic.address, - detectedAt: dynamic.detectedAt, - }); - } - - return merged.sort((a, b) => a.port - b.port); -} diff --git a/apps/desktop/src/renderer/stores/ports/store.ts b/apps/desktop/src/renderer/stores/ports/store.ts index 4b376bbda1c..2081bfebba1 100644 --- a/apps/desktop/src/renderer/stores/ports/store.ts +++ b/apps/desktop/src/renderer/stores/ports/store.ts @@ -1,17 +1,9 @@ -import type { DetectedPort } from "shared/types"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; interface PortsState { - // Runtime state (not persisted) - ports: DetectedPort[]; - // UI preferences (persisted) isListCollapsed: boolean; - addPort: (port: DetectedPort) => void; - removePort: (paneId: string, port: number) => void; - removePortsForPane: (paneId: string) => void; - setPorts: (ports: DetectedPort[]) => void; setListCollapsed: (collapsed: boolean) => void; toggleListCollapsed: () => void; } @@ -20,33 +12,8 @@ export const usePortsStore = create()( devtools( persist( (set, get) => ({ - ports: [], isListCollapsed: false, - addPort: (port) => - set((state) => { - // Check for duplicate - const exists = state.ports.some( - (p) => p.paneId === port.paneId && p.port === port.port, - ); - if (exists) return state; - return { ports: [...state.ports, port] }; - }), - - removePort: (paneId, port) => - set((state) => ({ - ports: state.ports.filter( - (p) => !(p.paneId === paneId && p.port === port), - ), - })), - - removePortsForPane: (paneId) => - set((state) => ({ - ports: state.ports.filter((p) => p.paneId !== paneId), - })), - - setPorts: (ports) => set({ ports }), - setListCollapsed: (collapsed) => set({ isListCollapsed: collapsed }), toggleListCollapsed: () => @@ -54,7 +21,6 @@ export const usePortsStore = create()( }), { name: "ports-store", - // Only persist UI preferences, not runtime port data partialize: (state) => ({ isListCollapsed: state.isListCollapsed, }), diff --git a/apps/desktop/src/shared/types/ports.ts b/apps/desktop/src/shared/types/ports.ts index 34716c440af..967a9a2a20d 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -20,14 +20,6 @@ export interface StaticPortsResult { error: string | null; } -export interface MergedPort { - port: number; - workspaceId: string; +export interface EnrichedPort extends DetectedPort { label: string | null; - isActive: boolean; - pid: number | null; - processName: string | null; - paneId: string | null; - address: string | null; - detectedAt: number | null; }