From 1724d4f50cefc8061f1395b3352468c7d7cec78c Mon Sep 17 00:00:00 2001 From: steven-terrana Date: Thu, 8 Jan 2026 18:33:00 -0500 Subject: [PATCH 1/9] feat(desktop): add static port configuration via .superset/ports.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now define static ports in a `.superset/ports.json` file within their workspace. When present, static ports replace dynamic port discovery in the sidebar. Features: - New static-ports module with loader and file watcher - tRPC procedures: hasStaticConfig, getStatic, subscribeStatic - Auto-reload when ports.json changes - Toast notifications for config errors - Marketing documentation page at /ports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../plans/20260108-2251-static-ports-json.md | 404 ++++++++++++++++++ .../src/lib/trpc/routers/ports/ports.ts | 113 ++++- .../src/main/lib/static-ports/index.ts | 2 + .../src/main/lib/static-ports/loader.test.ts | 285 ++++++++++++ .../src/main/lib/static-ports/loader.ts | 157 +++++++ .../src/main/lib/static-ports/watcher.ts | 155 +++++++ .../WorkspaceSidebar/PortsList/PortsList.tsx | 141 +++++- apps/desktop/src/shared/constants.ts | 1 + apps/desktop/src/shared/types/ports.ts | 12 + apps/marketing/src/app/ports/page.tsx | 237 ++++++++++ 10 files changed, 1492 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/plans/20260108-2251-static-ports-json.md create mode 100644 apps/desktop/src/main/lib/static-ports/index.ts create mode 100644 apps/desktop/src/main/lib/static-ports/loader.test.ts create mode 100644 apps/desktop/src/main/lib/static-ports/loader.ts create mode 100644 apps/desktop/src/main/lib/static-ports/watcher.ts create mode 100644 apps/marketing/src/app/ports/page.tsx diff --git a/apps/desktop/plans/20260108-2251-static-ports-json.md b/apps/desktop/plans/20260108-2251-static-ports-json.md new file mode 100644 index 0000000000..e09d11c2ee --- /dev/null +++ b/apps/desktop/plans/20260108-2251-static-ports-json.md @@ -0,0 +1,404 @@ +# Static Ports Configuration via ports.json + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from AGENTS.md and the ExecPlan template at `.agents/commands/create-plan.md`. + +## Purpose / Big Picture + +After this change, users can define static port entries in a `.superset/ports.json` file within their repository. When this file is present, the Ports section in the left sidebar will display the configured ports with custom labels instead of dynamically scanning for listening processes. This is useful for teams who want consistent port documentation or for projects where dynamic port scanning doesn't work well. + +Users will see: ports configured in `ports.json` appear as clickable badges in the PORTS section of the sidebar, each showing the port number with a tooltip displaying the custom label. If the file is malformed, a toast notification appears explaining the error and no ports are shown. + +## Assumptions + +1. The `.superset` directory already exists in repositories using Superset (same location as `config.json` for setup/teardown scripts). +2. Static ports should completely replace dynamic port discovery when `ports.json` is present (not merge with dynamic ports). +3. Each workspace reads from its own worktree's `.superset/ports.json` file (workspace-scoped, not project-scoped). + +## Open Questions + +None remaining - all questions resolved in Decision Log below. + +## Progress + +- [x] (2026-01-08 22:51Z) Define `StaticPort` type in `apps/desktop/src/shared/types/ports.ts` +- [x] (2026-01-08 22:52Z) Add `PORTS_FILE_NAME` constant to `apps/desktop/src/shared/constants.ts` +- [x] (2026-01-08 22:53Z) Create `apps/desktop/src/main/lib/static-ports/` module with loader and validator +- [x] (2026-01-08 22:54Z) Write unit tests for static ports loader (24 tests, all passing) +- [x] (2026-01-08 22:55Z) Create tRPC procedures `ports.hasStaticConfig`, `ports.getStatic`, and `ports.subscribeStatic` +- [x] (2026-01-08 22:56Z) Update `PortsList.tsx` to check for static config and display accordingly +- [x] (2026-01-08 22:57Z) Create `StaticPortBadge` component (no pane linking, label in tooltip) +- [x] (2026-01-08 22:58Z) Add toast notification for malformed ports.json errors +- [x] (2026-01-08 22:59Z) Create file watcher for live reload of ports.json changes +- [x] (2026-01-08 23:00Z) Create marketing documentation page at `apps/marketing/src/app/ports/page.tsx` +- [x] (2026-01-08 23:01Z) Run typecheck and lint - all passing +- [ ] Manual validation + +## Surprises & Discoveries + +- Observation: Toast import path is `@superset/ui/sonner`, not `sonner` directly + Evidence: Other components in the codebase use this import path + +- Observation: File watching needed additional logic to handle both file and directory watching + Evidence: When ports.json doesn't exist, we watch the .superset directory for file creation; when it exists, we watch the file directly for changes + +## Decision Log + +- Decision: Workspace-scoped ports.json reading + Rationale: User requested this approach. Each workspace's worktree has its own `.superset/ports.json`, allowing different branches to have different port configurations. + Date/Author: 2026-01-08 / Planning phase + +- Decision: Toast notification for malformed ports.json errors + Rationale: User preference. Consistent with how other errors are displayed in the app. Toast is dismissible and non-blocking. + Date/Author: 2026-01-08 / Planning phase + +- Decision: Static port tooltips show label only (no PID/process info) + Rationale: User preference. Static ports don't have process information, so showing just the custom label keeps the UI clean. + Date/Author: 2026-01-08 / Planning phase + +- Decision: Static ports completely replace dynamic discovery when present + Rationale: Provides predictable behavior. If a user wants static ports, they likely don't want dynamic ports interfering. + Date/Author: 2026-01-08 / Planning phase + +- Decision: Add file watching for live reload of ports.json changes + Rationale: User requested that changes to ports.json be detected automatically. Using fs.watch with debouncing provides responsive updates without polling overhead. + Date/Author: 2026-01-08 / Implementation phase + +- Decision: Skip Zustand store modifications for static ports + Rationale: Static ports don't need subscription management like dynamic ports. Using tRPC queries directly in the component is cleaner and follows the existing React Query pattern. + Date/Author: 2026-01-08 / Implementation phase + +## Outcomes & Retrospective + +(To be completed after implementation) + +## Context and Orientation + +This feature affects the **desktop app** (`apps/desktop`). The marketing documentation page affects `apps/marketing`. + +### Current Port Discovery Architecture + +The desktop app has a dynamic port scanning system: + +1. **Port Scanner** (`apps/desktop/src/main/lib/terminal/port-scanner.ts`): Cross-platform utility that uses `lsof` (macOS/Linux) or `netstat` (Windows) to find listening TCP ports for given process IDs. + +2. **Port Manager** (`apps/desktop/src/main/lib/terminal/port-manager.ts`): Singleton that tracks terminal sessions, periodically scans their process trees for ports, and emits `port:add` / `port:remove` events. Key methods: + - `registerSession(session, workspaceId)`: Start tracking a terminal + - `unregisterSession(paneId)`: Stop tracking and remove ports + - `getAllPorts()`: Return all currently detected ports + +3. **tRPC Router** (`apps/desktop/src/lib/trpc/routers/ports/ports.ts`): Exposes `getAll` (query) and `subscribe` (subscription) procedures that delegate to the port manager. + +4. **Renderer Store** (`apps/desktop/src/renderer/stores/ports/store.ts`): Zustand store holding `ports: DetectedPort[]` array and UI state like `isListCollapsed`. + +5. **UI Component** (`apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx`): Displays ports grouped by workspace. Each port badge shows the port number, with tooltip showing process name and PID. Clicking focuses the terminal pane that owns the port. + +### Existing Types + + // apps/desktop/src/shared/types/ports.ts + export interface DetectedPort { + port: number; + pid: number; + processName: string; + paneId: string; + workspaceId: string; + detectedAt: number; + address: string; + } + +### Configuration Pattern Reference + +The existing `config.json` setup/teardown feature provides a pattern to follow: + +- Constants defined in `apps/desktop/src/shared/constants.ts`: `PROJECT_SUPERSET_DIR_NAME = ".superset"`, `CONFIG_FILE_NAME = "config.json"` +- Loading logic in `apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts`: `loadSetupConfig(mainRepoPath)` reads and validates JSON +- Tests in `apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts` + +### Workspace Path Access + +Workspaces store their worktree path. The workspaces tRPC router (`apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts`) provides `getActive` and `getAll` procedures. Each workspace has a `repoPath` field that points to its worktree directory. + +### Toast Notifications + +The app uses `sonner` for toast notifications. Import `toast` from `sonner` and call `toast.error("message")` to show an error toast. + +## Plan of Work + +### Milestone 1: Types and Constants + +Add the new type for static ports and the constant for the filename. + +**File: `apps/desktop/src/shared/types/ports.ts`** + +Add a new interface for static ports: + + export interface StaticPort { + port: number; + label: string; + workspaceId: string; + } + +The `StaticPort` differs from `DetectedPort` in that it has no `pid`, `processName`, `paneId`, `detectedAt`, or `address` fields. Instead it has a `label` for display. + +**File: `apps/desktop/src/shared/constants.ts`** + +Add a constant for the ports config filename: + + export const PORTS_FILE_NAME = "ports.json"; + +### Milestone 2: Static Ports Loader + +Create a new module to load and validate `ports.json`. + +**File: `apps/desktop/src/main/lib/static-ports/loader.ts`** + +Create a function `loadStaticPorts(worktreePath: string)` that: + +1. Constructs path: `join(worktreePath, PROJECT_SUPERSET_DIR_NAME, PORTS_FILE_NAME)` +2. Checks if file exists using `existsSync` +3. If not exists, returns `{ exists: false, ports: null, error: null }` +4. If exists, reads the file with `readFileSync` +5. Parses JSON with `JSON.parse` (catch parse errors) +6. Validates structure: + - Root must have a `ports` key that is an array + - Each element must have `port` (number, 1-65535) and `label` (non-empty string) +7. Returns `{ exists: true, ports: StaticPort[], error: null }` on success +8. Returns `{ exists: true, ports: null, error: string }` on validation failure + +**File: `apps/desktop/src/main/lib/static-ports/index.ts`** + +Export the loader function. + +**File: `apps/desktop/src/main/lib/static-ports/loader.test.ts`** + +Unit tests covering: +- File does not exist: returns `{ exists: false, ports: null, error: null }` +- Valid JSON with ports array: returns parsed ports +- Invalid JSON syntax: returns error +- Missing `ports` key: returns error +- `ports` is not an array: returns error +- Port entry missing `port` field: returns error +- Port entry missing `label` field: returns error +- Port number out of range: returns error +- Empty label: returns error + +### Milestone 3: tRPC Router Updates + +Add procedures to check for and load static ports. + +**File: `apps/desktop/src/lib/trpc/routers/ports/ports.ts`** + +Add two new procedures: + +1. `hasStaticConfig`: Query that takes `{ workspaceId: string }`, looks up the workspace's `repoPath`, and returns `{ hasStatic: boolean }` indicating whether `ports.json` exists. + +2. `getStatic`: Query that takes `{ workspaceId: string }`, looks up the workspace's `repoPath`, calls `loadStaticPorts`, and returns `{ ports: StaticPort[] | null, error: string | null }`. + +This requires importing from the workspaces schema and the static-ports loader. + +### Milestone 4: Renderer Store Updates + +Update the ports store to track static ports and errors. + +**File: `apps/desktop/src/renderer/stores/ports/store.ts`** + +Add to the store state: +- `staticPorts: StaticPort[]` +- `staticPortsError: string | null` +- `useStaticPorts: boolean` (whether static config exists for current workspace) + +Add actions: +- `setStaticPorts(ports: StaticPort[])` +- `setStaticPortsError(error: string | null)` +- `setUseStaticPorts(use: boolean)` +- `clearStaticPorts()` + +These fields should NOT be persisted (only `isListCollapsed` is persisted). + +### Milestone 5: PortsList UI Updates + +Update the PortsList component to handle static ports. + +**File: `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx`** + +Changes: + +1. Add query for `trpc.ports.hasStaticConfig.useQuery({ workspaceId: activeWorkspace?.id })` when active workspace exists. + +2. When static config exists, fetch with `trpc.ports.getStatic.useQuery({ workspaceId })` instead of subscribing to dynamic ports. + +3. If static ports returns an error, show a toast with `toast.error()` and display nothing in the ports list. + +4. Create a new `StaticPortBadge` component variant that: + - Shows port number + - Has tooltip with just the label (no PID/process info) + - Opens `http://localhost:{port}` in browser on external link click + - Does NOT link to a terminal pane (no `handleClick` to focus pane) + +5. When displaying static ports, don't group by workspace since they're already workspace-scoped. Just show the static ports for the active workspace. + +### Milestone 6: Marketing Documentation Page + +Create documentation page following the pattern of the scripts page. + +**File: `apps/marketing/src/app/ports/page.tsx`** + +Create a new page with: + +1. Title: "Static Port Configuration" +2. Subtitle: "Define custom ports for your workspace with ports.json" + +3. **Overview section**: Explain that Superset normally auto-discovers ports from running processes, but you can override this with a static configuration file. + +4. **Configuration section**: Show path `.superset/ports.json` + +5. **Schema section**: Show example: + ```json + { + "ports": [ + { "port": 3000, "label": "Frontend Dev Server" }, + { "port": 8080, "label": "API Server" }, + { "port": 5432, "label": "PostgreSQL" } + ] + } + ``` + +6. **Field descriptions**: + - `ports`: Array of port definitions + - `port`: Port number (1-65535) + - `label`: Display text shown in tooltip + +7. **Behavior section**: Explain: + - Static config completely replaces dynamic discovery + - File is read from the workspace's worktree, so different branches can have different configs + - If malformed, an error toast appears and no ports are shown + +8. **Tips section**: + - Document ports that aren't auto-detected (like databases) + - Share port documentation with your team by committing `.superset/ports.json` + - Use meaningful labels to help teammates understand what each port is for + +## Concrete Steps + +After implementing each milestone, verify with: + + cd apps/desktop + bun run typecheck + # Expected: No type errors + + cd ../.. + bun run lint + # Expected: No lint errors + + cd apps/desktop + bun test src/main/lib/static-ports/loader.test.ts + # Expected: All tests pass + +For manual validation: + + bun dev + # Desktop app opens + + # Test 1: No ports.json + # Create a workspace, start a dev server + # Verify ports appear dynamically in sidebar + + # Test 2: Valid ports.json + # In the workspace's worktree, create .superset/ports.json: + # { "ports": [{ "port": 3000, "label": "Test Server" }] } + # Refresh/switch workspace, verify static port appears with label in tooltip + + # Test 3: Malformed ports.json + # Change ports.json to invalid JSON: { "ports": invalid } + # Refresh, verify error toast appears and no ports shown + + # Test 4: Invalid schema + # Change to: { "ports": [{ "port": "not-a-number", "label": "Test" }] } + # Refresh, verify error toast about invalid port number + +## Validation and Acceptance + +1. **Type safety**: Run `bun run typecheck` in apps/desktop - no errors + +2. **Lint**: Run `bun run lint` at root - no errors + +3. **Unit tests**: Run `bun test apps/desktop/src/main/lib/static-ports/` - all pass + +4. **Manual test - no config**: With no `.superset/ports.json`, dynamic port discovery works as before + +5. **Manual test - valid config**: With valid `ports.json`, static ports appear with custom labels in tooltips + +6. **Manual test - malformed JSON**: With invalid JSON, error toast appears, no ports shown + +7. **Manual test - invalid schema**: With invalid schema, descriptive error toast appears, no ports shown + +8. **Marketing page**: Navigate to `http://localhost:3001/ports` (marketing site), verify documentation renders correctly + +## Idempotence and Recovery + +All steps are idempotent: +- File creation is additive +- Type additions don't break existing code +- tRPC procedures are independent queries +- Store state changes are isolated + +If a step fails partway, you can re-run from the beginning of that milestone. No database migrations or destructive changes are involved. + +## Artifacts and Notes + +**ports.json schema example:** + + { + "ports": [ + { "port": 3000, "label": "Frontend Dev Server" }, + { "port": 8080, "label": "API Server" } + ] + } + +**Expected loader return types:** + + // File doesn't exist + { exists: false, ports: null, error: null } + + // Valid file + { exists: true, ports: [{ port: 3000, label: "Frontend", workspaceId: "ws-123" }], error: null } + + // Invalid file + { exists: true, ports: null, error: "Invalid JSON: Unexpected token..." } + +## Interfaces and Dependencies + +### New Type Definitions + + // apps/desktop/src/shared/types/ports.ts + export interface StaticPort { + port: number; + label: string; + workspaceId: string; + } + + // Loader return type + export interface StaticPortsResult { + exists: boolean; + ports: Omit[] | null; + error: string | null; + } + +### New tRPC Procedures + + // ports.hasStaticConfig + input: z.object({ workspaceId: z.string() }) + output: { hasStatic: boolean } + + // ports.getStatic + input: z.object({ workspaceId: z.string() }) + output: { ports: StaticPort[] | null, error: string | null } + +### Dependencies + +No new npm dependencies required. Uses existing: +- `node:fs` for file operations (main process only) +- `node:path` for path construction +- `zod` for input validation in tRPC +- `sonner` for toast notifications (already used in app) diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 65c8dae322..217a07b4a1 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -1,7 +1,17 @@ +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 { portManager } from "main/lib/terminal/port-manager"; -import type { DetectedPort } from "shared/types"; +import type { DetectedPort, StaticPort } from "shared/types"; +import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { getWorkspacePath } from "../workspaces/utils/worktree"; type PortEvent = | { type: "add"; port: DetectedPort } @@ -34,5 +44,106 @@ export const createPortsRouter = () => { }; }); }), + + // Check if a workspace has a static ports configuration file + 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) }; + }), + + // Get static ports from the workspace's ports.json file + 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 }; + } + + // Add workspaceId to each port + const portsWithWorkspace: StaticPort[] = + result.ports?.map((p) => ({ + ...p, + workspaceId: input.workspaceId, + })) ?? []; + + return { ports: portsWithWorkspace, error: null }; + }, + ), + + // Subscribe to static ports file changes for a workspace + 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 () => {}; + } + + // Start watching the file + 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); + }; + }); + }), }); }; diff --git a/apps/desktop/src/main/lib/static-ports/index.ts b/apps/desktop/src/main/lib/static-ports/index.ts new file mode 100644 index 0000000000..d437a2c59d --- /dev/null +++ b/apps/desktop/src/main/lib/static-ports/index.ts @@ -0,0 +1,2 @@ +export { hasStaticPortsConfig, loadStaticPorts } from "./loader"; +export { staticPortsWatcher } from "./watcher"; diff --git a/apps/desktop/src/main/lib/static-ports/loader.test.ts b/apps/desktop/src/main/lib/static-ports/loader.test.ts new file mode 100644 index 0000000000..55c6c0d013 --- /dev/null +++ b/apps/desktop/src/main/lib/static-ports/loader.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { hasStaticPortsConfig, loadStaticPorts } from "./loader"; + +const TEST_DIR = join(__dirname, ".test-tmp"); +const WORKTREE_PATH = join(TEST_DIR, "worktree"); +const SUPERSET_DIR = join(WORKTREE_PATH, ".superset"); +const PORTS_FILE = join(SUPERSET_DIR, "ports.json"); + +describe("loadStaticPorts", () => { + beforeEach(() => { + mkdirSync(SUPERSET_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test("returns exists: false when ports.json does not exist", () => { + rmSync(PORTS_FILE, { force: true }); + const result = loadStaticPorts(WORKTREE_PATH); + expect(result).toEqual({ exists: false, ports: null, error: null }); + }); + + test("loads valid ports.json with single port", () => { + const config = { + ports: [{ port: 3000, label: "Frontend" }], + }; + writeFileSync(PORTS_FILE, JSON.stringify(config)); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result).toEqual({ + exists: true, + ports: [{ port: 3000, label: "Frontend" }], + error: null, + }); + }); + + test("loads valid ports.json with multiple ports", () => { + const config = { + ports: [ + { port: 3000, label: "Frontend" }, + { port: 8080, label: "API Server" }, + { port: 5432, label: "PostgreSQL" }, + ], + }; + writeFileSync(PORTS_FILE, JSON.stringify(config)); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result).toEqual({ + exists: true, + ports: [ + { port: 3000, label: "Frontend" }, + { port: 8080, label: "API Server" }, + { port: 5432, label: "PostgreSQL" }, + ], + error: null, + }); + }); + + test("trims whitespace from labels", () => { + const config = { + ports: [{ port: 3000, label: " Frontend " }], + }; + writeFileSync(PORTS_FILE, JSON.stringify(config)); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.ports?.[0].label).toBe("Frontend"); + }); + + test("returns error for invalid JSON syntax", () => { + writeFileSync(PORTS_FILE, "{ invalid json }"); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toContain("Invalid JSON"); + }); + + test("returns error when ports.json is not an object", () => { + writeFileSync(PORTS_FILE, '"just a string"'); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports.json must contain a JSON object"); + }); + + test("returns error when ports key is missing", () => { + writeFileSync(PORTS_FILE, JSON.stringify({ other: "field" })); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports.json is missing required field 'ports'"); + }); + + test("returns error when ports is not an array", () => { + writeFileSync(PORTS_FILE, JSON.stringify({ ports: "not-an-array" })); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("'ports' field must be an array"); + }); + + test("returns error when port entry is not an object", () => { + writeFileSync(PORTS_FILE, JSON.stringify({ ports: ["not-an-object"] })); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0] must be an object"); + }); + + test("returns error when port field is missing", () => { + writeFileSync(PORTS_FILE, JSON.stringify({ ports: [{ label: "Test" }] })); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0] is missing required field 'port'"); + }); + + test("returns error when label field is missing", () => { + writeFileSync(PORTS_FILE, JSON.stringify({ ports: [{ port: 3000 }] })); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0] is missing required field 'label'"); + }); + + test("returns error when port is not a number", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ ports: [{ port: "3000", label: "Test" }] }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0].port must be an integer"); + }); + + test("returns error when port is not an integer", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ ports: [{ port: 3000.5, label: "Test" }] }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0].port must be an integer"); + }); + + test("returns error when port is below 1", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ ports: [{ port: 0, label: "Test" }] }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0].port must be between 1 and 65535"); + }); + + test("returns error when port is above 65535", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ ports: [{ port: 65536, label: "Test" }] }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0].port must be between 1 and 65535"); + }); + + test("returns error when label is not a string", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ ports: [{ port: 3000, label: 123 }] }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0].label must be a string"); + }); + + test("returns error when label is empty", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ ports: [{ port: 3000, label: "" }] }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0].label cannot be empty"); + }); + + test("returns error when label is only whitespace", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ ports: [{ port: 3000, label: " " }] }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[0].label cannot be empty"); + }); + + test("returns error with correct index for second invalid entry", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ + ports: [ + { port: 3000, label: "Valid" }, + { port: "invalid", label: "Test" }, + ], + }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[1].port must be an integer"); + }); + + test("accepts valid boundary port numbers", () => { + const config = { + ports: [ + { port: 1, label: "Min port" }, + { port: 65535, label: "Max port" }, + ], + }; + writeFileSync(PORTS_FILE, JSON.stringify(config)); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toHaveLength(2); + expect(result.error).toBeNull(); + }); + + test("handles empty ports array", () => { + writeFileSync(PORTS_FILE, JSON.stringify({ ports: [] })); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result).toEqual({ exists: true, ports: [], error: null }); + }); +}); + +describe("hasStaticPortsConfig", () => { + beforeEach(() => { + mkdirSync(SUPERSET_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test("returns false when ports.json does not exist", () => { + expect(hasStaticPortsConfig(WORKTREE_PATH)).toBe(false); + }); + + test("returns true when ports.json exists", () => { + writeFileSync(PORTS_FILE, JSON.stringify({ ports: [] })); + expect(hasStaticPortsConfig(WORKTREE_PATH)).toBe(true); + }); + + test("returns true even when ports.json is invalid", () => { + writeFileSync(PORTS_FILE, "invalid json"); + expect(hasStaticPortsConfig(WORKTREE_PATH)).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/lib/static-ports/loader.ts b/apps/desktop/src/main/lib/static-ports/loader.ts new file mode 100644 index 0000000000..0cec50fd24 --- /dev/null +++ b/apps/desktop/src/main/lib/static-ports/loader.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; +import type { StaticPortsResult } from "shared/types"; + +interface PortEntry { + port: unknown; + label: unknown; +} + +interface PortsConfig { + ports: unknown; +} + +function validatePortEntry( + entry: PortEntry, + index: number, +): + | { valid: true; port: number; label: string } + | { valid: false; error: string } { + if (typeof entry !== "object" || entry === null) { + return { valid: false, error: `ports[${index}] must be an object` }; + } + + if (!("port" in entry)) { + return { + valid: false, + error: `ports[${index}] is missing required field 'port'`, + }; + } + + if (!("label" in entry)) { + return { + valid: false, + error: `ports[${index}] is missing required field 'label'`, + }; + } + + const { port, label } = entry; + + if (typeof port !== "number" || !Number.isInteger(port)) { + return { valid: false, error: `ports[${index}].port must be an integer` }; + } + + if (port < 1 || port > 65535) { + return { + valid: false, + error: `ports[${index}].port must be between 1 and 65535`, + }; + } + + if (typeof label !== "string") { + return { valid: false, error: `ports[${index}].label must be a string` }; + } + + if (label.trim() === "") { + return { valid: false, error: `ports[${index}].label cannot be empty` }; + } + + return { valid: true, port, label: label.trim() }; +} + +/** + * Load and validate static ports configuration from a worktree's .superset/ports.json file. + * + * @param worktreePath - Path to the workspace's worktree directory + * @returns StaticPortsResult with exists flag, ports array, and any error message + */ +export function loadStaticPorts(worktreePath: string): StaticPortsResult { + const portsPath = join( + worktreePath, + PROJECT_SUPERSET_DIR_NAME, + PORTS_FILE_NAME, + ); + + if (!existsSync(portsPath)) { + return { exists: false, ports: null, error: null }; + } + + let content: string; + try { + content = readFileSync(portsPath, "utf-8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + exists: true, + ports: null, + error: `Failed to read ports.json: ${message}`, + }; + } + + let parsed: PortsConfig; + try { + parsed = JSON.parse(content) as PortsConfig; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + exists: true, + ports: null, + error: `Invalid JSON in ports.json: ${message}`, + }; + } + + if (typeof parsed !== "object" || parsed === null) { + return { + exists: true, + ports: null, + error: "ports.json must contain a JSON object", + }; + } + + if (!("ports" in parsed)) { + return { + exists: true, + ports: null, + error: "ports.json is missing required field 'ports'", + }; + } + + if (!Array.isArray(parsed.ports)) { + return { + exists: true, + ports: null, + error: "'ports' field must be an array", + }; + } + + const validatedPorts: Array<{ port: number; label: string }> = []; + + for (let i = 0; i < parsed.ports.length; i++) { + const entry = parsed.ports[i] as PortEntry; + const result = validatePortEntry(entry, i); + + if (!result.valid) { + return { exists: true, ports: null, error: result.error }; + } + + validatedPorts.push({ port: result.port, label: result.label }); + } + + return { exists: true, ports: validatedPorts, error: null }; +} + +/** + * Check if a static ports configuration file exists for a worktree. + * + * @param worktreePath - Path to the workspace's worktree directory + * @returns true if .superset/ports.json exists + */ +export function hasStaticPortsConfig(worktreePath: string): boolean { + const portsPath = join( + worktreePath, + PROJECT_SUPERSET_DIR_NAME, + PORTS_FILE_NAME, + ); + return existsSync(portsPath); +} diff --git a/apps/desktop/src/main/lib/static-ports/watcher.ts b/apps/desktop/src/main/lib/static-ports/watcher.ts new file mode 100644 index 0000000000..b85efd1306 --- /dev/null +++ b/apps/desktop/src/main/lib/static-ports/watcher.ts @@ -0,0 +1,155 @@ +import { EventEmitter } from "node:events"; +import { existsSync, type FSWatcher, statSync, watch } from "node:fs"; +import { join } from "node:path"; +import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; + +/** + * Watches for changes to ports.json files across workspaces. + * Emits 'change' event with workspaceId when a watched file changes. + */ +class StaticPortsWatcher extends EventEmitter { + private watchers = new Map(); + private debounceTimers = new Map>(); + private lastMtimes = new Map(); + + /** + * Start watching ports.json for a workspace. + * If the file doesn't exist, we'll still set up the watch on the directory + * to detect when it's created. + */ + watch(workspaceId: string, worktreePath: string): void { + // Clean up existing watcher for this workspace + this.unwatch(workspaceId); + + const portsPath = join( + worktreePath, + PROJECT_SUPERSET_DIR_NAME, + PORTS_FILE_NAME, + ); + const supersetDir = join(worktreePath, PROJECT_SUPERSET_DIR_NAME); + + // Determine what to watch: + // 1. If ports.json exists, watch it directly + // 2. If .superset dir exists, watch it for ports.json creation + // 3. If neither exists, watch the worktree root for .superset creation + let watchPath: string; + let watchingFor: "file" | "dir" | "root"; + + if (existsSync(portsPath)) { + watchPath = portsPath; + watchingFor = "file"; + // Store initial mtime to detect actual changes + try { + const stat = statSync(portsPath); + this.lastMtimes.set(workspaceId, stat.mtimeMs); + } catch { + // File may have been deleted between check and stat + } + } else if (existsSync(supersetDir)) { + watchPath = supersetDir; + watchingFor = "dir"; + } else if (existsSync(worktreePath)) { + watchPath = worktreePath; + watchingFor = "root"; + } else { + return; + } + + try { + const watcher = watch(watchPath, (_eventType, filename) => { + // Filter events based on what we're watching for + if (watchingFor === "dir") { + // Watching .superset dir - only care about ports.json + if (filename && filename !== PORTS_FILE_NAME) { + return; + } + } else if (watchingFor === "root") { + // Watching worktree root - only care about .superset dir creation + if (filename && filename !== PROJECT_SUPERSET_DIR_NAME) { + return; + } + // .superset was created, switch to watching it + if (existsSync(supersetDir)) { + this.watch(workspaceId, worktreePath); + return; + } + } else if (watchingFor === "file") { + // Check if file actually changed by comparing mtime + // This prevents spurious events from atime updates when reading the file + try { + if (!existsSync(portsPath)) { + // File was deleted - clear mtime and emit change + this.lastMtimes.delete(workspaceId); + } else { + const stat = statSync(portsPath); + const lastMtime = this.lastMtimes.get(workspaceId); + if (lastMtime !== undefined && stat.mtimeMs === lastMtime) { + // mtime unchanged - this is a spurious event (e.g., atime update) + return; + } + this.lastMtimes.set(workspaceId, stat.mtimeMs); + } + } catch { + // Error getting stat - file may have been deleted, continue with emit + } + } + + // Debounce to avoid multiple rapid events + const existingTimer = this.debounceTimers.get(workspaceId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.debounceTimers.delete(workspaceId); + this.emit("change", workspaceId); + + // If we were watching the directory and the file now exists, + // switch to watching the file directly for more precise events + if (watchingFor === "dir" && existsSync(portsPath)) { + this.watch(workspaceId, worktreePath); + } + }, 100); + + this.debounceTimers.set(workspaceId, timer); + }); + + this.watchers.set(workspaceId, watcher); + } catch (error) { + console.error( + `[StaticPortsWatcher] Failed to watch ${watchPath}:`, + error, + ); + } + } + + /** + * Stop watching ports.json for a workspace. + */ + unwatch(workspaceId: string): void { + const watcher = this.watchers.get(workspaceId); + if (watcher) { + watcher.close(); + this.watchers.delete(workspaceId); + } + + const timer = this.debounceTimers.get(workspaceId); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(workspaceId); + } + + this.lastMtimes.delete(workspaceId); + } + + /** + * Stop all watchers. + */ + unwatchAll(): void { + for (const workspaceId of this.watchers.keys()) { + this.unwatch(workspaceId); + } + } +} + +export const staticPortsWatcher = new StaticPortsWatcher(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index 8fa5ea52aa..429ce2b667 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -1,9 +1,11 @@ +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { LuChevronRight, LuExternalLink, LuRadioTower } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { type DetectedPort, usePortsStore } from "renderer/stores"; import { useTabsStore } from "renderer/stores/tabs/store"; +import type { StaticPort } from "shared/types"; import { STROKE_WIDTH } from "../constants"; interface WorkspaceGroup { @@ -23,18 +25,72 @@ export function PortsList() { const isCollapsed = usePortsStore((s) => s.isListCollapsed); const toggleCollapsed = usePortsStore((s) => s.toggleListCollapsed); - // Fetch initial ports - const { data: initialPorts } = trpc.ports.getAll.useQuery(); + const utils = trpc.useUtils(); + + // Check if the active workspace has static ports config + const { data: staticConfigCheck } = trpc.ports.hasStaticConfig.useQuery( + { workspaceId: activeWorkspace?.id ?? "" }, + { enabled: !!activeWorkspace?.id }, + ); + + const useStaticPorts = staticConfigCheck?.hasStatic ?? false; + + // Fetch static ports if config exists + const { data: staticPortsData } = trpc.ports.getStatic.useQuery( + { workspaceId: activeWorkspace?.id ?? "" }, + { enabled: useStaticPorts && !!activeWorkspace?.id }, + ); + + // Subscribe to static ports file changes (always enabled to detect file creation) + trpc.ports.subscribeStatic.useSubscription( + { workspaceId: activeWorkspace?.id ?? "" }, + { + enabled: !!activeWorkspace?.id, + onData: () => { + // Invalidate queries to refetch the latest data + utils.ports.hasStaticConfig.invalidate({ + workspaceId: activeWorkspace?.id ?? "", + }); + utils.ports.getStatic.invalidate({ + workspaceId: activeWorkspace?.id ?? "", + }); + }, + }, + ); + + // Track if we've shown the error toast for this error + const lastErrorRef = useRef(null); - // Set initial ports when they load + // Show toast error for static ports if there's an error useEffect(() => { - if (initialPorts) { + if ( + staticPortsData?.error && + staticPortsData.error !== lastErrorRef.current + ) { + lastErrorRef.current = staticPortsData.error; + toast.error("Failed to load ports.json", { + description: staticPortsData.error, + }); + } else if (!staticPortsData?.error) { + lastErrorRef.current = null; + } + }, [staticPortsData?.error]); + + // Fetch initial dynamic ports (only when not using static) + const { data: initialPorts } = trpc.ports.getAll.useQuery(undefined, { + enabled: !useStaticPorts, + }); + + // Set initial dynamic ports when they load + useEffect(() => { + if (initialPorts && !useStaticPorts) { setPorts(initialPorts); } - }, [initialPorts, setPorts]); + }, [initialPorts, setPorts, useStaticPorts]); - // Subscribe to port changes + // Subscribe to dynamic port changes (only when not using static) trpc.ports.subscribe.useSubscription(undefined, { + enabled: !useStaticPorts, onData: (event) => { if (event.type === "add") { addPort(event.port); @@ -56,8 +112,16 @@ export function PortsList() { ); }, [allWorkspaces]); - // Group ports by workspace, sorted with current workspace first + // Get static ports for display + const staticPorts = useMemo(() => { + if (!useStaticPorts || !staticPortsData?.ports) return []; + return staticPortsData.ports.sort((a, b) => a.port - b.port); + }, [useStaticPorts, staticPortsData?.ports]); + + // Group dynamic ports by workspace, sorted with current workspace first const groupedPorts = useMemo(() => { + if (useStaticPorts) return []; + const groups: Record = {}; for (const port of ports) { @@ -89,9 +153,13 @@ export function PortsList() { }); return result; - }, [ports, activeWorkspace?.id, workspaceNames]); + }, [ports, activeWorkspace?.id, workspaceNames, useStaticPorts]); + + // Calculate total port count for display + const totalPortCount = useStaticPorts ? staticPorts.length : ports.length; - if (ports.length === 0) { + // Don't render if there are no ports (static or dynamic) + if (totalPortCount === 0) { return null; } @@ -109,13 +177,25 @@ export function PortsList() { /> Ports - {ports.length} + + {totalPortCount} + {!isCollapsed && (
- {groupedPorts.map((group) => ( - - ))} + {useStaticPorts ? ( + // Static ports - just show a flat list for the current workspace +
+ {staticPorts.map((port) => ( + + ))} +
+ ) : ( + // Dynamic ports - grouped by workspace + groupedPorts.map((group) => ( + + )) + )}
)} @@ -238,3 +318,36 @@ function PortBadge({ port, isCurrentWorkspace }: PortBadgeProps) { ); } + +interface StaticPortBadgeProps { + port: StaticPort; +} + +function StaticPortBadge({ port }: StaticPortBadgeProps) { + const handleOpenInBrowser = () => { + window.open(`http://localhost:${port.port}`, "_blank"); + }; + + return ( + + +
+ {port.label} + +
+
+ +
+
localhost:{port.port}
+
+
+
+ ); +} diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index d35968ade4..fe0b04b578 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -33,6 +33,7 @@ export const PROTOCOL_SCHEME = export const PROJECT_SUPERSET_DIR_NAME = ".superset"; export const WORKTREES_DIR_NAME = "worktrees"; export const CONFIG_FILE_NAME = "config.json"; +export const PORTS_FILE_NAME = "ports.json"; export const CONFIG_TEMPLATE = `{ "setup": [], diff --git a/apps/desktop/src/shared/types/ports.ts b/apps/desktop/src/shared/types/ports.ts index fff8a53f97..889bcee51d 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -7,3 +7,15 @@ export interface DetectedPort { detectedAt: number; address: string; } + +export interface StaticPort { + port: number; + label: string; + workspaceId: string; +} + +export interface StaticPortsResult { + exists: boolean; + ports: Omit[] | null; + error: string | null; +} diff --git a/apps/marketing/src/app/ports/page.tsx b/apps/marketing/src/app/ports/page.tsx new file mode 100644 index 0000000000..16b54eef89 --- /dev/null +++ b/apps/marketing/src/app/ports/page.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { motion } from "framer-motion"; + +const CONFIG_EXAMPLE = `{ + "ports": [ + { "port": 3000, "label": "Frontend Dev Server" }, + { "port": 8080, "label": "API Server" }, + { "port": 5432, "label": "PostgreSQL" } + ] +}`; + +export default function PortsPage() { + return ( +
+
+ +

+ Static Port Configuration +

+

+ Define custom ports for your workspace with ports.json +

+ +
+

+ Overview +

+

+ Superset automatically detects ports opened by processes running + in your terminal sessions. However, you can override this dynamic + detection with a static configuration file. This is useful for: +

+
    +
  • + Documenting ports that aren't auto-detected (databases, + external services) +
  • +
  • Providing meaningful labels for your team
  • +
  • Ensuring consistent port documentation across branches
  • +
  • Projects where dynamic scanning doesn't work well
  • +
+
+ +
+

+ Configuration +

+

+ Create a{" "} + + ports.json + {" "} + file in your project's{" "} + + .superset + {" "} + directory: +

+
+							
+								your-project/.superset/ports.json
+							
+						
+
+ +
+

+ Schema +

+

+ The configuration file has one required field: +

+
+							
+								{CONFIG_EXAMPLE}
+							
+						
+ +
+
+

+ + ports + +

+

+ An array of port definitions. Each entry must include: +

+
    +
  • + + port + {" "} + - Port number (1-65535) +
  • +
  • + + label + {" "} + - Display text shown in the tooltip +
  • +
+
+
+
+ +
+

+ How It Works +

+
    +
  1. + When you open a workspace, Superset checks for{" "} + + .superset/ports.json + +
  2. +
  3. + If the file exists, its ports are displayed in the sidebar + instead of dynamically detected ports +
  4. +
  5. + Ports appear as clickable badges that open{" "} + + localhost:PORT + {" "} + in your browser +
  6. +
  7. + Hovering over a port badge shows your custom label in a tooltip +
  8. +
  9. + Changes to the file are detected automatically - no restart + needed +
  10. +
+
+ +
+

+ Error Handling +

+

+ If{" "} + + ports.json + {" "} + is malformed or contains invalid data: +

+
    +
  • An error toast notification will appear with details
  • +
  • No ports will be displayed until the issue is fixed
  • +
  • Dynamic port detection will NOT be used as a fallback
  • +
+

+ Common validation errors include: +

+
    +
  • Invalid JSON syntax
  • +
  • + Missing the{" "} + + ports + {" "} + array +
  • +
  • Port number out of range (must be 1-65535)
  • +
  • Missing or empty label
  • +
+
+ +
+

+ Workspace Scope +

+

+ The{" "} + + ports.json + {" "} + file is read from each workspace's working directory. This + means: +

+
    +
  • Different branches can have different port configurations
  • +
  • + Changes in one workspace don't affect other workspaces +
  • +
  • + You can commit{" "} + + .superset/ports.json + {" "} + to share with your team +
  • +
+
+ +
+

+ Tips +

+
    +
  • + Use descriptive labels that help teammates understand each + service +
  • +
  • + Include ports for databases and external services that run + outside terminals +
  • +
  • + Commit{" "} + + .superset/ports.json + {" "} + to version control so your whole team benefits +
  • +
  • + If you need dynamic detection, simply delete or rename the{" "} + + ports.json + {" "} + file +
  • +
+
+
+
+
+ ); +} From 2ea81f104f31ec162df13de77496f18ed4f6367e Mon Sep 17 00:00:00 2001 From: steven-terrana Date: Thu, 8 Jan 2026 18:43:05 -0500 Subject: [PATCH 2/9] docs(desktop): add docstring to validatePortEntry function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds JSDoc documentation to the validatePortEntry helper function to satisfy the docstring coverage threshold. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/main/lib/static-ports/loader.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/main/lib/static-ports/loader.ts b/apps/desktop/src/main/lib/static-ports/loader.ts index 0cec50fd24..b03684183d 100644 --- a/apps/desktop/src/main/lib/static-ports/loader.ts +++ b/apps/desktop/src/main/lib/static-ports/loader.ts @@ -12,6 +12,13 @@ interface PortsConfig { ports: unknown; } +/** + * Validate a single port entry from the ports.json configuration. + * + * @param entry - The port entry object to validate + * @param index - The index of the entry in the ports array (for error messages) + * @returns Validation result with either the validated port/label or an error message + */ function validatePortEntry( entry: PortEntry, index: number, From 0fbf1418bc8be2f187101e33b9229fd5e4eb31d9 Mon Sep 17 00:00:00 2001 From: steven-terrana Date: Thu, 8 Jan 2026 18:44:02 -0500 Subject: [PATCH 3/9] docs(desktop): fix ExecPlan inconsistencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update toast import path from `sonner` to `@superset/ui/sonner` - Mark Milestone 4 (Zustand store updates) as skipped with note referencing the Decision Log entry 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../plans/20260108-2251-static-ports-json.md | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/apps/desktop/plans/20260108-2251-static-ports-json.md b/apps/desktop/plans/20260108-2251-static-ports-json.md index e09d11c2ee..63d737066c 100644 --- a/apps/desktop/plans/20260108-2251-static-ports-json.md +++ b/apps/desktop/plans/20260108-2251-static-ports-json.md @@ -121,7 +121,7 @@ Workspaces store their worktree path. The workspaces tRPC router (`apps/desktop/ ### Toast Notifications -The app uses `sonner` for toast notifications. Import `toast` from `sonner` and call `toast.error("message")` to show an error toast. +The app uses `sonner` for toast notifications. Import `toast` from `@superset/ui/sonner` and call `toast.error("message")` to show an error toast. ## Plan of Work @@ -197,24 +197,9 @@ Add two new procedures: This requires importing from the workspaces schema and the static-ports loader. -### Milestone 4: Renderer Store Updates +### ~~Milestone 4: Renderer Store Updates~~ (SKIPPED) -Update the ports store to track static ports and errors. - -**File: `apps/desktop/src/renderer/stores/ports/store.ts`** - -Add to the store state: -- `staticPorts: StaticPort[]` -- `staticPortsError: string | null` -- `useStaticPorts: boolean` (whether static config exists for current workspace) - -Add actions: -- `setStaticPorts(ports: StaticPort[])` -- `setStaticPortsError(error: string | null)` -- `setUseStaticPorts(use: boolean)` -- `clearStaticPorts()` - -These fields should NOT be persisted (only `isListCollapsed` is persisted). +> **Note:** This milestone was intentionally skipped. See Decision Log entry "Skip Zustand store modifications for static ports". The implementation uses tRPC queries directly in the component instead, which is cleaner and follows the existing React Query pattern. ### Milestone 5: PortsList UI Updates @@ -401,4 +386,4 @@ No new npm dependencies required. Uses existing: - `node:fs` for file operations (main process only) - `node:path` for path construction - `zod` for input validation in tRPC -- `sonner` for toast notifications (already used in app) +- `@superset/ui/sonner` for toast notifications (already used in app) From 73d6016d46211e513dd883485a91a6509c4f5fa6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 9 Jan 2026 12:27:28 -0800 Subject: [PATCH 4/9] update merge ports --- .../src/lib/trpc/routers/ports/ports.ts | 40 ++- .../WorkspaceSidebar/PortsList/PortsList.tsx | 297 ++++++------------ .../MergedPortBadge/MergedPortBadge.tsx | 107 +++++++ .../components/MergedPortBadge/index.ts | 1 + .../WorkspaceSidebar/PortsList/utils/index.ts | 1 + .../PortsList/utils/merge-ports.ts | 69 ++++ apps/desktop/src/shared/types/ports.ts | 12 + 7 files changed, 315 insertions(+), 212 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/merge-ports.ts diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 217a07b4a1..63893fbc4f 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -19,12 +19,10 @@ type PortEvent = export const createPortsRouter = () => { return router({ - // Get all currently detected ports getAll: publicProcedure.query(() => { return portManager.getAllPorts(); }), - // Subscribe to port changes (add/remove events) subscribe: publicProcedure.subscription(() => { return observable((emit) => { const onAdd = (port: DetectedPort) => { @@ -45,7 +43,6 @@ export const createPortsRouter = () => { }); }), - // Check if a workspace has a static ports configuration file hasStaticConfig: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }): { hasStatic: boolean } => { @@ -67,7 +64,6 @@ export const createPortsRouter = () => { return { hasStatic: hasStaticPortsConfig(workspacePath) }; }), - // Get static ports from the workspace's ports.json file getStatic: publicProcedure .input(z.object({ workspaceId: z.string() })) .query( @@ -108,7 +104,41 @@ export const createPortsRouter = () => { }, ), - // Subscribe to static ports file changes for a workspace + 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 }) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index 429ce2b667..c363d3b095 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -1,18 +1,18 @@ import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useEffect, useMemo, useRef } from "react"; -import { LuChevronRight, LuExternalLink, LuRadioTower } from "react-icons/lu"; +import { LuChevronRight, LuRadioTower } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; -import { type DetectedPort, usePortsStore } from "renderer/stores"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import type { StaticPort } from "shared/types"; +import { usePortsStore } from "renderer/stores"; +import type { MergedPort } from "shared/types"; import { STROKE_WIDTH } from "../constants"; +import { MergedPortBadge } from "./components/MergedPortBadge"; +import { mergePorts } from "./utils"; -interface WorkspaceGroup { +interface MergedWorkspaceGroup { workspaceId: string; workspaceName: string; isCurrentWorkspace: boolean; - ports: DetectedPort[]; + ports: MergedPort[]; } export function PortsList() { @@ -27,70 +27,34 @@ export function PortsList() { const utils = trpc.useUtils(); - // Check if the active workspace has static ports config - const { data: staticConfigCheck } = trpc.ports.hasStaticConfig.useQuery( - { workspaceId: activeWorkspace?.id ?? "" }, - { enabled: !!activeWorkspace?.id }, - ); - - const useStaticPorts = staticConfigCheck?.hasStatic ?? false; - - // Fetch static ports if config exists - const { data: staticPortsData } = trpc.ports.getStatic.useQuery( - { workspaceId: activeWorkspace?.id ?? "" }, - { enabled: useStaticPorts && !!activeWorkspace?.id }, - ); + // Fetch static ports for all workspaces + const { data: allStaticPortsData } = trpc.ports.getAllStatic.useQuery(); - // Subscribe to static ports file changes (always enabled to detect file creation) + // Subscribe to static ports file changes for active workspace + // (triggers refetch of all static ports when file changes) trpc.ports.subscribeStatic.useSubscription( { workspaceId: activeWorkspace?.id ?? "" }, { enabled: !!activeWorkspace?.id, onData: () => { - // Invalidate queries to refetch the latest data - utils.ports.hasStaticConfig.invalidate({ - workspaceId: activeWorkspace?.id ?? "", - }); - utils.ports.getStatic.invalidate({ - workspaceId: activeWorkspace?.id ?? "", - }); + // Invalidate to refetch all static ports + utils.ports.getAllStatic.invalidate(); }, }, ); - // Track if we've shown the error toast for this error - const lastErrorRef = useRef(null); - - // Show toast error for static ports if there's an error - useEffect(() => { - if ( - staticPortsData?.error && - staticPortsData.error !== lastErrorRef.current - ) { - lastErrorRef.current = staticPortsData.error; - toast.error("Failed to load ports.json", { - description: staticPortsData.error, - }); - } else if (!staticPortsData?.error) { - lastErrorRef.current = null; - } - }, [staticPortsData?.error]); - - // Fetch initial dynamic ports (only when not using static) - const { data: initialPorts } = trpc.ports.getAll.useQuery(undefined, { - enabled: !useStaticPorts, - }); + // Fetch initial dynamic ports + const { data: initialPorts } = trpc.ports.getAll.useQuery(); // Set initial dynamic ports when they load useEffect(() => { - if (initialPorts && !useStaticPorts) { + if (initialPorts) { setPorts(initialPorts); } - }, [initialPorts, setPorts, useStaticPorts]); + }, [initialPorts, setPorts]); - // Subscribe to dynamic port changes (only when not using static) + // Subscribe to dynamic port changes trpc.ports.subscribe.useSubscription(undefined, { - enabled: !useStaticPorts, onData: (event) => { if (event.type === "add") { addPort(event.port); @@ -112,51 +76,88 @@ export function PortsList() { ); }, [allWorkspaces]); - // Get static ports for display - const staticPorts = useMemo(() => { - if (!useStaticPorts || !staticPortsData?.ports) return []; - return staticPortsData.ports.sort((a, b) => a.port - b.port); - }, [useStaticPorts, staticPortsData?.ports]); + // Track which errors we've shown toasts for + const shownErrorsRef = useRef>(new Set()); + + // Show toast errors for any static port loading errors + 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, + }); + } + } + }, [allStaticPortsData?.errors, workspaceNames]); - // Group dynamic ports by workspace, sorted with current workspace first - const groupedPorts = useMemo(() => { - if (useStaticPorts) return []; + // Get all workspace IDs that have either static or dynamic ports + const allWorkspaceIds = useMemo(() => { + const ids = new Set(); - const groups: Record = {}; + // Add workspaces with static ports + for (const port of allStaticPortsData?.ports ?? []) { + ids.add(port.workspaceId); + } + // Add workspaces with dynamic ports for (const port of ports) { - if (!groups[port.workspaceId]) { - groups[port.workspaceId] = []; - } - groups[port.workspaceId].push(port); + ids.add(port.workspaceId); } - // Sort ports within each group by port number - for (const workspaceId of Object.keys(groups)) { - groups[workspaceId].sort((a, b) => a.port - b.port); - } + return Array.from(ids); + }, [allStaticPortsData?.ports, ports]); + + // Merge static + dynamic ports for ALL workspaces, grouped + const workspacePortGroups = useMemo(() => { + const allStaticPorts = allStaticPortsData?.ports ?? []; - // Convert to array and sort groups (current workspace first) - const result: WorkspaceGroup[] = Object.entries(groups).map( - ([workspaceId, workspacePorts]) => ({ + const groups = allWorkspaceIds.map((workspaceId) => { + // Get static ports for this workspace + const staticPortsForWorkspace = allStaticPorts.filter( + (p) => p.workspaceId === workspaceId, + ); + + // Merge with dynamic ports + const merged = mergePorts({ + staticPorts: staticPortsForWorkspace, + dynamicPorts: ports, + workspaceId, + }); + + return { workspaceId, workspaceName: workspaceNames[workspaceId] || "Unknown", isCurrentWorkspace: workspaceId === activeWorkspace?.id, - ports: workspacePorts, - }), - ); + ports: merged, + }; + }); - result.sort((a, b) => { + // Sort: current workspace first, then alphabetically + groups.sort((a, b) => { if (a.isCurrentWorkspace && !b.isCurrentWorkspace) return -1; if (!a.isCurrentWorkspace && b.isCurrentWorkspace) return 1; return a.workspaceName.localeCompare(b.workspaceName); }); - return result; - }, [ports, activeWorkspace?.id, workspaceNames, useStaticPorts]); + return groups; + }, [ + allWorkspaceIds, + allStaticPortsData?.ports, + ports, + workspaceNames, + activeWorkspace?.id, + ]); // Calculate total port count for display - const totalPortCount = useStaticPorts ? staticPorts.length : ports.length; + const totalPortCount = workspacePortGroups.reduce( + (sum, g) => sum + g.ports.length, + 0, + ); // Don't render if there are no ports (static or dynamic) if (totalPortCount === 0) { @@ -183,30 +184,20 @@ export function PortsList() { {!isCollapsed && (
- {useStaticPorts ? ( - // Static ports - just show a flat list for the current workspace -
- {staticPorts.map((port) => ( - - ))} -
- ) : ( - // Dynamic ports - grouped by workspace - groupedPorts.map((group) => ( - - )) - )} + {workspacePortGroups.map((group) => ( + + ))}
)} ); } -interface WorkspacePortGroupProps { - group: WorkspaceGroup; +interface MergedWorkspacePortGroupProps { + group: MergedWorkspaceGroup; } -function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { +function MergedWorkspacePortGroup({ group }: MergedWorkspacePortGroupProps) { const setActiveMutation = trpc.workspaces.setActive.useMutation(); const utils = trpc.useUtils(); @@ -233,8 +224,8 @@ function WorkspacePortGroup({ group }: WorkspacePortGroupProps) {
{group.ports.map((port) => ( - @@ -243,111 +234,3 @@ function WorkspacePortGroup({ group }: WorkspacePortGroupProps) {
); } - -interface PortBadgeProps { - port: DetectedPort; - isCurrentWorkspace: boolean; -} - -function PortBadge({ port, isCurrentWorkspace }: PortBadgeProps) { - const setActiveTab = useTabsStore((s) => s.setActiveTab); - const setFocusedPane = useTabsStore((s) => s.setFocusedPane); - const setActiveMutation = trpc.workspaces.setActive.useMutation(); - const utils = trpc.useUtils(); - - const handleClick = async () => { - // If not in current workspace, switch to it first - if (!isCurrentWorkspace) { - await setActiveMutation.mutateAsync({ id: port.workspaceId }); - await utils.workspaces.getActive.invalidate(); - } - - // Look up pane after potential workspace switch - const pane = useTabsStore.getState().panes[port.paneId]; - if (!pane) return; - - // Set the tab as active for this workspace - setActiveTab(port.workspaceId, pane.tabId); - - // Focus the specific pane - setFocusedPane(pane.tabId, port.paneId); - }; - - const handleOpenInBrowser = () => { - window.open(`http://localhost:${port.port}`, "_blank"); - }; - - return ( - - -
- - -
-
- -
-
localhost:{port.port}
-
- {port.processName} (pid {port.pid}) -
-
- Click to jump to terminal -
-
-
-
- ); -} - -interface StaticPortBadgeProps { - port: StaticPort; -} - -function StaticPortBadge({ port }: StaticPortBadgeProps) { - const handleOpenInBrowser = () => { - window.open(`http://localhost:${port.port}`, "_blank"); - }; - - return ( - - -
- {port.label} - -
-
- -
-
localhost:{port.port}
-
-
-
- ); -} 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 new file mode 100644 index 0000000000..441163d148 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -0,0 +1,107 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuExternalLink } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { MergedPort } from "shared/types"; +import { STROKE_WIDTH } from "../../../constants"; + +interface MergedPortBadgeProps { + port: MergedPort; + isCurrentWorkspace: boolean; +} + +export function MergedPortBadge({ + port, + isCurrentWorkspace, +}: MergedPortBadgeProps) { + const setActiveTab = useTabsStore((s) => s.setActiveTab); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const setActiveMutation = trpc.workspaces.setActive.useMutation(); + const utils = trpc.useUtils(); + + // Display "label - port" for static ports, otherwise just port number for dynamic + const displayText = port.label + ? `${port.label} - ${port.port}` + : port.port.toString(); + + // Can jump to terminal only if active and has paneId + const canJumpToTerminal = port.isActive && port.paneId; + + const handleClick = async () => { + if (!canJumpToTerminal || !port.paneId) return; + + // If not in current workspace, switch to it first + if (!isCurrentWorkspace) { + await setActiveMutation.mutateAsync({ id: port.workspaceId }); + await utils.workspaces.getActive.invalidate(); + } + + // Look up pane after potential workspace switch + const pane = useTabsStore.getState().panes[port.paneId]; + if (!pane) return; + + // Set the tab as active for this workspace + setActiveTab(port.workspaceId, pane.tabId); + + // Focus the specific pane + setFocusedPane(pane.tabId, port.paneId); + }; + + const handleOpenInBrowser = () => { + window.open(`http://localhost:${port.port}`, "_blank"); + }; + + // Consistent styling regardless of active state + const badgeClasses = isCurrentWorkspace + ? "bg-primary/10 text-primary hover:bg-primary/20" + : "bg-muted/50 text-muted-foreground hover:bg-muted"; + + return ( + + +
+ + +
+
+ +
+ {port.label &&
{port.label}
} +
+ localhost:{port.port} +
+ {port.isActive && ( + <> +
+ {port.processName} (pid {port.pid}) +
+ {canJumpToTerminal && ( +
+ Click to open workspace +
+ )} + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/index.ts new file mode 100644 index 0000000000..87e4d959f0 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/index.ts @@ -0,0 +1 @@ +export { MergedPortBadge } from "./MergedPortBadge"; 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 new file mode 100644 index 0000000000..de0001109e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..abe6088f85 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/merge-ports.ts @@ -0,0 +1,69 @@ +import type { DetectedPort, MergedPort, StaticPort } from "shared/types"; + +/** + * Merge static port configuration with dynamically detected ports. + * + * Logic: + * 1. Start with all static ports (always shown, even when inactive) + * 2. For dynamic ports matching a static port number: merge process info, mark active + * 3. For dynamic ports not in static config: add as dynamic-only entries + * 4. Sort by port number + */ +export function mergePorts({ + staticPorts, + dynamicPorts, + workspaceId, +}: { + staticPorts: StaticPort[]; + dynamicPorts: DetectedPort[]; + workspaceId: string; +}): MergedPort[] { + // Filter dynamic ports for current workspace + const workspaceDynamicPorts = dynamicPorts.filter( + (p) => p.workspaceId === workspaceId, + ); + + // Create a map of port number -> dynamic port info + const dynamicByPort = new Map(workspaceDynamicPorts.map((p) => [p.port, p])); + + // Create a set of port numbers that have static config + const staticPortNumbers = new Set(staticPorts.map((p) => p.port)); + + const merged: MergedPort[] = []; + + // 1. Process all static ports (they always appear) + for (const staticPort of staticPorts) { + const dynamic = dynamicByPort.get(staticPort.port); + merged.push({ + port: staticPort.port, + workspaceId, + label: staticPort.label, + isActive: !!dynamic, + pid: dynamic?.pid ?? null, + processName: dynamic?.processName ?? null, + paneId: dynamic?.paneId ?? null, + address: dynamic?.address ?? null, + detectedAt: dynamic?.detectedAt ?? null, + }); + } + + // 2. Add dynamic-only ports (not in static config) + for (const dynamic of workspaceDynamicPorts) { + if (!staticPortNumbers.has(dynamic.port)) { + merged.push({ + port: dynamic.port, + workspaceId, + label: null, + isActive: true, + pid: dynamic.pid, + processName: dynamic.processName, + paneId: dynamic.paneId, + address: dynamic.address, + detectedAt: dynamic.detectedAt, + }); + } + } + + // Sort by port number + return merged.sort((a, b) => a.port - b.port); +} diff --git a/apps/desktop/src/shared/types/ports.ts b/apps/desktop/src/shared/types/ports.ts index 889bcee51d..34716c440a 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -19,3 +19,15 @@ export interface StaticPortsResult { ports: Omit[] | null; error: string | null; } + +export interface MergedPort { + port: number; + workspaceId: string; + label: string | null; + isActive: boolean; + pid: number | null; + processName: string | null; + paneId: string | null; + address: string | null; + detectedAt: number | null; +} From 1b374703b0e1aeec2791361aaa973c0ba8b2fc7d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 9 Jan 2026 12:30:06 -0800 Subject: [PATCH 5/9] remove redundant comments --- .../src/lib/trpc/routers/ports/ports.ts | 2 -- .../WorkspaceSidebar/PortsList/PortsList.tsx | 20 +------------------ .../MergedPortBadge/MergedPortBadge.tsx | 8 -------- .../PortsList/utils/merge-ports.ts | 8 -------- 4 files changed, 1 insertion(+), 37 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 63893fbc4f..43684314c9 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -93,7 +93,6 @@ export const createPortsRouter = () => { return { ports: null, error: result.error }; } - // Add workspaceId to each port const portsWithWorkspace: StaticPort[] = result.ports?.map((p) => ({ ...p, @@ -158,7 +157,6 @@ export const createPortsRouter = () => { return () => {}; } - // Start watching the file staticPortsWatcher.watch(input.workspaceId, workspacePath); const onChange = (changedWorkspaceId: string) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index c363d3b095..78f08ba59f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -27,33 +27,26 @@ export function PortsList() { const utils = trpc.useUtils(); - // Fetch static ports for all workspaces const { data: allStaticPortsData } = trpc.ports.getAllStatic.useQuery(); - // Subscribe to static ports file changes for active workspace - // (triggers refetch of all static ports when file changes) trpc.ports.subscribeStatic.useSubscription( { workspaceId: activeWorkspace?.id ?? "" }, { enabled: !!activeWorkspace?.id, onData: () => { - // Invalidate to refetch all static ports utils.ports.getAllStatic.invalidate(); }, }, ); - // Fetch initial dynamic ports const { data: initialPorts } = trpc.ports.getAll.useQuery(); - // Set initial dynamic ports when they load useEffect(() => { if (initialPorts) { setPorts(initialPorts); } }, [initialPorts, setPorts]); - // Subscribe to dynamic port changes trpc.ports.subscribe.useSubscription(undefined, { onData: (event) => { if (event.type === "add") { @@ -64,7 +57,6 @@ export function PortsList() { }, }); - // Create a map of workspace IDs to names const workspaceNames = useMemo(() => { if (!allWorkspaces) return {}; return allWorkspaces.reduce( @@ -76,10 +68,9 @@ export function PortsList() { ); }, [allWorkspaces]); - // Track which errors we've shown toasts for + // Prevent showing duplicate error toasts on re-renders const shownErrorsRef = useRef>(new Set()); - // Show toast errors for any static port loading errors useEffect(() => { const errors = allStaticPortsData?.errors ?? []; for (const { workspaceId, error } of errors) { @@ -95,16 +86,13 @@ export function PortsList() { } }, [allStaticPortsData?.errors, workspaceNames]); - // Get all workspace IDs that have either static or dynamic ports const allWorkspaceIds = useMemo(() => { const ids = new Set(); - // Add workspaces with static ports for (const port of allStaticPortsData?.ports ?? []) { ids.add(port.workspaceId); } - // Add workspaces with dynamic ports for (const port of ports) { ids.add(port.workspaceId); } @@ -112,17 +100,14 @@ export function PortsList() { return Array.from(ids); }, [allStaticPortsData?.ports, ports]); - // Merge static + dynamic ports for ALL workspaces, grouped const workspacePortGroups = useMemo(() => { const allStaticPorts = allStaticPortsData?.ports ?? []; const groups = allWorkspaceIds.map((workspaceId) => { - // Get static ports for this workspace const staticPortsForWorkspace = allStaticPorts.filter( (p) => p.workspaceId === workspaceId, ); - // Merge with dynamic ports const merged = mergePorts({ staticPorts: staticPortsForWorkspace, dynamicPorts: ports, @@ -137,7 +122,6 @@ export function PortsList() { }; }); - // Sort: current workspace first, then alphabetically groups.sort((a, b) => { if (a.isCurrentWorkspace && !b.isCurrentWorkspace) return -1; if (!a.isCurrentWorkspace && b.isCurrentWorkspace) return 1; @@ -153,13 +137,11 @@ export function PortsList() { activeWorkspace?.id, ]); - // Calculate total port count for display const totalPortCount = workspacePortGroups.reduce( (sum, g) => sum + g.ports.length, 0, ); - // Don't render if there are no ports (static or dynamic) if (totalPortCount === 0) { return null; } 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 441163d148..4c8718e2d1 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 @@ -19,31 +19,24 @@ export function MergedPortBadge({ const setActiveMutation = trpc.workspaces.setActive.useMutation(); const utils = trpc.useUtils(); - // Display "label - port" for static ports, otherwise just port number for dynamic const displayText = port.label ? `${port.label} - ${port.port}` : port.port.toString(); - // Can jump to terminal only if active and has paneId const canJumpToTerminal = port.isActive && port.paneId; const handleClick = async () => { if (!canJumpToTerminal || !port.paneId) return; - // If not in current workspace, switch to it first if (!isCurrentWorkspace) { await setActiveMutation.mutateAsync({ id: port.workspaceId }); await utils.workspaces.getActive.invalidate(); } - // Look up pane after potential workspace switch const pane = useTabsStore.getState().panes[port.paneId]; if (!pane) return; - // Set the tab as active for this workspace setActiveTab(port.workspaceId, pane.tabId); - - // Focus the specific pane setFocusedPane(pane.tabId, port.paneId); }; @@ -51,7 +44,6 @@ export function MergedPortBadge({ window.open(`http://localhost:${port.port}`, "_blank"); }; - // Consistent styling regardless of active state const badgeClasses = isCurrentWorkspace ? "bg-primary/10 text-primary hover:bg-primary/20" : "bg-muted/50 text-muted-foreground hover:bg-muted"; 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 index abe6088f85..ba87af6036 100644 --- 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 @@ -18,20 +18,14 @@ export function mergePorts({ dynamicPorts: DetectedPort[]; workspaceId: string; }): MergedPort[] { - // Filter dynamic ports for current workspace const workspaceDynamicPorts = dynamicPorts.filter( (p) => p.workspaceId === workspaceId, ); - // Create a map of port number -> dynamic port info const dynamicByPort = new Map(workspaceDynamicPorts.map((p) => [p.port, p])); - - // Create a set of port numbers that have static config const staticPortNumbers = new Set(staticPorts.map((p) => p.port)); - const merged: MergedPort[] = []; - // 1. Process all static ports (they always appear) for (const staticPort of staticPorts) { const dynamic = dynamicByPort.get(staticPort.port); merged.push({ @@ -47,7 +41,6 @@ export function mergePorts({ }); } - // 2. Add dynamic-only ports (not in static config) for (const dynamic of workspaceDynamicPorts) { if (!staticPortNumbers.has(dynamic.port)) { merged.push({ @@ -64,6 +57,5 @@ export function mergePorts({ } } - // Sort by port number return merged.sort((a, b) => a.port - b.port); } From ec049a059d4bcbf87b5d880e0630851cc2d90067 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 9 Jan 2026 13:58:50 -0800 Subject: [PATCH 6/9] update UI --- .../WorkspaceSidebar/PortsList/PortsList.tsx | 35 +++++++++++++++++-- .../MergedPortBadge/MergedPortBadge.tsx | 23 ++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index 78f08ba59f..17d86ec5ec 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -1,6 +1,8 @@ +import { COMPANY } from "@superset/shared/constants"; import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useEffect, useMemo, useRef } from "react"; -import { LuChevronRight, LuRadioTower } from "react-icons/lu"; +import { LuChevronRight, LuCircleHelp, LuRadioTower } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { usePortsStore } from "renderer/stores"; import type { MergedPort } from "shared/types"; @@ -8,6 +10,8 @@ import { STROKE_WIDTH } from "../constants"; import { MergedPortBadge } from "./components/MergedPortBadge"; import { mergePorts } from "./utils"; +const PORTS_DOCS_URL = `https://${COMPANY.DOMAIN}/ports`; + interface MergedWorkspaceGroup { workspaceId: string; workspaceName: string; @@ -146,13 +150,18 @@ export function PortsList() { return null; } + const handleOpenPortsDocs = (e: React.MouseEvent) => { + e.stopPropagation(); + window.open(PORTS_DOCS_URL, "_blank"); + }; + return (
{!isCollapsed && ( -
+
{workspacePortGroups.map((group) => ( ))} 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 4c8718e2d1..f50aa3ac1a 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 @@ -19,9 +19,20 @@ export function MergedPortBadge({ const setActiveMutation = trpc.workspaces.setActive.useMutation(); const utils = trpc.useUtils(); - const displayText = port.label - ? `${port.label} - ${port.port}` - : port.port.toString(); + const portNumberColor = port.isActive + ? "text-muted-foreground" + : "text-muted-foreground/80"; + + const displayContent = port.label ? ( + <> + {port.label}{" "} + + {port.port} + + + ) : ( + {port.port} + ); const canJumpToTerminal = port.isActive && port.paneId; @@ -52,7 +63,7 @@ export function MergedPortBadge({
From a369febae5f9c2549ca71fc59bf0232c066d4399 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 9 Jan 2026 14:14:39 -0800 Subject: [PATCH 7/9] update UI --- .../WorkspaceSidebar/PortsList/PortsList.tsx | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index 17d86ec5ec..dc7e504fcb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -157,44 +157,39 @@ export function PortsList() { return (
- + - { - if (e.key === "Enter" || e.key === " ") { - handleOpenPortsDocs(e as unknown as React.MouseEvent); - } - }} - className="p-0.5 rounded hover:bg-muted/50 opacity-0 group-hover:opacity-100 transition-opacity" + className="ml-auto p-0.5 rounded hover:bg-muted/50 opacity-0 group-hover:opacity-100 transition-opacity" > - +

Learn about static port configuration

- + {totalPortCount} +
{!isCollapsed && ( -
+
{workspacePortGroups.map((group) => ( ))} From a4521403fc38b959a811766dd6d2a855c48c23b2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 9 Jan 2026 14:16:21 -0800 Subject: [PATCH 8/9] address comments --- .../components/MergedPortBadge/MergedPortBadge.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 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 f50aa3ac1a..4aaeca42b7 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 @@ -93,9 +93,12 @@ export function MergedPortBadge({
{port.isActive && ( <> -
- {port.processName} (pid {port.pid}) -
+ {(port.processName || port.pid != null) && ( +
+ {port.processName} + {port.pid != null && ` (pid ${port.pid})`} +
+ )} {canJumpToTerminal && (
Click to open workspace From c017c96a31e1316a36892ff98c74d2eba63c9cd2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 9 Jan 2026 14:24:00 -0800 Subject: [PATCH 9/9] Refactor components --- .../WorkspaceSidebar/PortsList/PortsList.tsx | 182 +----------------- .../WorkspacePortGroup/WorkspacePortGroup.tsx | 45 +++++ .../components/WorkspacePortGroup/index.ts | 1 + .../PortsList/hooks/usePortsData.ts | 146 ++++++++++++++ 4 files changed, 196 insertions(+), 178 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index dc7e504fcb..96da16909e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -1,150 +1,18 @@ import { COMPANY } from "@superset/shared/constants"; -import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useEffect, useMemo, useRef } from "react"; import { LuChevronRight, LuCircleHelp, LuRadioTower } from "react-icons/lu"; -import { trpc } from "renderer/lib/trpc"; import { usePortsStore } from "renderer/stores"; -import type { MergedPort } from "shared/types"; import { STROKE_WIDTH } from "../constants"; -import { MergedPortBadge } from "./components/MergedPortBadge"; -import { mergePorts } from "./utils"; +import { WorkspacePortGroup } from "./components/WorkspacePortGroup"; +import { usePortsData } from "./hooks/usePortsData"; const PORTS_DOCS_URL = `https://${COMPANY.DOMAIN}/ports`; -interface MergedWorkspaceGroup { - workspaceId: string; - workspaceName: string; - isCurrentWorkspace: boolean; - ports: MergedPort[]; -} - export function PortsList() { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const { data: allWorkspaces } = trpc.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 isCollapsed = usePortsStore((s) => s.isListCollapsed); const toggleCollapsed = usePortsStore((s) => s.toggleListCollapsed); - const utils = trpc.useUtils(); - - const { data: allStaticPortsData } = trpc.ports.getAllStatic.useQuery(); - - trpc.ports.subscribeStatic.useSubscription( - { workspaceId: activeWorkspace?.id ?? "" }, - { - enabled: !!activeWorkspace?.id, - onData: () => { - utils.ports.getAllStatic.invalidate(); - }, - }, - ); - - const { data: initialPorts } = trpc.ports.getAll.useQuery(); - - useEffect(() => { - if (initialPorts) { - setPorts(initialPorts); - } - }, [initialPorts, setPorts]); - - trpc.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 workspaceNames = useMemo(() => { - if (!allWorkspaces) return {}; - return allWorkspaces.reduce( - (acc, ws) => { - acc[ws.id] = ws.name; - return acc; - }, - {} as Record, - ); - }, [allWorkspaces]); - - // Prevent showing duplicate error toasts on re-renders - const shownErrorsRef = useRef>(new Set()); - - 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, - }); - } - } - }, [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); - } - - return Array.from(ids); - }, [allStaticPortsData?.ports, ports]); - - const workspacePortGroups = useMemo(() => { - const allStaticPorts = allStaticPortsData?.ports ?? []; - - const groups = 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", - isCurrentWorkspace: workspaceId === activeWorkspace?.id, - ports: merged, - }; - }); - - groups.sort((a, b) => { - if (a.isCurrentWorkspace && !b.isCurrentWorkspace) return -1; - if (!a.isCurrentWorkspace && b.isCurrentWorkspace) return 1; - return a.workspaceName.localeCompare(b.workspaceName); - }); - - return groups; - }, [ - allWorkspaceIds, - allStaticPortsData?.ports, - ports, - workspaceNames, - activeWorkspace?.id, - ]); - - const totalPortCount = workspacePortGroups.reduce( - (sum, g) => sum + g.ports.length, - 0, - ); + const { workspacePortGroups, totalPortCount } = usePortsData(); if (totalPortCount === 0) { return null; @@ -191,52 +59,10 @@ export function PortsList() { {!isCollapsed && (
{workspacePortGroups.map((group) => ( - + ))}
)}
); } - -interface MergedWorkspacePortGroupProps { - group: MergedWorkspaceGroup; -} - -function MergedWorkspacePortGroup({ group }: MergedWorkspacePortGroupProps) { - const setActiveMutation = trpc.workspaces.setActive.useMutation(); - const utils = trpc.useUtils(); - - const handleWorkspaceClick = async () => { - if (group.isCurrentWorkspace) return; - - await setActiveMutation.mutateAsync({ id: group.workspaceId }); - await utils.workspaces.getActive.invalidate(); - }; - - return ( -
- -
- {group.ports.map((port) => ( - - ))} -
-
- ); -} 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 new file mode 100644 index 0000000000..9c49ae2dd4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx @@ -0,0 +1,45 @@ +import { trpc } from "renderer/lib/trpc"; +import type { MergedWorkspaceGroup } from "../../hooks/usePortsData"; +import { MergedPortBadge } from "../MergedPortBadge"; + +interface WorkspacePortGroupProps { + group: MergedWorkspaceGroup; +} + +export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { + const setActiveMutation = trpc.workspaces.setActive.useMutation(); + const utils = trpc.useUtils(); + + const handleWorkspaceClick = async () => { + if (group.isCurrentWorkspace) return; + + await setActiveMutation.mutateAsync({ id: group.workspaceId }); + await utils.workspaces.getActive.invalidate(); + }; + + return ( +
+ +
+ {group.ports.map((port) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/index.ts new file mode 100644 index 0000000000..235d74f9ac --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/index.ts @@ -0,0 +1 @@ +export { WorkspacePortGroup } from "./WorkspacePortGroup"; 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 new file mode 100644 index 0000000000..b9498c8379 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -0,0 +1,146 @@ +import { toast } from "@superset/ui/sonner"; +import { useEffect, useMemo, useRef } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { usePortsStore } from "renderer/stores"; +import type { MergedPort } from "shared/types"; +import { mergePorts } from "../utils"; + +export interface MergedWorkspaceGroup { + workspaceId: string; + workspaceName: string; + isCurrentWorkspace: boolean; + ports: MergedPort[]; +} + +export function usePortsData() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: allWorkspaces } = trpc.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 = trpc.useUtils(); + + const { data: allStaticPortsData } = trpc.ports.getAllStatic.useQuery(); + + trpc.ports.subscribeStatic.useSubscription( + { workspaceId: activeWorkspace?.id ?? "" }, + { + enabled: !!activeWorkspace?.id, + onData: () => { + utils.ports.getAllStatic.invalidate(); + }, + }, + ); + + const { data: initialPorts } = trpc.ports.getAll.useQuery(); + + useEffect(() => { + if (initialPorts) { + setPorts(initialPorts); + } + }, [initialPorts, setPorts]); + + trpc.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 workspaceNames = useMemo(() => { + if (!allWorkspaces) return {}; + return allWorkspaces.reduce( + (acc, ws) => { + acc[ws.id] = ws.name; + return acc; + }, + {} as Record, + ); + }, [allWorkspaces]); + + // Prevent showing duplicate error toasts on re-renders + const shownErrorsRef = useRef>(new Set()); + + 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, + }); + } + } + }, [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); + } + + 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", + isCurrentWorkspace: workspaceId === activeWorkspace?.id, + ports: merged, + }; + }, + ); + + groups.sort((a, b) => { + if (a.isCurrentWorkspace && !b.isCurrentWorkspace) return -1; + if (!a.isCurrentWorkspace && b.isCurrentWorkspace) return 1; + return a.workspaceName.localeCompare(b.workspaceName); + }); + + return groups; + }, [ + allWorkspaceIds, + allStaticPortsData?.ports, + ports, + workspaceNames, + activeWorkspace?.id, + ]); + + const totalPortCount = workspacePortGroups.reduce( + (sum, g) => sum + g.ports.length, + 0, + ); + + return { + workspacePortGroups, + totalPortCount, + }; +}