diff --git a/.cursor/rules/shadcn-ui.mdc b/.cursor/rules/shadcn-ui.mdc new file mode 100644 index 0000000..706c5da --- /dev/null +++ b/.cursor/rules/shadcn-ui.mdc @@ -0,0 +1,33 @@ +--- +description: Deckhand UI must use shadcn/ui Maia + Tailwind only +globs: src/**/*.{tsx,ts,css} +alwaysApply: false +--- + +# shadcn Maia UI only + +Deckhand’s React UI uses **shadcn/ui** with the **Maia** preset (`base-maia`, preset `bbVJxYW`) and **Tailwind CSS v4**. + +Icons use **[Lucide React](https://lucide.dev/guide/react/)** (`lucide-react`) via `lucideProps()` / `Icon` in `src/components/Icon.tsx`. + +## Required + +- Primitives from `@/components/ui/*` (generated by shadcn CLI) +- Layout/tokens via Tailwind utility classes and CSS variables from `src/index.css` +- Root: `AppProvider` (theme + `TooltipProvider`), Sonner toasts via `@/lib/toast` +- Button events: standard `onClick` on shadcn/Radix components +- `cn()` from `@/lib/utils` for conditional classes + +## Do not add + +- Adobe React Spectrum (`@react-spectrum/s2`, `@adobe/react-spectrum`), Spectrum Charts, or `style()` macros +- Parallel component kits (MUI, Chakra, Ant Design) alongside shadcn +- Hand-rolled Radix wrappers that duplicate `@/components/ui` + +## App helpers + +Prefer shadcn primitives and small app helpers (`Tip`, `StatusBadge`, `RowMenu`, `Field`, `MoreActionsMenu`, `SegmentedControl`) under `src/components/` — do not reintroduce Spectrum providers or macros. + +## Custom widgets + +When shadcn has no component (e.g. xterm, custom charts), style chrome with Tailwind tokens. Do not reintroduce a second design system. diff --git a/.cursor/rules/spectrum-ui.mdc b/.cursor/rules/spectrum-ui.mdc deleted file mode 100644 index 50ec875..0000000 --- a/.cursor/rules/spectrum-ui.mdc +++ /dev/null @@ -1,33 +0,0 @@ ---- -description: Deckhand UI must use Adobe Spectrum 2 only -globs: app/src/**/*.{tsx,ts,css} -alwaysApply: false ---- - -# Spectrum 2 UI only - -Deckhand’s React UI uses **Adobe React Spectrum 2** (`@react-spectrum/s2`) for components and layout. -Icons use **[Lucide React](https://lucide.dev/guide/react/)** (`lucide-react`) — not official Spectrum, but the chosen set for this app. - -## Required - -- Components from `@react-spectrum/s2` -- Icons from `lucide-react` via `lucideProps()` / `Icon` in `app/src/components/Icon.tsx` (size tokens S/M/L, stroke 1.75) -- Layout/tokens via `import { style } from '@react-spectrum/s2/style' with { type: 'macro' }` -- Root: `Provider`, `@react-spectrum/s2/page.css`, `ToastContainer` / `ToastQueue` -- Button events: `onPress` (not `onClick`) on Spectrum components -- `style()` arguments must be **static** object literals (build-time macro) - -## Do not add - -- Radix UI, shadcn/ui, Tailwind utility styling, MUI, Chakra, Ant Design, or parallel component kits -- `@react-spectrum/s2/icons/*` (use Lucide instead) -- Sonner or non-Spectrum toast libraries - -## App helpers - -Small Spectrum helpers live under `app/src/components/spectrum/` (`Tip`, `StatusBadge`, `RowMenu`, `Field`/`Area`). Prefer these or raw `@react-spectrum/s2` — do not recreate `components/ui` or shadcn-style wrappers. - -## Custom widgets - -When S2 has no component (e.g. xterm, custom charts), style chrome with Spectrum tokens / `style` macros (and React Aria if needed). Do not reintroduce a second design system. diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e1e9f4c --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Mechanical reformats — skip in blame. +# Enable with: git config blame.ignoreRevsFile .git-blame-ignore-revs + +# Apply Biome formatting across the TypeScript sources +ea6d2d05947e562f45cf62c954035165d1f84ba1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ec0ad2..d3df44f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,33 @@ jobs: with: go-version-file: sidecar/go.mod cache-dependency-path: sidecar/go.sum - - name: Build sidecar + - name: Format check + working-directory: sidecar + run: | + # gofmt -l exits 0 even when it lists files, so check explicitly. + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "Not gofmt-formatted:" + echo "$unformatted" + echo "Fix with: gofmt -w sidecar/" + exit 1 + fi + - name: Build all packages + working-directory: sidecar + run: go build ./... + - name: Vet + working-directory: sidecar + run: go vet ./... + - name: Test + working-directory: sidecar + run: go test ./... + - name: Build sidecar binary run: bash scripts/build-sidecar.sh - name: Upload sidecar artifact uses: actions/upload-artifact@v7 with: name: deckhand-sidecar-${{ matrix.os }} - path: tauri/src-tauri/binaries/deckhand-sidecar* + path: src-tauri/binaries/deckhand-sidecar* ui: runs-on: ubuntu-latest @@ -34,8 +54,9 @@ jobs: bun-version: latest - run: bun install - run: bun run version:check - - run: bun run --filter @deckhand/app build - - run: bun run --filter @deckhand/tauri build + - name: Lint and format check + run: bunx biome ci . + - run: bun run build # Installer packaging: .github/workflows/release.yml (on v* tags). # Local: bun run tauri:build → .dmg/.app (macOS) or .deb/AppImage (Linux). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a01e56..5c7facd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,7 +88,7 @@ jobs: - uses: swatinem/rust-cache@v2 with: - workspaces: tauri/src-tauri -> target + workspaces: src-tauri -> target - name: Install JS dependencies run: bun install @@ -101,7 +101,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - projectPath: tauri tagName: ${{ steps.tag.outputs.name }} releaseName: Deckhand ${{ steps.tag.outputs.name }} releaseBody: | diff --git a/.gitignore b/.gitignore index 5af8650..8ce4b59 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,14 @@ target/ .env .env.* sidecar/deckhand-sidecar -tauri/src-tauri/binaries/ -tauri/src-tauri/target/ +bin/ +src-tauri/binaries/ +src-tauri/target/ *.dmg *.deb *.AppImage .idea/ .vscode/ coverage/ +releases/ *.exe diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..44aa1c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +bun install +bun run dev # build sidecar → Tauri dev (Vite on :1420) +bun run dev:ui # Vite UI only; pair with VITE_SIDECAR_URL +bun run dev:sidecar # go run the sidecar on 127.0.0.1:7420 +bun run build # tsc --noEmit && vite build (the UI typecheck gate) +bun run build:sidecar # scripts/build-sidecar.sh → src-tauri/binaries/ +bun run version 0.1.0-x # sync package.json + Cargo.toml +bun run version:check # fail if Cargo.toml ≠ root package.json version +bun run tauri:build # sidecar + installers (.dmg/.app, .deb/AppImage) + +cd sidecar && go build ./... && go vet ./... # sidecar checks +``` + +There are **no tests yet** — 32 Go files, zero `*_test.go`, and no JS test runner. Verification +is the build: `bun run build` for the UI, `go build ./... && go vet ./...` for the sidecar. CI +(`.github/workflows/ci.yml`) runs exactly those plus `go test ./...` and `bun run version:check`. +CI also gates `gofmt` on `sidecar/` — fix a failure with `gofmt -w sidecar/`. There is no +formatter or linter configured for the TypeScript side; `tsc --noEmit` is the only gate there. + +xterm.js is code-split behind `ExecTerminalLazy` (~85 KB gzipped, the largest single dependency). +Import that wrapper rather than `ExecTerminal` directly, or the terminal lands back in the initial +bundle. + +The repo is a **single Bun package at the root** — no workspaces, one `package.json`, one +`vite.config.ts`, one `tsconfig.json`, one `index.html`. `sidecar/` is a separate Go module. +Do not reintroduce a second Vite app or a workspace member for the frontend. + +Split-process dev (UI iteration without rebuilding Tauri) — stop any Tauri Vite on `:1420` first, +since `strictPort` is on: + +```bash +./src-tauri/binaries/deckhand-sidecar --addr 127.0.0.1:7420 +VITE_SIDECAR_URL=http://127.0.0.1:7420 bun run dev:ui +``` + +## Architecture + +Three processes, one direction of travel: + +``` +React UI (src/) ──HTTP+WS localhost──► Go sidecar (sidecar/) ──► Docker Engine API + ├──► kubeconfig (client-go) + ├──► helm CLI + Tauri 2 (src-tauri/) spawns/kills ──────┴──► Firecracker (Linux + KVM) +``` + +The UI **never** talks to Docker or Kubernetes directly, and the sidecar has no UI knowledge — +every feature crosses both boundaries. Adding one typically means editing four places: + +1. `sidecar/internal//` — the capability (docker, k8s, helm, compose, domains, runtime, engine) +2. `sidecar/internal/server/server.go` `routes()` — one `mux.HandleFunc("VERB /api/...")` line + (Go 1.22 method+wildcard patterns; handler bodies live in `server.go` or `handlers_ext.go`) +3. `src/lib/api.ts` — a method on the single `api` object (the only place `fetch` base URL lives) +4. `src/routes/*.tsx` — a React Query `useQuery`/`useMutation` + +Keep `docs/api.md` in step when routes change; it is the hand-maintained mirror of `routes()`. + +### Sidecar boundaries + +- `sidecar/` is a separate Go module, outside the JS package. It builds with Go and is copied + into `src-tauri/binaries/`, so Tauri runs a *stale* sidecar until `build:sidecar` reruns. +- Two binaries share the internals: `cmd/deckhand-sidecar` (HTTP daemon) and `cmd/deckhand` (CLI). +- Streaming is plain HTTP, not a framework: logs/build are text streams, image pull and container + stats are NDJSON, Docker events are SSE, TTYs are WebSockets (`tty_ws.go`). +- Docker, Kubernetes, and Firecracker report availability **independently** via `GET /api/status`; + partial connectivity is normal and the UI must degrade per-subsystem, not fail whole-app. +- Firecracker uses build-tag providers: `runtime/firecracker_linux.go` vs `runtime/nop.go`. + Anything platform-specific belongs behind `runtime.Provider`, not in a handler. + +### Auth (do not regress this) + +Every sidecar request must carry a per-launch token — `Authorization: Bearer ` or `?token=`. +The query form is not laziness: `EventSource`, `WebSocket`, and `window.open` cannot set headers, +and the events, logs, exec, stats, and volume-export endpoints are driven through exactly those. +In `src/lib/api.ts`, `request()` uses the header and every `*Url()` helper goes through +`tokenized()`; a new streaming endpoint must use one or the other or it will 401. + +CORS mirrors auth rather than an origin allowlist — the request's `Origin` is echoed only after the +token is accepted. That is deliberate: the Tauri webview's origin differs per platform +(`tauri://localhost` on macOS, `http://tauri.localhost` elsewhere), so an allowlist risks breaking +a platform that cannot be tested locally, while a caller holding the token is authorized anyway. +Preflight is answered before the token check because a preflight never carries the header. + +This replaced `Access-Control-Allow-Origin: *` with no auth at all, under which any page the user +visited could `POST /api/docker/containers` with a bind mount of `/` and take the host. + +### Sidecar-URL resolution (the fragile part) + +Tauri spawns the sidecar, prefers `127.0.0.1:7420`, and falls back to an ephemeral port. It reuses a sidecar already holding that port **only** when `DECKHAND_SIDECAR_TOKEN` is set, since it never sees the stdout of one it did not spawn: without a token, attaching to a current sidecar 401s every request, and attaching to one predating auth (an older installed build, or any local process squatting the port) would run the app unauthenticated against a server it did not start. Otherwise it starts its own on an ephemeral port and logs why. + +The child +prints `DECKHAND_SIDECAR_ADDR=host:port` on stdout, which Tauri exposes via the `sidecar_url` +command, alongside `sidecar_token`. `src/App.tsx` polls `invoke("sidecar_url")` (up to 40×250ms), +calls `setApiBaseUrl` + `setApiToken`, then polls `/health` before rendering — and **re-invokes** +both periodically while failing, so a restarted sidecar on a new port recovers. The token must be +refreshed with the URL: a restarted sidecar mints a new one, so refreshing only the URL reconnects +to an endpoint that then 401s everything. In a plain browser it uses `VITE_SIDECAR_URL` or +`:7420`. Never hardcode a base URL outside `src/lib/api.ts`. + +### UI shell + +`vite.config.ts` aliases `@` → `src/` and serves on `:1420` with `strictPort`, so a second Vite +fails loudly instead of drifting to another port; `tauri dev` starts it via `beforeDevCommand`. +Routing is code-defined in `src/router.tsx` (TanStack Router, flat routes under one +`RootLayout`), not file-based. Server state is React Query with `refetchInterval` polling +(~4–8s per list); client/persisted state is `src/stores/uiStore.ts` (Zustand + `persist`) — +including the three-way `mode` (`docker` | `kubernetes` | `microvms`) that drives the sidebar, and +user prefs like `confirmPrune`. + +## Conventions + +- **UI kit: shadcn/ui with the Maia preset (`base-maia`, `bbVJxYW`) + Tailwind v4, exclusively.** + Primitives in `src/components/ui/` (shadcn CLI-generated), tokens in `src/index.css`, + `cn()` from `@/lib/utils`. Do not add Adobe React Spectrum, Spectrum Charts, `style()` macros, + MUI/Chakra/Ant, or hand-rolled Radix wrappers duplicating `@/components/ui`. The repo was + migrated off Spectrum; reintroducing it undoes that. See `.cursor/rules/shadcn-ui.mdc`. +- Icons: `lucide-react` via `Icon` / `lucideProps()` in `src/components/Icon.tsx` (S/M/L size + tokens, stroke 1.75) — not raw ``. +- Page composition: `PageShell` (title/description/actions) wrapping `ListPane*` + `DetailPane` + parts, with the app helpers `Tip`, `StatusBadge`, `RowMenu`, `Field`, `MoreActionsMenu`, + `SegmentedControl`. Custom widgets with no shadcn equivalent (xterm, Vega charts) get Tailwind + token chrome rather than a second design system. +- Toasts go through `@/lib/toast` (Sonner), not `sonner` directly. +- Title-bar drag: use `useWindowDragProps()` and mark interactive children `data-no-drag`. +- Mutating sidecar operations log to the local JSONL audit log (`~/.deckhand/audit.jsonl`) via + `s.audit.Log(action, target, detail, err)` — follow that for new destructive/stateful handlers. +- Kubernetes Secret values are redacted server-side (`k8s/resources.go` `SecretSummary`); never + return raw secret data to the UI. +- Destructive actions (prune, remove) confirm in the UI by default, gated on `confirmPrune`. +- Versions: `package.json` is the source of truth; `src-tauri/Cargo.toml` must match, and + release tags `v` must match it too. Use `bun run version ` rather than editing both. + +## Platform notes + +macOS and Linux only (Windows packaging deferred). Firecracker/MicroVMs are Linux + `/dev/kvm` + +`firecracker` on PATH; on macOS the `nop` provider reports unavailable and the sidebar entry hides. +macOS release builds are **ad-hoc signed, not notarized**. diff --git a/README.md b/README.md index 6778b28..70cc622 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local-first Docker and Kubernetes desktop app for **macOS** and **Linux**. -Tauri 2 shell · Adobe Spectrum UI · Go sidecar for Docker, Compose, Kubernetes, Helm, and Linux Firecracker microVMs. +Tauri 2 shell · shadcn/ui (Maia) · Go sidecar for Docker, Compose, Kubernetes, Helm, and Linux Firecracker microVMs. ## Features @@ -47,7 +47,7 @@ xattr -cr /Applications/Deckhand.app ## Architecture (sketch) ``` -UI (Spectrum / React) → HTTP localhost → Go sidecar → Docker / kubeconfig / Helm / Firecracker +UI (shadcn / React) → HTTP localhost → Go sidecar → Docker / kubeconfig / Helm / Firecracker ↑ Tauri 2 (spawn + tray) ``` diff --git a/app/package.json b/app/package.json deleted file mode 100644 index d076008..0000000 --- a/app/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@deckhand/app", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc --noEmit && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@adobe/react-spectrum": "^3.47.2", - "@react-spectrum/s2": "^1.5.1", - "@spectrum-charts/react-spectrum-charts-s2": "^0.3.2", - "@tanstack/react-query": "^5.66.9", - "@tanstack/react-router": "^1.105.0", - "@tauri-apps/api": "^2.3.0", - "@tauri-apps/plugin-autostart": "^2", - "@tauri-apps/plugin-shell": "^2", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", - "framer-motion": "^12.4.7", - "lucide-react": "^1.25.0", - "react": "^19", - "react-aria-components": "^1.19.0", - "react-dom": "^19", - "vega": "^6.2.0", - "vega-lite": "^6.4.3", - "zustand": "^5.0.3" - }, - "devDependencies": { - "@react-aria/optimize-locales-plugin": "^2.0.0", - "@types/react": "^19", - "@types/react-dom": "^19", - "@vitejs/plugin-react": "^4.3.4", - "lightningcss": "^1.32.0", - "typescript": "~5.7.3", - "unplugin-parcel-macros": "^0.2.0", - "vite": "^6.1.0" - } -} diff --git a/app/public/favicon.png b/app/public/favicon.png deleted file mode 100644 index 3fa8570..0000000 Binary files a/app/public/favicon.png and /dev/null differ diff --git a/app/scripts/convert-pages-styles.mjs b/app/scripts/convert-pages-styles.mjs deleted file mode 100644 index 981cdf2..0000000 --- a/app/scripts/convert-pages-styles.mjs +++ /dev/null @@ -1,341 +0,0 @@ -import fs from "node:fs"; - -const path = new URL("../src/routes/pages.tsx", import.meta.url); -let src = fs.readFileSync(path, "utf8"); - -// Icons -src = src.replace( - `import { FolderPlus, ListFilter, Plus, RefreshCw } from "lucide-react";`, - `import Filter from "@react-spectrum/s2/icons/Filter"; -import FolderAdd from "@react-spectrum/s2/icons/FolderAdd"; -import Add from "@react-spectrum/s2/icons/Add"; -import DataRefresh from "@react-spectrum/s2/icons/DataRefresh";`, -); -src = src.replaceAll("ListFilter", "Filter"); -src = src.replaceAll("FolderPlus", "FolderAdd"); -src = src.replaceAll("RefreshCw", "DataRefresh"); -src = src.replaceAll(//g, ""); -src = src.replaceAll(//g, ""); -src = src.replace( - ``, - ``, -); - -// Import style macro if missing -if (!src.includes("@react-spectrum/s2/style")) { - src = src.replace( - `import type { ComposeProject } from "@/lib/api";`, - `import type { ComposeProject } from "@/lib/api"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" };`, - ); -} - -const replacements = [ - [ - `className="glass mb-5 flex items-center justify-between gap-4 rounded-[1.75rem] px-5 py-4"`, - `className={style({ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, marginBottom: 20, paddingX: 20, paddingY: 16, backgroundColor: "layer-1", borderRadius: "xl" })}`, - ], - [ - `className="hidden max-w-sm text-right text-xs text-muted-foreground md:block"`, - `className={style({ font: "body-xs", color: "neutral-subdued", textAlign: "end", maxWidth: 384 })}`, - ], - [ - `className="space-y-6"`, - `className={style({ display: "flex", flexDirection: "column", gap: 24 })}`, - ], - [ - `className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5"`, - `className={style({ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 12 })}`, - ], - [ - `className="glass-soft rounded-[1.75rem] px-5 py-4"`, - `className={style({ backgroundColor: "layer-1", borderRadius: "xl", paddingX: 20, paddingY: 16 })}`, - ], - [ - `className="mb-2 flex items-center justify-between"`, - `className={style({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 })}`, - ], - [ - `className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground"`, - `className={style({ display: "flex", alignItems: "center", gap: 6, font: "detail-xs", color: "neutral-subdued" })}`, - ], - [ - `className="text-xs text-muted-foreground"`, - `className={style({ font: "body-xs", color: "neutral-subdued" })}`, - ], - [ - `className="grid gap-6 lg:grid-cols-2"`, - `className={style({ display: "grid", gridTemplateColumns: "1fr", gap: 24 })}`, - ], - [ - `className="mb-3 flex items-center justify-between gap-2"`, - `className={style({ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: 12 })}`, - ], - [ - `className="flex items-center gap-1.5"`, - `className={style({ display: "flex", alignItems: "center", gap: 6 })}`, - ], - [ - `className="text-lg font-semibold tracking-tight"`, - `className={style({ font: "title" })}`, - ], - [ - `className="flex h-full min-h-0 gap-6"`, - `className={style({ display: "flex", height: "full", minHeight: 0, gap: 24 })}`, - ], - [ - `className="flex items-center gap-2"`, - `className={style({ display: "flex", alignItems: "center", gap: 8 })}`, - ], - [ - `className="flex items-center justify-between gap-2"`, - `className={style({ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 })}`, - ], - [ - `className="flex min-w-0 items-center gap-2"`, - `className={style({ display: "flex", alignItems: "center", gap: 8, minWidth: 0 })}`, - ], - [ - `className="flex shrink-0 items-center gap-1"`, - `className={style({ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 })}`, - ], - [ - `className="truncate font-medium"`, - `className={style({ font: "body", fontWeight: "medium", truncate: true })}`, - ], - [ - `className="truncate text-xs text-muted-foreground"`, - `className={style({ font: "body-xs", color: "neutral-subdued", truncate: true })}`, - ], - [ - `className="space-y-4 pb-6"`, - `className={style({ display: "flex", flexDirection: "column", gap: 16, paddingBottom: 24 })}`, - ], - [ - `className="space-y-3 pb-6"`, - `className={style({ display: "flex", flexDirection: "column", gap: 12, paddingBottom: 24 })}`, - ], - [ - `className="space-y-5 pb-2"`, - `className={style({ display: "flex", flexDirection: "column", gap: 20, paddingBottom: 8 })}`, - ], - [ - `className="flex flex-wrap items-center gap-2"`, - `className={style({ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 })}`, - ], - [ - `className="flex flex-wrap items-start justify-between gap-2"`, - `className={style({ display: "flex", flexWrap: "wrap", alignItems: "start", justifyContent: "space-between", gap: 8 })}`, - ], - [ - `className="flex flex-wrap items-center justify-between gap-2"`, - `className={style({ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 8 })}`, - ], - [ - `className="flex flex-wrap gap-2"`, - `className={style({ display: "flex", flexWrap: "wrap", gap: 8 })}`, - ], - [ - `className="flex flex-wrap gap-1.5"`, - `className={style({ display: "flex", flexWrap: "wrap", gap: 6 })}`, - ], - [ - `className="text-xl font-semibold"`, - `className={style({ font: "heading" })}`, - ], - [ - `className="truncate text-xl font-semibold"`, - `className={style({ font: "heading", truncate: true })}`, - ], - [ - `className="relative"`, - `className={style({ position: "relative" })}`, - ], - [ - `className="absolute right-3 top-3 z-10"`, - `className={style({ position: "absolute", top: 12, end: 12, zIndex: 10 })}`, - ], - [ - `className="glass-soft max-h-[60vh] overflow-auto rounded-[1.25rem] p-4 font-mono text-xs leading-relaxed"`, - `className={style({ backgroundColor: "layer-1", borderRadius: "xl", padding: 16, font: "code-xs", maxHeight: "[60vh]", overflow: "auto" })}`, - ], - [ - `className="glass-soft max-h-[40vh] overflow-auto rounded-[1.25rem] p-4 font-mono text-xs leading-relaxed"`, - `className={style({ backgroundColor: "layer-1", borderRadius: "xl", padding: 16, font: "code-xs", maxHeight: "[40vh]", overflow: "auto" })}`, - ], - [ - `className="glass-soft space-y-3 rounded-[1.5rem] p-4"`, - `className={style({ display: "flex", flexDirection: "column", gap: 12, backgroundColor: "layer-1", borderRadius: "xl", padding: 16 })}`, - ], - [ - `className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground"`, - `className={style({ font: "detail-xs", color: "neutral-subdued" })}`, - ], - [ - `className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground"`, - `className={style({ font: "detail-xs", color: "neutral-subdued" })}`, - ], - [ - `className="flex gap-2"`, - `className={style({ display: "flex", gap: 8 })}`, - ], - [ - `className="mt-1 font-mono text-xs text-muted-foreground"`, - `className={style({ font: "code-xs", color: "neutral-subdued", marginTop: 4 })}`, - ], - [ - `className="mt-1 text-sm text-muted-foreground"`, - `className={style({ font: "body-sm", color: "neutral-subdued", marginTop: 4 })}`, - ], - [ - `className="space-y-2"`, - `className={style({ display: "flex", flexDirection: "column", gap: 8 })}`, - ], - [ - `className="space-y-1.5"`, - `className={style({ display: "flex", flexDirection: "column", gap: 6 })}`, - ], - [ - `className="space-y-3"`, - `className={style({ display: "flex", flexDirection: "column", gap: 12 })}`, - ], - [ - `className="flex items-center justify-between gap-2 rounded-[1rem] bg-muted/35 px-3 py-2 text-sm"`, - `className={style({ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, backgroundColor: "layer-2", borderRadius: "lg", paddingX: 12, paddingY: 8, font: "body-sm" })}`, - ], - [ - `className="shrink-0 font-mono text-[11px] text-muted-foreground"`, - `className={style({ font: "code-xs", color: "neutral-subdued", flexShrink: 0 })}`, - ], - [ - `className="flex h-full min-h-0"`, - `className={style({ display: "flex", height: "full", minHeight: 0 })}`, - ], - [ - `className="flex min-h-0 min-w-0 flex-1 flex-col"`, - `className={style({ display: "flex", flexDirection: "column", flexGrow: 1, minWidth: 0, minHeight: 0 })}`, - ], - [ - `className="shrink-0 border-t border-border/60 px-1 py-4"`, - `className={style({ flexShrink: 0, paddingX: 4, paddingY: 16, marginTop: 8 })}`, - ], - [ - `className="h-full"`, - `className={style({ height: "full" })}`, - ], - [ - `className="mb-4 flex flex-wrap items-center gap-3"`, - `className={style({ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12, marginBottom: 16 })}`, - ], - [ - `className="flex h-[calc(100%-3rem)] min-h-0 gap-6"`, - `className={style({ display: "flex", height: "full", minHeight: 0, gap: 24 })}`, - ], - [ - `className="grid grid-cols-2 gap-3 md:grid-cols-4"`, - `className={style({ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 12 })}`, - ], - [ - `className="glass-soft rounded-[1.35rem]"`, - `className={style({ backgroundColor: "layer-1", borderRadius: "xl" })}`, - ], - [ - `className="mb-4 grid gap-2 md:grid-cols-3"`, - `className={style({ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8, marginBottom: 16 })}`, - ], - [ - `className="mb-4 h-32 font-mono text-xs"`, - `className={style({ marginBottom: 16, font: "code-xs" })}`, - ], - [ - `className="mb-4 space-y-2"`, - `className={style({ display: "flex", flexDirection: "column", gap: 8, marginBottom: 16 })}`, - ], - [ - `className="flex items-center justify-between glass-soft rounded-[1.25rem] px-4 py-3.5"`, - `className={style({ display: "flex", alignItems: "center", justifyContent: "space-between", backgroundColor: "layer-1", borderRadius: "xl", paddingX: 16, paddingY: 14 })}`, - ], - [ - `className="font-medium"`, - `className={style({ fontWeight: "medium" })}`, - ], - [ - `className="mb-4 grid gap-2 md:grid-cols-2"`, - `className={style({ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8, marginBottom: 16 })}`, - ], - [ - `className="glass-soft rounded-[1.25rem] px-4 py-3.5"`, - `className={style({ backgroundColor: "layer-1", borderRadius: "xl", paddingX: 16, paddingY: 14 })}`, - ], - [ - `className="flex items-center justify-between gap-3"`, - `className={style({ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 })}`, - ], - [ - `className="mt-4 max-h-64 overflow-auto rounded-2xl bg-black/80 p-4 text-xs text-emerald-200"`, - `className={style({ marginTop: 16, maxHeight: 256, overflow: "auto", borderRadius: "xl", padding: 16, font: "code-xs", backgroundColor: "layer-2" })}`, - ], - [ - `className="font-mono text-xs text-muted-foreground"`, - `className={style({ font: "code-xs", color: "neutral-subdued" })}`, - ], - [ - `className="px-5 py-2"`, - `className={style({ paddingX: 20, paddingY: 8 })}`, - ], - [ - `className="px-5 py-4"`, - `className={style({ paddingX: 20, paddingY: 16 })}`, - ], - [ - `className="mt-3 text-xs text-muted-foreground"`, - `className={style({ font: "body-xs", color: "neutral-subdued", marginTop: 12 })}`, - ], - [ - `className="text-sm text-muted-foreground"`, - `className={style({ font: "body-sm", color: "neutral-subdued" })}`, - ], - [ - `className="rounded-[1.15rem] border border-border/70 bg-muted/30 px-3.5 py-3"`, - `className={style({ backgroundColor: "layer-2", borderRadius: "xl", paddingX: 14, paddingY: 12 })}`, - ], - [ - `className="mb-1 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground"`, - `className={style({ font: "detail-xs", color: "neutral-subdued", marginBottom: 4 })}`, - ], - [ - `className="flex items-start gap-2"`, - `className={style({ display: "flex", alignItems: "start", gap: 8 })}`, - ], - [ - `className="min-w-0 flex-1 break-all font-mono text-xs leading-relaxed"`, - `className={style({ font: "code-xs", flexGrow: 1, minWidth: 0 })}`, - ], - [ - `className="flex items-center gap-1.5"`, - `className={style({ display: "flex", alignItems: "center", gap: 6 })}`, - ], - [ - `className="max-w-[14rem] truncate font-mono text-[11px]"`, - `className={style({ font: "code-xs", truncate: true, maxWidth: 224 })}`, - ], - [ - `className="h-48 font-mono text-xs leading-relaxed"`, - `className={style({ font: "code-xs" })}`, - ], - [ - `className="h-16"`, - `className={style({ height: 64 })}`, - ], -]; - -for (const [from, to] of replacements) { - src = src.replaceAll(from, to); -} - -// Remaining glass / muted leftovers → layer panels -src = src.replaceAll(/className="glass[^"]*"/g, `className={style({ backgroundColor: "layer-1", borderRadius: "xl", padding: 16 })}`); -src = src.replaceAll(/className="[^"]*text-muted-foreground[^"]*"/g, `className={style({ color: "neutral-subdued", font: "body-xs" })}`); - -fs.writeFileSync(path, src); -console.log("Converted pages.tsx styles/icons"); diff --git a/app/src/components/AppFrame/AppFrame.tsx b/app/src/components/AppFrame/AppFrame.tsx deleted file mode 100644 index 0f8008d..0000000 --- a/app/src/components/AppFrame/AppFrame.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { CSSProperties, ReactNode } from "react"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { TitleBarDragRegion } from "@/components/TitleBarDragRegion"; -import { isTauriShell } from "@/lib/platform"; - -/** - * Reserved space under live content so scrollports end above the fixed dock - * with a visible breathing gap (dock itself stays content-height). - * Applied as inline padding-bottom — Spectrum style() can drop arbitrary clearance. - */ -export const STATUS_DOCK_CLEARANCE = 96; - -const frameBase = style({ - display: "flex", - flexDirection: "column", - height: "screen", - overflow: "hidden", - backgroundColor: "base", -}); - -const frameDesktop = style({ - display: "flex", - flexDirection: "column", - height: "screen", - overflow: "hidden", - backgroundColor: "base", - paddingTop: 56, - boxSizing: "border-box", -}); - -const content = style({ - position: "relative", - zIndex: 0, - minHeight: 0, - flexGrow: 1, - overflow: "hidden", - boxSizing: "border-box", -}); - -/** Content-sized dock — top hairline only (avoid borderStyle painting all sides). */ -const dock = style({ - position: "fixed", - insetX: 0, - bottom: 0, - zIndex: 40, - backgroundColor: "layer-1", - paddingY: 12, -}); - -export function AppFrame({ - children, - dock: dockSlot, -}: { - children: ReactNode; - dock?: ReactNode; -}) { - const desktop = isTauriShell(); - - return ( -
- {desktop ? : null} -
- {children} -
- {dockSlot ? ( -
- {dockSlot} -
- ) : null} -
- ); -} diff --git a/app/src/components/BootSplash.tsx b/app/src/components/BootSplash.tsx deleted file mode 100644 index 85716c4..0000000 --- a/app/src/components/BootSplash.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Heading, ProgressCircle, Text } from "@react-spectrum/s2"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { LogoMark } from "@/components/Logo"; - -export function BootSplash({ message = "Starting sidecar…" }: { message?: string }) { - return ( -
- -
- - Deckhand - -
- - - {message} - -
-
-
- ); -} diff --git a/app/src/components/CodeBlock.tsx b/app/src/components/CodeBlock.tsx deleted file mode 100644 index d86e1f2..0000000 --- a/app/src/components/CodeBlock.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { ReactNode } from "react"; -import { Text } from "@react-spectrum/s2"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { CopyButton } from "@/components/CopyButton"; - -/** Structured code/JSON panel with a quiet toolbar (inspect, VM logs, etc.). */ -export function CodeBlock({ - value, - title = "JSON", - meta, - maxHeight = "50vh", - empty = "No data", -}: { - value: string; - title?: string; - meta?: ReactNode; - maxHeight?: string; - empty?: string; -}) { - const hasContent = Boolean(value.trim()); - - return ( -
-
-
- {title} - {meta ? ( - {meta} - ) : null} -
- -
-
-        {hasContent ? value : empty}
-      
-
- ); -} diff --git a/app/src/components/ConfirmDialog.tsx b/app/src/components/ConfirmDialog.tsx deleted file mode 100644 index 85058aa..0000000 --- a/app/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { AlertDialog, DialogContainer } from "@react-spectrum/s2"; - -/** Confirm dialog — Spectrum AlertDialog, no dismiss X. */ -export function ConfirmDialog({ - open, - onOpenChange, - title, - description, - confirmLabel = "Confirm", - cancelLabel = "Cancel", - destructive, - loading, - onConfirm, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - description: string; - confirmLabel?: string; - cancelLabel?: string; - destructive?: boolean; - loading?: boolean; - onConfirm: () => void | Promise; -}) { - return ( - onOpenChange(false)}> - {open ? ( - onOpenChange(false)} - onPrimaryAction={() => { - void Promise.resolve(onConfirm()) - .then(() => onOpenChange(false)) - .catch(() => { - /* keep open — callers toast errors */ - }); - }} - > - {description} - - ) : null} - - ); -} diff --git a/app/src/components/DetailPane.tsx b/app/src/components/DetailPane.tsx deleted file mode 100644 index 8a0fa4b..0000000 --- a/app/src/components/DetailPane.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import type { ReactNode } from "react"; -import { Heading, Text } from "@react-spectrum/s2"; -import { MousePointerClick } from "lucide-react"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { lucideProps } from "@/components/Icon"; - -/** Detail title — matches ListPaneTitle (heading-lg) for list/detail alignment. */ -export function DetailHeading({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -export function DetailEmpty({ - title = "Nothing selected", - description, - action, - icon, -}: { - title?: string; - description?: string; - action?: ReactNode; - /** Optional custom icon node. Defaults to MousePointerClick. */ - icon?: ReactNode; -}) { - return ( -
-
- {icon ?? } -
- - {title} - - {description ? ( - - {description} - - ) : null} - {action ? ( -
{action}
- ) : null} -
- ); -} - -/** Master–detail content pane. Remounts on selectionKey change. */ -export function DetailPane({ - selectionKey, - empty, - header, - children, - className, -}: { - selectionKey: string | null; - empty?: ReactNode; - /** Sticky chrome above the scroll body when an item is selected. */ - header?: ReactNode; - children: ReactNode; - className?: string; -}) { - const emptyNode = - empty == null || typeof empty === "string" ? ( - - ) : ( - empty - ); - - return ( -
- {!selectionKey ? ( -
- {emptyNode} -
- ) : ( -
- {header ? ( -
{header}
- ) : null} - {/* Native overflow — Console/Exec need nested scroll. */} -
- {children} -
-
- )} -
- ); -} diff --git a/app/src/components/GlassSheet.tsx b/app/src/components/GlassSheet.tsx deleted file mode 100644 index 0f7d7af..0000000 --- a/app/src/components/GlassSheet.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import type { ReactNode } from "react"; -import { - ButtonGroup, - Content, - Dialog, - DialogContainer, - Heading, - Header, - Text, -} from "@react-spectrum/s2"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { CopyButton } from "@/components/CopyButton"; - -const sizeMap = { - md: "M", - lg: "L", - xl: "XL", -} as const; - -/** Overlay sheet for deploy output, logs, and exec results. */ -export function GlassSheet({ - open, - onOpenChange, - title, - description, - children, - footer, - size = "lg", - mono, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - description?: string; - children: ReactNode; - footer?: ReactNode; - size?: "md" | "lg" | "xl"; - mono?: boolean; -}) { - return ( - onOpenChange(false)}> - {open ? ( - - {() => ( - <> - {title} - {description ? ( -
- - {description} - -
- ) : null} - -
- {children} -
-
- {footer ? {footer} : null} - - )} -
- ) : null} -
- ); -} - -/** Dark output block for build/pull/compose streams in sheets. */ -export function TerminalBlock({ - children, - copyValue, -}: { - children: ReactNode; - copyValue?: string; -}) { - const text = copyValue ?? (typeof children === "string" ? children : ""); - const hasCopyable = Boolean(text.trim()); - - return ( -
- {hasCopyable ? ( -
- -
- ) : null} -
-        {children}
-      
-
- ); -} diff --git a/app/src/components/GpuPanel.tsx b/app/src/components/GpuPanel.tsx deleted file mode 100644 index b592259..0000000 --- a/app/src/components/GpuPanel.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - ActionButton, - Badge, - Content, - Heading, - IllustratedMessage, - ProgressCircle, - Text, - Tooltip, - TooltipTrigger, -} from "@react-spectrum/s2"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import CloudStateError from "@react-spectrum/s2/illustrations/linear/CloudStateError"; -import { api, type GPUStatus } from "@/lib/api"; -import { HelpHint } from "@/components/HelpHint"; -import { RingGauge } from "@/components/charts/MetricChart"; - -export function GpuPanel() { - const gpus = useQuery({ queryKey: ["gpus"], queryFn: api.gpus, refetchInterval: 4000 }); - - if (gpus.isLoading) { - return ( -
- - Probing GPUs… -
- ); - } - - const data = gpus.data as GPUStatus | undefined; - if (!data?.available && !data?.devices?.length) { - return ( -
- - - No GPU runtime detected - - {data?.toolkitHint || - "Install NVIDIA drivers and the NVIDIA Container Toolkit, then expose GPUs with docker run --gpus."} - - -
- ); - } - - const runtimeTip = data.available - ? data.runtime - ? `Docker GPU runtime “${data.runtime}” is registered for --gpus` - : "GPU tooling detected on this host" - : "GPU toolkit detected but devices are unavailable"; - - return ( -
-
- - {data.runtime ? `runtime: ${data.runtime}` : data.nvidiaSmi ? "nvidia-smi" : "GPU"} - - - - {data.devices.length} device{data.devices.length === 1 ? "" : "s"} · Docker GPU access - -
-
- {data.devices.map((d) => { - const memPct = d.memoryTotalMiB > 0 ? (d.memoryUsedMiB / d.memoryTotalMiB) * 100 : 0; - return ( -
-
-
- {d.name} - - - - GPU {d.index} - {d.uuid ? ` · ${d.uuid.slice(0, 18)}…` : ""} - - - - {d.uuid ? `Device UUID: ${d.uuid}` : `GPU index ${d.index}`} - - -
-
- - {d.temperature}°C - - -
-
-
-
- -
-
- -
-
-
- ); - })} -
- {!data.devices.length && data.available ? ( - - NVIDIA runtime is registered, but nvidia-smi reported no devices. On macOS / remote Docker this is - common — GPU passthrough needs a Linux host with the toolkit. - - ) : null} -
- ); -} diff --git a/app/src/components/HelpHint.tsx b/app/src/components/HelpHint.tsx deleted file mode 100644 index a231aec..0000000 --- a/app/src/components/HelpHint.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { ReactNode } from "react"; -import { Content, ContextualHelp, Heading } from "@react-spectrum/s2"; - -const placementMap = { - top: "top", - right: "right", - bottom: "bottom", - left: "left", -} as const; - -/** Small info affordance — Spectrum ContextualHelp next to labels. */ -export function HelpHint({ - label, - title, - side = "top", -}: { - label: ReactNode; - /** Optional popover title; omit for tip-only content. */ - title?: string; - side?: "top" | "right" | "bottom" | "left"; -}) { - return ( - - {title ? {title} : null} - {label} - - ); -} diff --git a/app/src/components/InspectFields.tsx b/app/src/components/InspectFields.tsx deleted file mode 100644 index 7c8a0ee..0000000 --- a/app/src/components/InspectFields.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import type { ReactNode } from "react"; -import { Divider, Tag, TagGroup, Text } from "@react-spectrum/s2"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { CopyButton } from "@/components/CopyButton"; - -export function InspectFields({ - rows, -}: { - rows: { label: string; value?: ReactNode; copy?: string; mono?: boolean }[]; -}) { - const visible = rows.filter((r) => r.value != null && r.value !== ""); - if (!visible.length) return null; - - return ( -
- {visible.map((r, i) => ( -
- {i > 0 ? : null} -
-
- - {r.label} - -
-
- - - {r.value} - - {r.copy ? : null} - -
-
-
- ))} -
- ); -} - -export function LabelChips({ labels }: { labels?: Record | null }) { - const entries = Object.entries(labels || {}); - if (!entries.length) return null; - return ( - - {entries.map(([k, v]) => ( - - {k}={v} - - ))} - - ); -} diff --git a/app/src/components/ListPane.tsx b/app/src/components/ListPane.tsx deleted file mode 100644 index 69ce238..0000000 --- a/app/src/components/ListPane.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { forwardRef, type CSSProperties, type ReactNode } from "react"; -import { Heading, SearchField, Text, Skeleton } from "@react-spectrum/s2"; -import { List } from "lucide-react"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { lucideProps } from "@/components/Icon"; -import { useWindowDragProps } from "@/components/TitleBarDragRegion"; - -/** Master list shell (compound pieces + convenience wrapper). */ -export function ListPaneRoot({ className, children }: { className?: string; children: ReactNode }) { - return ( -
-
- {children} -
- ); -} - -export function ListPaneHeader({ className, children }: { className?: string; children: ReactNode }) { - const drag = useWindowDragProps(); - return ( -
- {children} -
- ); -} - -export function ListPaneTitleRow({ className, children }: { className?: string; children: ReactNode }) { - return ( -
- {children} -
- ); -} - -export function ListPaneTitle({ className, children }: { className?: string; children: ReactNode }) { - return ( - - {children} - - ); -} - -export function ListPaneActions({ className, children }: { className?: string; children: ReactNode }) { - return ( -
- {children} -
- ); -} - -export function ListPaneSearch({ - value, - onChange, - placeholder = "Search", - className, -}: { - value: string; - onChange: (value: string) => void; - placeholder?: string; - className?: string; -}) { - return ( -
- -
- ); -} - -export function ListSkeleton({ rows = 6 }: { rows?: number }) { - return ( -
- - {Array.from({ length: rows }, (_, i) => ( -
-
-
-
- ))} - -
- ); -} - -export function ListEmpty({ - title, - description, - action, -}: { - title: string; - description?: string; - action?: ReactNode; -}) { - return ( -
-
- -
- {title} - {description ? ( - - {description} - - ) : null} - {action ?
{action}
: null} -
- ); -} - -export function ListPaneScroll({ - className, - style: inlineStyle, - children, - empty, - loading, -}: { - className?: string; - style?: CSSProperties; - children: ReactNode; - empty?: ReactNode; - loading?: boolean; -}) { - const hasItems = Array.isArray(children) - ? children.length > 0 - : children != null && children !== false; - - return ( -
-
- {loading ? ( - - ) : hasItems ? ( - children - ) : ( -
- {typeof empty === "string" || empty == null ? ( - - ) : ( - empty - )} -
- )} -
-
- ); -} - -/** Convenience API used by existing pages. */ -export function ListPane({ - title, - actions, - children, - className, - search, - empty, - loading, -}: { - title: string; - actions?: ReactNode; - children: ReactNode; - className?: string; - search?: { value: string; onChange: (v: string) => void; placeholder?: string }; - empty?: ReactNode; - loading?: boolean; -}) { - return ( - - - - {title} - {actions ? {actions} : null} - - {search ? ( - - ) : null} - - - {children} - - - ); -} - -const listItemIdle = style({ - display: "flex", - flexDirection: "column", - gap: 2, - width: "full", - minWidth: 0, - marginBottom: 0, - borderRadius: "default", - paddingX: 12, - paddingY: 12, - textAlign: "start", - borderWidth: 0, - cursor: "pointer", - backgroundColor: "transparent", - color: "neutral", -}); - -const listItemSelected = style({ - display: "flex", - flexDirection: "column", - gap: 2, - width: "full", - minWidth: 0, - marginBottom: 0, - borderRadius: "default", - paddingX: 12, - paddingY: 12, - textAlign: "start", - borderWidth: 0, - cursor: "pointer", - backgroundColor: "gray-200", - color: "neutral", -}); - -export const ListItem = forwardRef< - HTMLDivElement, - { - active?: boolean; - onClick?: () => void; - children: React.ReactNode; - className?: string; - } ->(function ListItem({ active, onClick, children, className }, ref) { - return ( -
{ - if (!onClick) return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); - } - }} - className={[ - "dh-list-item", - active ? "dh-list-item-selected" : null, - active ? listItemSelected : listItemIdle, - className, - ] - .filter(Boolean) - .join(" ")} - aria-current={active ? "true" : undefined} - style={{ WebkitAppRegion: "no-drag" } as CSSProperties} - data-no-drag - > - {children} -
- ); -}); diff --git a/app/src/components/Logo.tsx b/app/src/components/Logo.tsx deleted file mode 100644 index b44bb7a..0000000 --- a/app/src/components/Logo.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Text } from "@react-spectrum/s2"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import markUrl from "@/assets/brand/mark.svg"; -import markMonoUrl from "@/assets/brand/mark-mono.svg"; - -/** Deckhand mark tile (Adobe red + white anchor). */ -export function LogoMark({ - size = 36, - alt = "Deckhand", - className, -}: { - size?: number; - alt?: string; - /** Optional passthrough; prefer `size` over Tailwind utility classes. */ - className?: string; -}) { - return ( - {alt} - ); -} - -/** Monochrome mark for currentColor contexts. */ -export function LogoMarkMono({ - size = 36, - className, -}: { - size?: number; - className?: string; -}) { - return ( - - ); -} - -export function LogoWordmark({ className }: { className?: string }) { - return ( -
- -
- - Deckhand - - - Local container ops - -
-
- ); -} diff --git a/app/src/components/ModeMarks.tsx b/app/src/components/ModeMarks.tsx deleted file mode 100644 index 6ce3bd5..0000000 --- a/app/src/components/ModeMarks.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** Compact runtime marks for the sidebar mode switcher. */ - -export function DockerMark({ size = 18 }: { size?: number }) { - return ( - - - - - - - - - ); -} - -export function KubernetesMark({ size = 18 }: { size?: number }) { - return ( - - - - - - - - ); -} - -export function MicroVMMark({ size = 18 }: { size?: number }) { - return ( - - - - - ); -} diff --git a/app/src/components/PageShell.tsx b/app/src/components/PageShell.tsx deleted file mode 100644 index ff4cc7d..0000000 --- a/app/src/components/PageShell.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import type { CSSProperties, ReactNode } from "react"; -import { Heading, Text, Tooltip, TooltipTrigger } from "@react-spectrum/s2"; -import { CloudOff } from "lucide-react"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { lucideProps } from "@/components/Icon"; -import { useWindowDragProps } from "@/components/TitleBarDragRegion"; - -export function PageShell({ - title, - description, - actions, - children, - className, -}: { - title: string; - description?: string; - actions?: ReactNode; - children: ReactNode; - className?: string; -}) { - const drag = useWindowDragProps(); - - return ( -
-
-
- - {title} - - {description ? ( - - {description} - - ) : null} -
- {actions ? ( -
- {actions} -
- ) : null} -
- {children} -
- ); -} - -export function EmptyState({ - title, - description, - action, -}: { - title: string; - description: string; - action?: ReactNode; -}) { - return ( -
-
- -
- - {title} - - - {description} - - {action ?
{action}
: null} -
- ); -} - -const metricTile = style({ - position: "relative", - overflow: "hidden", - backgroundColor: "layer-1", - borderRadius: "xl", - paddingX: 20, - paddingY: 20, - width: "full", - textAlign: "start", - borderWidth: 0, - borderStyle: "none", - outlineStyle: "none", - cursor: "pointer", -}); - -const metricTileStatic = style({ - position: "relative", - overflow: "hidden", - backgroundColor: "layer-1", - borderRadius: "xl", - paddingX: 20, - paddingY: 20, - width: "full", - textAlign: "start", - borderWidth: 0, - borderStyle: "none", - outlineStyle: "none", -}); - -function MetricTileBody({ - label, - value, - hint, -}: { - label: string; - value: string | number; - hint?: string; -}) { - return ( - <> - - {label} - - - {value} - - {hint ? ( - - {hint} - - ) : null} - - ); -} - -export function MetricTile({ - label, - value, - hint, - tip, - onClick, -}: { - label: string; - value: string | number; - hint?: string; - tip?: string; - onClick?: () => void; -}) { - // Never disable tipped tiles — disabled controls cannot host Spectrum tooltips. - const tile = ( - - ); - - if (!tip) return tile; - - return ( - - {tile} - {tip} - - ); -} diff --git a/app/src/components/RunContainerSheet.tsx b/app/src/components/RunContainerSheet.tsx deleted file mode 100644 index 10a1ee5..0000000 --- a/app/src/components/RunContainerSheet.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { useEffect, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { - Button, - Checkbox, - Divider, - Picker, - PickerItem, - Switch, - Text, - TextArea, - TextField, -} from "@react-spectrum/s2"; -import { style } from "@react-spectrum/s2/style" with { type: "macro" }; -import { api, type MountSpec, type RunContainerBody } from "@/lib/api"; -import { GlassSheet } from "@/components/GlassSheet"; -import { HelpHint } from "@/components/HelpHint"; -import { toast } from "@/components/Toaster"; -import { useUIStore } from "@/stores/uiStore"; - -const emptyForm = { - image: "nginx:alpine", - name: "", - cmd: "", - ports: "8080:80", - env: "", - mounts: "", - labels: "", - network: "", - workdir: "", - restart: "no" as RunContainerBody["restart"], - gpu: false, - autoRemove: false, - start: true, -}; - -/** Parse lines like `key=value` into a label map. */ -function parseLabelLines(text: string): Record { - const labels: Record = {}; - for (const raw of text.split("\n")) { - const line = raw.trim(); - if (!line) continue; - const eq = line.indexOf("="); - if (eq <= 0) { - throw new Error(`Invalid label (need key=value): ${line}`); - } - const key = line.slice(0, eq).trim(); - const value = line.slice(eq + 1).trim(); - if (!key) { - throw new Error(`Invalid label (need key=value): ${line}`); - } - labels[key] = value; - } - return labels; -} - -/** Parse lines like `/host:/container:ro` or `volname:/path` into MountSpec[]. */ -function parseMountLines(text: string): MountSpec[] { - const mounts: MountSpec[] = []; - for (const raw of text.split("\n")) { - const line = raw.trim(); - if (!line) continue; - let readOnly = false; - let rest = line; - if (rest.endsWith(":ro") || rest.endsWith(":rw")) { - readOnly = rest.endsWith(":ro"); - rest = rest.slice(0, -3); - } - const colon = rest.lastIndexOf(":"); - if (colon <= 0) { - throw new Error(`Invalid mount (need source:target): ${line}`); - } - const source = rest.slice(0, colon).trim(); - const target = rest.slice(colon + 1).trim(); - if (!source || !target) { - throw new Error(`Invalid mount (need source:target): ${line}`); - } - mounts.push({ source, target, readOnly: readOnly || undefined }); - } - return mounts; -} - -export function RunContainerSheet({ - open, - onOpenChange, - initialImage, - onCreated, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - initialImage?: string; - onCreated?: (id: string) => void; -}) { - const qc = useQueryClient(); - const startAfterCreate = useUIStore((s) => s.startAfterCreate); - const [form, setForm] = useState(emptyForm); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open) return; - setError(null); - setForm((f) => ({ - ...emptyForm, - start: startAfterCreate, - image: initialImage || f.image || emptyForm.image, - })); - }, [open, initialImage, startAfterCreate]); - - const submit = async () => { - setBusy(true); - setError(null); - try { - const ports = form.ports - .split(/[\n,]/) - .map((p) => p.trim()) - .filter(Boolean); - const env = form.env - .split("\n") - .map((e) => e.trim()) - .filter(Boolean); - const mounts = parseMountLines(form.mounts); - const labels = parseLabelLines(form.labels); - const body: RunContainerBody = { - image: form.image.trim(), - name: form.name.trim() || undefined, - cmd: form.cmd.trim() || undefined, - ports, - env, - mounts: mounts.length ? mounts : undefined, - labels: Object.keys(labels).length ? labels : undefined, - network: form.network.trim() || undefined, - workdir: form.workdir.trim() || undefined, - restart: form.restart || "no", - gpu: form.gpu, - autoRemove: form.autoRemove, - start: form.start, - }; - const res = await api.createContainer(body); - await qc.invalidateQueries({ queryKey: ["containers"] }); - await qc.invalidateQueries({ queryKey: ["docker-dashboard"] }); - toast.success(form.start ? "Container started" : "Container created", { - description: form.name.trim() || res.id.slice(0, 12), - }); - onOpenChange(false); - onCreated?.(res.id); - } catch (e: any) { - const msg = e.message || "Failed to create container"; - setError(msg); - toast.error("Run failed", { description: msg }); - } finally { - setBusy(false); - } - }; - - return ( - - - - - } - > -
- } - value={form.image} - onChange={(image) => setForm({ ...form, image })} - placeholder="image:tag" - autoFocus - /> -
- } - value={form.name} - onChange={(name) => setForm({ ...form, name })} - placeholder="my-app" - /> - } - value={form.ports} - onChange={(ports) => setForm({ ...form, ports })} - placeholder="8080:80" - /> -
- } - value={form.cmd} - onChange={(cmd) => setForm({ ...form, cmd })} - placeholder="optional override" - /> -