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 00000000000..63d737066cc --- /dev/null +++ b/apps/desktop/plans/20260108-2251-static-ports-json.md @@ -0,0 +1,389 @@ +# 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 `@superset/ui/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~~ (SKIPPED) + +> **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 + +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 +- `@superset/ui/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 65c8dae3224..43684314c96 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 } @@ -9,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) => { @@ -34,5 +42,136 @@ export const createPortsRouter = () => { }; }); }), + + hasStaticConfig: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }): { hasStatic: boolean } => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); + + if (!workspace) { + return { hasStatic: false }; + } + + const workspacePath = getWorkspacePath(workspace); + if (!workspacePath) { + return { hasStatic: false }; + } + + return { hasStatic: hasStaticPortsConfig(workspacePath) }; + }), + + getStatic: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query( + ({ input }): { ports: StaticPort[] | null; error: string | null } => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); + + if (!workspace) { + return { ports: null, error: "Workspace not found" }; + } + + const workspacePath = getWorkspacePath(workspace); + if (!workspacePath) { + return { ports: null, error: "Workspace path not found" }; + } + + const result = loadStaticPorts(workspacePath); + + if (!result.exists) { + return { ports: null, error: null }; + } + + if (result.error) { + return { ports: null, error: result.error }; + } + + const portsWithWorkspace: StaticPort[] = + result.ports?.map((p) => ({ + ...p, + workspaceId: input.workspaceId, + })) ?? []; + + return { ports: portsWithWorkspace, error: null }; + }, + ), + + getAllStatic: publicProcedure.query( + (): { + ports: StaticPort[]; + errors: Array<{ workspaceId: string; error: string }>; + } => { + const allWorkspaces = localDb.select().from(workspaces).all(); + const allPorts: StaticPort[] = []; + const errors: Array<{ workspaceId: string; error: string }> = []; + + for (const workspace of allWorkspaces) { + const workspacePath = getWorkspacePath(workspace); + if (!workspacePath) continue; + + const result = loadStaticPorts(workspacePath); + + if (!result.exists) continue; + + if (result.error) { + errors.push({ workspaceId: workspace.id, error: result.error }); + continue; + } + + if (result.ports) { + const portsWithWorkspace = result.ports.map((p) => ({ + ...p, + workspaceId: workspace.id, + })); + allPorts.push(...portsWithWorkspace); + } + } + + return { ports: allPorts, errors }; + }, + ), + + subscribeStatic: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .subscription(({ input }) => { + return observable<{ type: "change" }>((emit) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); + + if (!workspace) { + return () => {}; + } + + const workspacePath = getWorkspacePath(workspace); + if (!workspacePath) { + return () => {}; + } + + staticPortsWatcher.watch(input.workspaceId, workspacePath); + + const onChange = (changedWorkspaceId: string) => { + if (changedWorkspaceId === input.workspaceId) { + emit.next({ type: "change" }); + } + }; + + staticPortsWatcher.on("change", onChange); + + return () => { + staticPortsWatcher.off("change", onChange); + staticPortsWatcher.unwatch(input.workspaceId); + }; + }); + }), }); }; 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 00000000000..d437a2c59d7 --- /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 00000000000..55c6c0d0139 --- /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 00000000000..b03684183de --- /dev/null +++ b/apps/desktop/src/main/lib/static-ports/loader.ts @@ -0,0 +1,164 @@ +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; +} + +/** + * 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, +): + | { 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 00000000000..b85efd13069 --- /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 8fa5ea52aa1..96da16909e5 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,119 +1,64 @@ +import { COMPANY } from "@superset/shared/constants"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useEffect, useMemo } 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 { LuChevronRight, LuCircleHelp, LuRadioTower } from "react-icons/lu"; +import { usePortsStore } from "renderer/stores"; import { STROKE_WIDTH } from "../constants"; +import { WorkspacePortGroup } from "./components/WorkspacePortGroup"; +import { usePortsData } from "./hooks/usePortsData"; -interface WorkspaceGroup { - workspaceId: string; - workspaceName: string; - isCurrentWorkspace: boolean; - ports: DetectedPort[]; -} +const PORTS_DOCS_URL = `https://${COMPANY.DOMAIN}/ports`; 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); - // Fetch initial ports - const { data: initialPorts } = trpc.ports.getAll.useQuery(); - - // Set initial ports when they load - useEffect(() => { - if (initialPorts) { - setPorts(initialPorts); - } - }, [initialPorts, setPorts]); - - // Subscribe to port changes - 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); - } - }, - }); - - // Create a map of workspace IDs to names - const workspaceNames = useMemo(() => { - if (!allWorkspaces) return {}; - return allWorkspaces.reduce( - (acc, ws) => { - acc[ws.id] = ws.name; - return acc; - }, - {} as Record, - ); - }, [allWorkspaces]); - - // Group ports by workspace, sorted with current workspace first - const groupedPorts = useMemo(() => { - const groups: Record = {}; - - for (const port of ports) { - if (!groups[port.workspaceId]) { - groups[port.workspaceId] = []; - } - groups[port.workspaceId].push(port); - } + const { workspacePortGroups, totalPortCount } = usePortsData(); - // Sort ports within each group by port number - for (const workspaceId of Object.keys(groups)) { - groups[workspaceId].sort((a, b) => a.port - b.port); - } - - // Convert to array and sort groups (current workspace first) - const result: WorkspaceGroup[] = Object.entries(groups).map( - ([workspaceId, workspacePorts]) => ({ - workspaceId, - workspaceName: workspaceNames[workspaceId] || "Unknown", - isCurrentWorkspace: workspaceId === activeWorkspace?.id, - ports: workspacePorts, - }), - ); - - result.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]); - - if (ports.length === 0) { + if (totalPortCount === 0) { return null; } + const handleOpenPortsDocs = (e: React.MouseEvent) => { + e.stopPropagation(); + window.open(PORTS_DOCS_URL, "_blank"); + }; + return (
- +
+ + + + + + + +

Learn about static port configuration

+
+
+ {totalPortCount} +
{!isCollapsed && ( -
- {groupedPorts.map((group) => ( +
+ {workspacePortGroups.map((group) => ( ))}
@@ -121,120 +66,3 @@ export function PortsList() {
); } - -interface WorkspacePortGroupProps { - group: WorkspaceGroup; -} - -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) => ( - - ))} -
-
- ); -} - -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 -
-
-
-
- ); -} 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 00000000000..4aaeca42b7b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -0,0 +1,113 @@ +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(); + + 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; + + const handleClick = async () => { + if (!canJumpToTerminal || !port.paneId) return; + + if (!isCurrentWorkspace) { + await setActiveMutation.mutateAsync({ id: port.workspaceId }); + await utils.workspaces.getActive.invalidate(); + } + + const pane = useTabsStore.getState().panes[port.paneId]; + if (!pane) return; + + setActiveTab(port.workspaceId, pane.tabId); + setFocusedPane(pane.tabId, port.paneId); + }; + + const handleOpenInBrowser = () => { + window.open(`http://localhost:${port.port}`, "_blank"); + }; + + 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 || port.pid != null) && ( +
+ {port.processName} + {port.pid != null && ` (pid ${port.pid})`} +
+ )} + {canJumpToTerminal && ( +
+ Click to open workspace +
+ )} + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/index.ts new file mode 100644 index 00000000000..87e4d959f04 --- /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/components/WorkspacePortGroup/WorkspacePortGroup.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx new file mode 100644 index 00000000000..9c49ae2dd4b --- /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 00000000000..235d74f9acd --- /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 00000000000..b9498c83797 --- /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, + }; +} 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 00000000000..de0001109e9 --- /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 00000000000..ba87af60365 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/utils/merge-ports.ts @@ -0,0 +1,61 @@ +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[] { + const workspaceDynamicPorts = dynamicPorts.filter( + (p) => p.workspaceId === workspaceId, + ); + + const dynamicByPort = new Map(workspaceDynamicPorts.map((p) => [p.port, p])); + const staticPortNumbers = new Set(staticPorts.map((p) => p.port)); + const merged: MergedPort[] = []; + + 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, + }); + } + + 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, + }); + } + } + + return merged.sort((a, b) => a.port - b.port); +} diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index d35968ade46..fe0b04b5786 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 fff8a53f978..34716c440af 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -7,3 +7,27 @@ 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; +} + +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; +} diff --git a/apps/marketing/src/app/ports/page.tsx b/apps/marketing/src/app/ports/page.tsx new file mode 100644 index 00000000000..16b54eef89d --- /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 +
  • +
+
+
+
+
+ ); +}