diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 383ef6029..7d25a1f65 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -69,7 +69,7 @@ jobs: - name: Clippy (default features) run: cargo clippy --locked --all-targets -- -D warnings - name: Clippy (screenshots feature) - run: cargo clippy --locked --all-targets --features hack-desktop/screenshots -- -D warnings + run: cargo clippy --locked --all-targets --features hack-desktop/screenshots,hack-desktop-ui/screenshots -- -D warnings - name: Build app run: cargo build --locked -p hack-desktop diff --git a/apps/desktop/Cargo.lock b/apps/desktop/Cargo.lock index 7074abb5e..ca51eeec0 100644 --- a/apps/desktop/Cargo.lock +++ b/apps/desktop/Cargo.lock @@ -3193,6 +3193,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hack-desktop-ui" +version = "0.0.0" +dependencies = [ + "gpui-kit", + "hack-desktop-core", + "image", +] + [[package]] name = "half" version = "2.7.1" diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 662da4c86..9e9381849 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -3,7 +3,7 @@ # release of packages/runtime-core or packages/config-compiler. [workspace] resolver = "3" -members = ["core", "app"] +members = ["core", "app", "ui"] [workspace.package] version = "0.0.0" diff --git a/apps/desktop/DESIGN-V1.md b/apps/desktop/DESIGN-V1.md new file mode 100644 index 000000000..b82ace988 --- /dev/null +++ b/apps/desktop/DESIGN-V1.md @@ -0,0 +1,353 @@ +# Hack desktop app: design v1 (visual spec) + +Status: direction accepted by the owner on Nov 15 2026 (HACK-2252), with +the logs moved into a terminal panel (approved panel mockup). The current +window (`app/src/view.rs`) is unchanged; this spec, the tokens and components +in `ui/` and the gallery renders in `screenshots/v1/` are what it will be +restyled to after the Logs (#808) and Doctor (#806) tab PRs land. + +Reference: the approved mockups (main window: title bar with search, sidebar +with Running / Stopped / Machine, detail header, pill tabs, services table, +status bar; and the edge-to-edge terminal panel with tabs and env badges). Quality bar: a native, crisp, dense but calm +window with clear hierarchy and nothing that looks like a default widget. + +`DESIGN.md` still owns behaviour (states, actions, data flow). This file owns +the look. Every number below is a named token in `ui/src/tokens.rs`; change +both together. + +## Renders + +| | | +|---|---| +| ![running, panel half, light](screenshots/v1/panel-running-half-light.png) | ![running, panel half, dark](screenshots/v1/panel-running-half-dark.png) | +| ![running, panel maximized](screenshots/v1/panel-running-max-light.png) | ![running, panel collapsed](screenshots/v1/panel-running-collapsed-dark.png) | + +Terminal panel in its three states, light and dark, for `running` and +`failed-start`, plus the new-tab menu: `screenshots/v1/panel-*.png`. +All 15 main-window fixtures with the panel half open on Logs, row by row +(loading, empty, daemon-down / daemon-mismatch, engine-down, error / +cli-missing, many-projects, stopped / starting, running, degraded / stopping, +failed-start, needs-attention): +[light](screenshots/v1/fixtures-light.png), [dark](screenshots/v1/fixtures-dark.png). + +```sh +cd apps/desktop +cargo run -p hack-desktop-ui --bin hack-desktop-gallery -- --fixture running --theme dark --panel max +cargo run -p hack-desktop-ui --features screenshots --bin hack-desktop-gallery -- --screenshots /tmp/v1 +``` + +The offscreen renders draw stand-in traffic lights so the title bar can be +judged; the real window uses the native controls. CPU and memory in the +gallery are labelled sample values: the core does not read per-service usage +yet (`hack usage --json`, #852, is the source to wire next). Shell tabs in +the gallery show sample output (a starship-style prompt drawn with an +installed Nerd Font when there is one); the gallery never reads the user's +own configuration. + +## Type + +UI text uses the platform UI font (SF Pro on macOS; the installed fallback +on Linux). URLs, routes, commands and logs use the platform monospace font +(Menlo on macOS by gpui-kit default). No uppercase captions, no letter-spacing. + +| Token | Size / line | Weight | Used for | +|---|---|---|---| +| `TITLE` | 17 / 22 | 600 | Project name in the header | +| `HEADLINE` | 15 / 20 | 600 | Empty and error pane headline | +| `BODY` | 13 / 18 | 400 | Rows, tabs, buttons, table cells | +| `BODY_STRONG` | 13 / 18 | 500 | Selected row, selected tab, window title, primary button | +| `SMALL` | 12 / 16 | 400 | Table header, status bar | +| `SMALL_STRONG` | 12 / 16 | 500 | Sidebar captions, pills | +| `MONO` | 12 / 18 | 400 | URL, routes, commands | +| `MICRO` | 11 / 14 | 500 | Count pill, ⌘K hint | + +Hierarchy comes from three text colours (primary, secondary, muted) and one +weight step, not from size jumps. + +## Spacing, sizes, radii, borders + +Spacing is a 4-point scale: 2, 4, 6, 8, 10, 12, 16, 20, 24 (`S1`–`S9`). + +| Size | Value | +|---|---| +| Title bar | 38 | +| Status bar | 26 | +| Sidebar width | 208 | +| Sidebar row | 28 | +| Table row | 32 | +| Control (button, tab) | 26 | +| State dot | 8 (6 in the status bar) | +| Icon | 14 (15 on machine rows, 20 in empty panes) | +| Search field | 240 × 24 | +| Traffic-light inset | 76 | + +Radii: pill 5, control 6, menu and icon tile 8, terminal panel 0 (edge to edge), window 12. Borders are +hairlines, 0.5 pt (one device pixel at 2x), in `border`; controls use +`border_strong`. The services table has row separators only, no outer box. +Detail content is inset 20 left and right; the sidebar list 6. + +## Colour + +| Token | Light | Dark | Used for | +|---|---|---|---| +| `surface` | `#FFFFFF` | `#1B1B1A` | Detail pane | +| `chrome` | `#F7F7F5` | `#161615` | Title bar, sidebar, status bar | +| `surface_1` | `#EFEFEC` | `#272725` | Hover, selected row, selected tab, panel handle, neutral pill | +| `surface_2` | `#E6E6E2` | `#31312E` | Pressed | +| `border` | black 9% | white 8% | Hairlines | +| `border_strong` | black 18% | white 15% | Button and field outlines | +| `text_primary` | `#1C1C1A` | `#EDEDEA` | | +| `text_secondary` | `#5C5B57` | `#A9A8A2` | URL, stopped names, inactive tabs | +| `text_muted` | `#8B8A85` | `#7B7A75` | Captions, table header, timestamps | +| `accent` | `#2F6FDB` | `#6EA2F5` | Switch on, focus ring | + +State colours (dot / pill background / pill text): + +| State | Light | Dark | Shown as | +|---|---|---|---| +| Running | `#1D9E75` / `#E2F3EC` / `#146B50` | `#2DB386` / 19% / `#6FD3AE` | solid dot | +| Busy (starting, stopping) | `#378ADD` / `#E5EEFA` / `#235E9E` | `#4C98E6` / 19% / `#8DBDF0` | solid dot | +| Degraded | `#EF9F27` / `#FBEFDA` / `#8A5608` | `#F0A83C` / 18% / `#F3C27A` | solid dot | +| Failed (failed start, needs attention) | `#E24B4A` / `#FBE6E5` / `#A82E2D` | `#EC5D5C` / 19% / `#F29A99` | solid dot | +| Stopped | `#BDBCB6` / `surface_1` / secondary | `#5E5D59` / `surface_1` / secondary | solid grey dot, name in secondary | +| Unknown | `#9C9B95` / `surface_1` / secondary | `#7B7A75` / `surface_1` / secondary | **ring**, never a solid dot | + +Unknown is a ring so an unobserved project never reads as stopped. + +## Icons + +Lucide, from the icon catalog shipped inside the pinned `gpui-kit-assets` +crate (ISC). Only the icons below are embedded (`ui/src/icons.rs`, +`icon_assets!`), composed with gpui-kit's default bundle for its own widgets. +Stroke icons at 14 pt, coloured with the text colour of their context. + +| Icon | Where | +|---|---| +| `search` | Title bar search field | +| `pin` | Sidebar row of an always-on project (muted) | +| `globe` | Machine: Routing and DNS | +| `stethoscope` | Machine: Doctor | +| `external-link` | Header: Open | +| `rotate-cw` | Header: Restart; banner: Restart hackd | +| `square` | Header: Stop | +| `play` | Header: Start | +| `power` | Banner: Start hackd | +| `triangle-alert` | Banners | +| `folder-plus`, `circle-alert`, `loader-circle` | Empty, error and loading panes | +| `list` | Panel tab: Logs | +| `box` | Panel tab and menu item: shell in a service | +| `terminal` | Panel tab and menu item: host shell | +| `plus`, `chevron-down` | Panel: new-tab menu | +| `x` | Panel: close tab | +| `maximize-2` / `minimize-2` / `chevron-up` | Panel: maximize / restore / expand from collapsed | +| `square-terminal` | Reserved: "Open shell" on a service row | + +## Anatomy + +### Title bar (38) +`chrome` background, hairline below. Left: traffic-light inset (76), then +"Hack" in `BODY_STRONG`. Right: the search field (240 × 24, `surface`, +`border_strong` hairline, radius 6): search icon, "Search" in muted, `⌘K` +hint chip (`surface_1`, `MICRO`). On macOS the window uses a transparent, +full-size-content title bar so this row is the title bar; on Linux it sits +under the system decorations. + +### Sidebar (208) +`chrome` background, hairline on the right. Scrolling project list, fixed +Machine section at the bottom. + +- Captions: "Running", "Stopped" (or "Not observed" when every remaining row + is Unknown), "Machine". `SMALL_STRONG`, muted, sentence case, 12 above / 4 + below. +- Project row (28, radius 6): state dot, name (`BODY`; `BODY_STRONG` when + selected; secondary when stopped or unknown), muted pin icon if always on. + Selected and hover: `surface_1`. +- Machine rows: icon (15, secondary), label, trailing state. Routing and DNS + has a dot (a ring while routing status is not read). Doctor has an amber + count pill when it reports findings, a green dot when clean, a ring when + unknown. Routing and DNS shows a solid state dot whenever its state is + known (from Doctor's checks in its `routing_dns` and `certificates` + areas, as classified in `src/lib/doctor-metadata.ts`, optional tool probes + excluded; a test keeps the list in sync; worst wins: + green ok, amber warn, red error) and a ring only when nothing has reported. + The live app needs the core to expose this; the gallery derives it from the + fixture's Doctor checks. + +### Banners +Directly under the title bar, across the detail pane: one line, tinted +(`degraded.bg` for daemon problems, `failed.bg` for engine down), alert icon, +message, optional action button on the right (Start hackd / Restart hackd). +The stale banner is folded into the daemon banner when one is shown (it has +the same cause); otherwise it is shown ("Showing last known state. The latest +read failed: …"), because it is then the only sign that the current read +failed. The reason line is suppressed when a daemon +banner explains an Unknown project. + +### Detail header +Inset 20, top 16. + +1. Name (`TITLE`), state pill, backend pill (neutral), spacer, actions. + - State pill labels are short and lowercase: `running · 2h`, `starting…`, + `stopping…`, `degraded · worker down`, `failed · api, worker` (three or + more: `failed · api +2`), `needs attention`, `stopped`, `unknown`. + - Actions: Open (`external-link`), Restart (`rotate-cw`), Stop (`square`) + as quiet outlined buttons (26 high, `border_strong` hairline, hover + `surface_1`). Start (`play`) is the one filled button, shown when the + project is stopped or failed. While an action is in flight all buttons + are disabled (muted text, no hover). +2. URL in `MONO`, secondary; spacer; Always on switch. +3. Reason line when needed: failed/needs-attention reason in `failed.fg`, + others in secondary. Native-project note in `SMALL`, muted. + +### Tabs +Pill tabs: Services, Env, Activity (Logs moved into the terminal panel). 26 high, radius 6, padding 10. +Selected: `surface_1` background, `BODY_STRONG`, primary. Others: `BODY`, +secondary, primary on hover. No underline, no bar background. + +### Services table +Columns: Service (flex, dot + name), State (92), CPU (64, right-aligned), +Memory (64, right-aligned), Route (flex, `MONO` secondary; "internal" muted +when the service has no route). Header row 26, `SMALL` muted, sentence case. +Rows 32, hairline separators. State cell is short and agrees with the dot: +`running`, `unhealthy` (degraded), `restarting` / `dead` / `exited 1` +(failed), `exited 0` and `not created` (stopped), `paused` and any +unrecognised container state (unknown ring, never stopped), `unknown` when +not observed; failures in `failed.fg`. Unknown +numbers are an em dash in muted. + +### Terminal panel + +Approved mockup: an edge-to-edge panel docked at the bottom of the content +area, right of the sidebar, full width. No card, no rounded corners, no inner +padding box. Its height is remembered per project. + +Anatomy, top to bottom: + +1. **Drag handle** (9): `surface_1`, `border_strong` hairline on top, a + 36 × 3 grip (radius 2), row-resize cursor. Drag to resize; drag to the top + or double-click to maximize; drag down past the strip to collapse. +2. **Tab strip** (32): terminal `strip` colour, hairline below. Tabs are + 12 pt with a 14 pt icon, separated by hairlines; the selected tab takes the + terminal background and primary text. After the tabs: `+` and a caret for + the new-tab menu. Right: find (search icon) and maximize / restore + (`chevron-up` when collapsed). +3. **Terminal surface**: fills the rest; 14 / 10 inset; terminal font and + theme (below). The emulator draws everything inside it. + +States: + +| State | Panel | Project header | Top tabs and Services | +|---|---|---|---| +| Collapsed | Handle and tab strip only, at the bottom | Full | Visible, fill the area | +| Open (default half; any remembered height) | Handle, strip, terminal | Full | Visible above, scroll | +| Maximized | Strip and terminal fill the content area | One compact row: name (13/500), state pill, backend pill, actions | Hidden | + +Tab kinds and labels (names only, never values): + +| Kind | Started with | Label | Badge | +|---|---|---|---| +| Logs (always first, not closable) | Core log stream reader (from #808), read-only | `Logs` | — | +| Shell in a service | `hack exec -- bash` (falls back to `sh`) | `api · sh` | — | +| Host shell | User's login shell, login + interactive, in the project root | `host · zsh` | — | +| Host shell with env | `hack host shell --env --scope ` | `host · zsh` | `env staging · api` | + +The env badge is 11 pt, radius 5: amber (`badge_warn`) when the overlay is +not the project's default, so a production-like environment is obvious; +neutral when it is the default. It stays visible when the panel is +maximized. The Logs tab has a service filter, follow, and find; its output +is shown exactly as printed (ANSI kept, never stripped or re-coloured). + +New-tab menu (`+`): "Shell in " for each running service, a +separator, "Host shell", "Host shell with env…" (then pick overlay and scope +from names read with `hack env list --json`). 220 wide, `surface`, +`border_strong` hairline, shadow, 28 pt rows, at most 300 tall (longer +service lists scroll). It opens from the `+` button toward the larger space: +above the strip when the panel is docked, below it when maximized. + +Closing a tab, or the window, ends that tab's whole process session. + +Terminal look: the user's Ghostty config when it exists (font family and +size, theme or palette, cursor style, padding), mapped onto the component. +Without one, the fallback terminal theme (`TermPalette::fallback`), dark in +both app modes as in the approved mockup, 12.5 / 19 in the app's mono font: + +| Token | Value | | Token | Value | +|---|---|---|---|---| +| `bg` | `#1C1C1A` | | `green` | `#5DCAA5` | +| `fg`, `cursor` | `#E8E6DC` | | `bright_green` | `#97C459` | +| `dim` | `#8B8A85` | | `blue` | `#85B7EB` | +| `strip` | `#2A2A28` | | `magenta` | `#AFA9EC` | +| `strip_border` | `#3A3A37` | | `yellow` | `#EF9F27` | +| `tab_fg` | `#B4B2A9` | | `red` | `#F07B7A` | +| `badge_warn_bg` / `_fg` | `#633806` / `#FAC775` | | `cyan` | `#6CCFD9` | +| `badge_bg` / `_fg` | `#3A3A37` / `#D3D1C7` | | | | + +Prompt themes (zsh, oh-my-zsh, starship) come from the real shell in a +real PTY (`TERM`, `COLORTERM=truecolor`); tmux is not involved. + +Keyboard (shortcuts that would reach the shell are never taken; on Linux the +app uses Ctrl-Shift chords, as terminals do): + +| Action | macOS | Linux | +|---|---|---| +| Toggle panel | ⌘J | Ctrl-Shift-J | +| New tab (opens the menu) | ⌘T | Ctrl-Shift-T | +| Close tab | ⌘W | Ctrl-Shift-W | +| Next / previous tab | ⌘⇧] / ⌘⇧[ | Ctrl-Shift-PageDown / Ctrl-Shift-PageUp | +| Maximize / restore | ⌘⇧↩ | Ctrl-Shift-Enter | +| Find in tab | ⌘F | Ctrl-Shift-F | +| Copy / paste | ⌘C / ⌘V | Ctrl-Shift-C / Ctrl-Shift-V | + +### Status bar (26) +`chrome`, hairline above, `SMALL` secondary. Left, separated by `·`: +`N running` (or `N not observed`), `N always on`, engine state with a 6 pt +dot (`engine ok` / `engine down` / `engine unknown`), and `hackd off` / +`hackd mismatch` only when relevant. Right: `hack `. Without a +snapshot: `not connected`. + +### Empty, error and loading panes +Centred: 40 pt rounded icon tile (`surface_1`), headline, one-line body +(secondary, max 360 wide), optional command chip in `MONO`. Loading shows +three skeleton rows in the sidebar. + +## gpui-kit components and overrides + +| Element | Built with | Override | +|---|---|---| +| Window root | `gpui_kit::open_window` (gpui-kit `Root`) | Background, text colour and font family set from tokens, not the theme | +| Always on switch | gpui-kit `Switch`, `.small()` | `.color(accent)`; label from tokens; disabled until HACK-2213 | +| Icons | gpui-kit `Icon` with `.path()` | Selected Lucide set via `icon_assets!`, explicit size and colour | +| Buttons, tabs, pills, rows, table, search field, banners, terminal panel chrome | `div` compositions in `ui/src/components.rs` and `ui/src/panel.rs` | All colours, sizes and radii from tokens | + +gpui-kit's `Button`, `TabBar::pill` and `Tag` draw from the theme's +colours, heights (32) and radii, which are the "default-looking" parts. When +the real window is restyled, interactive pieces keep gpui-kit behaviour +(focus, keyboard, tooltips) by either (a) the custom button variant fed from +tokens, or (b) the small components here gaining `track_focus` and key +handlers. Recommended default: (a) for buttons, (b) for tabs and rows. +Hairlines use a small `StyledExt` helper because GPUI's border helpers start +at 1 pt. + +## Terminal sessions + +The `term` crate (decision in `term/DECISION.md`, PR #862) owns the PTYs and +the emulator; this spec owns the panel. Session rules: + +- The app owns each PTY and runs the CLI in it. It never builds an + environment and never sees a value: env injection happens inside + `hack host shell` / `hack exec`, and the app passes overlay, scope, + service and shell names only. +- Each session starts in its own session and process group; closing the + tab or the window ends the whole group. +- The app never types secrets, never logs terminal contents, and passes no + values in argv. What a service prints is shown as printed, without any + redaction claim. +- Preferred over attaching to tmux: one mechanism, it works without tmux, + and the CLI stays the only thing that touches env values. + +## Not in v1 + +Menu bar popover (mockup "later"), Doctor and Routing detail views, +Env and Activity content, per-service usage in the live app, and wiring the +panel to real sessions (PR 3, after #808 and #806). Accessibility labels are kept from the current view when the +window is restyled. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7cd78c2d6..cb5494068 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,6 +10,7 @@ of the release or onboarding yet. |---|---| | `core` (`hack-desktop-core`) | Models, CLI runner with timeouts, read-only hackd client, project state, list/detail view models, fixtures. No GUI dependency. | | `app` (`hack-desktop`) | The window, built with [gpui-kit](https://gpui-kit.com/) `=0.9.1`. | +| `ui` (`hack-desktop-ui`) | Design v1 tokens and components, and the `hack-desktop-gallery` fixture gallery. Spec and renders: [DESIGN-V1.md](DESIGN-V1.md). Not yet used by the window. | This is its own Cargo workspace with its own lockfile and target directory; it does not touch `packages/runtime-core`. diff --git a/apps/desktop/screenshots/v1/fixtures-dark.png b/apps/desktop/screenshots/v1/fixtures-dark.png new file mode 100644 index 000000000..85ab69688 Binary files /dev/null and b/apps/desktop/screenshots/v1/fixtures-dark.png differ diff --git a/apps/desktop/screenshots/v1/fixtures-light.png b/apps/desktop/screenshots/v1/fixtures-light.png new file mode 100644 index 000000000..5359f443c Binary files /dev/null and b/apps/desktop/screenshots/v1/fixtures-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-failed-collapsed-dark.png b/apps/desktop/screenshots/v1/panel-failed-collapsed-dark.png new file mode 100644 index 000000000..175825651 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-failed-collapsed-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-failed-collapsed-light.png b/apps/desktop/screenshots/v1/panel-failed-collapsed-light.png new file mode 100644 index 000000000..a296f7bd2 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-failed-collapsed-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-failed-half-dark.png b/apps/desktop/screenshots/v1/panel-failed-half-dark.png new file mode 100644 index 000000000..f385360c0 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-failed-half-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-failed-half-light.png b/apps/desktop/screenshots/v1/panel-failed-half-light.png new file mode 100644 index 000000000..eed437436 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-failed-half-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-failed-max-dark.png b/apps/desktop/screenshots/v1/panel-failed-max-dark.png new file mode 100644 index 000000000..4a0d14e02 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-failed-max-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-failed-max-light.png b/apps/desktop/screenshots/v1/panel-failed-max-light.png new file mode 100644 index 000000000..775952e44 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-failed-max-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-many-menu-dark.png b/apps/desktop/screenshots/v1/panel-many-menu-dark.png new file mode 100644 index 000000000..bca2f7775 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-many-menu-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-many-menu-light.png b/apps/desktop/screenshots/v1/panel-many-menu-light.png new file mode 100644 index 000000000..db47d668c Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-many-menu-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-collapsed-dark.png b/apps/desktop/screenshots/v1/panel-running-collapsed-dark.png new file mode 100644 index 000000000..1bfed7faf Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-collapsed-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-collapsed-light.png b/apps/desktop/screenshots/v1/panel-running-collapsed-light.png new file mode 100644 index 000000000..eab3b6902 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-collapsed-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-half-dark.png b/apps/desktop/screenshots/v1/panel-running-half-dark.png new file mode 100644 index 000000000..7e9487a8d Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-half-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-half-light.png b/apps/desktop/screenshots/v1/panel-running-half-light.png new file mode 100644 index 000000000..35256a591 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-half-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-max-dark.png b/apps/desktop/screenshots/v1/panel-running-max-dark.png new file mode 100644 index 000000000..2dc09df81 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-max-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-max-light.png b/apps/desktop/screenshots/v1/panel-running-max-light.png new file mode 100644 index 000000000..2a0fa37f2 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-max-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-max-menu-dark.png b/apps/desktop/screenshots/v1/panel-running-max-menu-dark.png new file mode 100644 index 000000000..85337fda6 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-max-menu-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-max-menu-light.png b/apps/desktop/screenshots/v1/panel-running-max-menu-light.png new file mode 100644 index 000000000..fda830b98 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-max-menu-light.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-menu-dark.png b/apps/desktop/screenshots/v1/panel-running-menu-dark.png new file mode 100644 index 000000000..3fe67b8ee Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-menu-dark.png differ diff --git a/apps/desktop/screenshots/v1/panel-running-menu-light.png b/apps/desktop/screenshots/v1/panel-running-menu-light.png new file mode 100644 index 000000000..53b565b44 Binary files /dev/null and b/apps/desktop/screenshots/v1/panel-running-menu-light.png differ diff --git a/apps/desktop/ui/Cargo.toml b/apps/desktop/ui/Cargo.toml new file mode 100644 index 000000000..725c846b7 --- /dev/null +++ b/apps/desktop/ui/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hack-desktop-ui" +description = "Design v1 tokens and presentational components for the Hack desktop app, with a fixture gallery" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "hack-desktop-gallery" +path = "src/bin/gallery.rs" + +[features] +# Offscreen rendering of the gallery to PNG (design evidence). +screenshots = ["gpui-kit/test-support", "dep:image"] + +[dependencies] +hack-desktop-core.workspace = true +gpui-kit.workspace = true +image = { version = "0.25", optional = true, default-features = false, features = ["png"] } diff --git a/apps/desktop/ui/src/bin/gallery.rs b/apps/desktop/ui/src/bin/gallery.rs new file mode 100644 index 000000000..4aeed51ce --- /dev/null +++ b/apps/desktop/ui/src/bin/gallery.rs @@ -0,0 +1,319 @@ +//! Design v1 gallery: the main window, drawn with the v1 components from the +//! app's fixtures. It runs no command, never polls and reads no user config. +//! +//! ```text +//! hack-desktop-gallery --fixture running --theme dark [--panel collapsed|half|max] +//! hack-desktop-gallery --screenshots (feature `screenshots`) every +//! fixture plus the terminal panel +//! scenes, light and dark, offscreen +//! ``` + +use gpui_kit::component::{Theme, ThemeMode}; +use gpui_kit::{AppContext as _, Bounds, SharedString, WindowBounds, WindowOptions, px, size}; +use hack_desktop_core::fixtures::Fixture; +use hack_desktop_ui::panel::{PanelState, TabKind}; +use hack_desktop_ui::tokens::Mode; +use hack_desktop_ui::window::{MainWindow, WindowData}; + +fn usage() -> ! { + eprintln!( + "usage: hack-desktop-gallery [--fixture ] [--theme light|dark] [--panel collapsed|half|max] [--screenshots ]" + ); + std::process::exit(2) +} + +fn window_options() -> WindowOptions { + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Default::default(), + size: size(px(1040.), px(720.)), + })), + app_id: Some("dance.hack.desktop.gallery".into()), + ..WindowOptions::default() + } +} + +/// One rendered scene: a fixture plus a terminal panel arrangement. +pub struct Scene { + pub name: String, + pub fixture: Fixture, + pub panel: PanelState, + pub tabs: Vec, + pub selected: usize, + pub menu: bool, +} + +fn env_tab() -> TabKind { + TabKind::HostShellWithEnv { + shell: "zsh".into(), + overlay: "staging".into(), + scope: "api".into(), + is_default: false, + } +} + +/// Every fixture (panel half open, Logs), then the panel scenes. +pub fn scenes() -> Vec { + let mut out: Vec = Fixture::ALL + .iter() + .map(|&f| Scene { + name: f.name().into(), + fixture: f, + panel: PanelState::half(), + tabs: vec![TabKind::Logs], + selected: 0, + menu: false, + }) + .collect(); + let running_tabs = vec![ + TabKind::Logs, + TabKind::ContainerShell { + service: "api".into(), + shell: "sh".into(), + }, + env_tab(), + ]; + let failed_tabs = vec![ + TabKind::Logs, + TabKind::HostShell { + shell: "zsh".into(), + }, + ]; + for (name, fixture, panel, tabs, selected, menu) in [ + ( + "panel-running-collapsed", + Fixture::Running, + PanelState::Collapsed, + &running_tabs, + 0, + false, + ), + ( + "panel-running-half", + Fixture::Running, + PanelState::half(), + &running_tabs, + 2, + false, + ), + ( + "panel-running-max", + Fixture::Running, + PanelState::Maximized, + &running_tabs, + 0, + false, + ), + ( + "panel-running-menu", + Fixture::Running, + PanelState::half(), + &running_tabs, + 0, + true, + ), + ( + "panel-running-max-menu", + Fixture::Running, + PanelState::Maximized, + &running_tabs, + 0, + true, + ), + ( + "panel-many-menu", + Fixture::ManyProjects, + PanelState::half(), + &running_tabs, + 0, + true, + ), + ( + "panel-failed-collapsed", + Fixture::FailedStart, + PanelState::Collapsed, + &failed_tabs, + 0, + false, + ), + ( + "panel-failed-half", + Fixture::FailedStart, + PanelState::half(), + &failed_tabs, + 0, + false, + ), + ( + "panel-failed-max", + Fixture::FailedStart, + PanelState::Maximized, + &failed_tabs, + 1, + false, + ), + ] { + out.push(Scene { + name: name.into(), + fixture, + panel, + tabs: tabs.clone(), + selected, + menu, + }); + } + out +} + +fn build( + scene: &Scene, + mode: Mode, + traffic_lights: bool, + term_font: Option, +) -> MainWindow { + let mut w = MainWindow::new(WindowData::from_fixture(scene.fixture), mode); + w.traffic_lights = traffic_lights; + w.panel = scene.panel; + w.panel_tabs = scene.tabs.clone(); + w.panel_selected = scene.selected; + w.panel_menu = scene.menu; + w.term_font = term_font; + w +} + +/// A Nerd Font for prompt glyphs when one is installed: a stand-in for the +/// user's Ghostty `font-family`, which the gallery deliberately does not read. +fn nerd_font(cx: &gpui_kit::App) -> Option { + cx.text_system() + .all_font_names() + .into_iter() + .find(|n| n.contains("Nerd Font") && n.contains("Mono")) + .map(Into::into) +} + +fn main() { + let mut fixture = Fixture::Running; + let mut mode = Mode::Light; + let mut panel = PanelState::half(); + let mut shots: Option = None; + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--fixture" => { + let name = it.next().unwrap_or_else(|| usage()); + fixture = Fixture::parse(&name).unwrap_or_else(|| usage()); + } + "--theme" => { + mode = match it.next().as_deref() { + Some("light") => Mode::Light, + Some("dark") => Mode::Dark, + _ => usage(), + } + } + "--panel" => { + panel = match it.next().as_deref() { + Some("collapsed") => PanelState::Collapsed, + Some("half") => PanelState::half(), + Some("max") => PanelState::Maximized, + _ => usage(), + } + } + "--screenshots" => shots = Some(it.next().unwrap_or_else(|| usage()).into()), + _ => usage(), + } + } + if let Some(dir) = shots { + #[cfg(feature = "screenshots")] + { + screenshots::run(&dir); + return; + } + #[cfg(not(feature = "screenshots"))] + { + let _ = dir; + eprintln!("--screenshots needs a build with --features screenshots"); + std::process::exit(2); + } + } + let scene = Scene { + name: fixture.name().into(), + fixture, + panel, + tabs: vec![TabKind::Logs, env_tab()], + selected: 0, + menu: false, + }; + gpui_kit::application() + .with_assets(hack_desktop_ui::icons::Assets) + .run(move |cx| { + gpui_kit::init(cx); + gpui_kit::open_window(window_options(), cx, move |window, cx| { + Theme::change(theme_mode(mode), Some(window), cx); + let font = nerd_font(cx); + cx.new(|_| build(&scene, mode, false, font)) + }) + .expect("failed to open the window"); + }); +} + +fn theme_mode(mode: Mode) -> ThemeMode { + match mode { + Mode::Light => ThemeMode::Light, + Mode::Dark => ThemeMode::Dark, + } +} + +#[cfg(feature = "screenshots")] +mod screenshots { + use std::path::Path; + use std::sync::Arc; + + use gpui_kit::component::Theme; + use gpui_kit::test::TestWindowExt as _; + use gpui_kit::{AppContext as _, HeadlessAppContext, WindowOptions}; + use hack_desktop_ui::tokens::Mode; + + pub fn run(dir: &Path) { + std::fs::create_dir_all(dir).expect("create screenshot dir"); + let mut cx = HeadlessAppContext::with_platform( + gpui_kit::platform::current_platform(true).text_system(), + Arc::new(hack_desktop_ui::icons::Assets), + gpui_kit::platform::current_headless_renderer, + ); + cx.update(gpui_kit::init); + let font = cx.update(|cx| super::nerd_font(cx)); + for mode in [Mode::Light, Mode::Dark] { + for scene in super::scenes() { + cx.update(|cx| Theme::change(super::theme_mode(mode), None, cx)); + let opts = WindowOptions { + focus: false, + show: false, + ..super::window_options() + }; + let font = font.clone(); + let (handle, _) = cx + .update(|cx| { + gpui_kit::open_window(opts, cx, |_, cx| { + cx.new(|_| super::build(&scene, mode, true, font)) + }) + }) + .expect("open offscreen window"); + // Two frames: the first loads icon assets. + for _ in 0..2 { + cx.update_window(handle, |_, window, cx| window.render_frame(cx)) + .expect("render"); + cx.run_until_parked(); + } + let img = cx + .capture_screenshot(handle) + .expect("offscreen rendering must be available"); + let suffix = if mode == Mode::Dark { "dark" } else { "light" }; + let path = dir.join(format!("{}-{suffix}.png", scene.name)); + img.save(&path).expect("write png"); + println!("{}", path.display()); + cx.update_window(handle, |_, window, _| window.remove_window()) + .ok(); + } + } + } +} diff --git a/apps/desktop/ui/src/components.rs b/apps/desktop/ui/src/components.rs new file mode 100644 index 000000000..87edb9afc --- /dev/null +++ b/apps/desktop/ui/src/components.rs @@ -0,0 +1,628 @@ +//! Small presentational components. Each takes data and [`Ui`], returns an +//! element, and holds no state. Click handlers are passed in by the caller. + +use gpui_kit::component::{Icon, Sizable as _, switch::Switch}; +use gpui_kit::{ + Div, ElementId, Hsla, IntoElement, ParentElement, SharedString, Stateful, Styled, div, + prelude::*, px, +}; + +use crate::icons::Glyph; +use crate::status::Status; +use crate::tokens::{HAIRLINE, Palette, TypeStep, radius, size, space, text}; + +/// Everything a component needs to draw: palette and font families. +#[derive(Clone)] +pub struct Ui { + pub p: Palette, + pub font: SharedString, + pub mono: SharedString, +} + +/// Type-scale and hairline helpers on any styled element. +pub trait StyledExt: Styled + Sized { + fn type_step(self, t: TypeStep) -> Self { + self.text_size(t.size) + .line_height(t.line) + .font_weight(t.weight) + } + fn hairline_t(mut self, color: Hsla) -> Self { + self.style().border_widths.top = Some(HAIRLINE.into()); + self.border_color(color) + } + fn hairline_b(mut self, color: Hsla) -> Self { + self.style().border_widths.bottom = Some(HAIRLINE.into()); + self.border_color(color) + } + fn hairline_r(mut self, color: Hsla) -> Self { + self.style().border_widths.right = Some(HAIRLINE.into()); + self.border_color(color) + } + fn hairline(mut self, color: Hsla) -> Self { + let s = self.style(); + s.border_widths.top = Some(HAIRLINE.into()); + s.border_widths.bottom = Some(HAIRLINE.into()); + s.border_widths.left = Some(HAIRLINE.into()); + s.border_widths.right = Some(HAIRLINE.into()); + self.border_color(color) + } +} +impl StyledExt for T {} + +/// 8px state dot. Unknown is a ring so it never reads as "stopped". +pub fn dot(status: Status, ui: &Ui) -> Div { + let c = status.colors(&ui.p); + let d = div().size(size::DOT).rounded_full().flex_none(); + if status == Status::Unknown { + d.border_color(c.dot).border(px(1.5)) + } else { + d.bg(c.dot) + } +} + +/// An icon from the app's icon set. +pub fn icon(glyph: Glyph, px_size: gpui_kit::Pixels, color: Hsla) -> Icon { + Icon::default() + .path(glyph.path()) + .with_size(px_size) + .text_color(color) +} + +/// Rounded label pill: state, backend, counts. +pub fn pill(label: impl Into, bg: Hsla, fg: Hsla) -> Div { + div() + .flex_none() + .px(space::S4) + .py(px(1.)) + .rounded(radius::PILL) + .bg(bg) + .text_color(fg) + .type_step(text::SMALL_STRONG) + .whitespace_nowrap() + .child(label.into()) +} + +/// State pill with the short label. +pub fn state_pill(status: Status, label: impl Into, ui: &Ui) -> Div { + let c = status.colors(&ui.p); + pill(label, c.bg, c.fg) +} + +/// Neutral pill (backend name). +pub fn neutral_pill(label: impl Into, ui: &Ui) -> Div { + pill(label, ui.p.surface_1, ui.p.text_secondary) +} + +/// Count pill on the Doctor row; amber when there are findings. +pub fn count_pill(n: usize, ui: &Ui) -> Div { + let c = ui.p.states.degraded; + div() + .flex_none() + .min_w(px(18.)) + .h(px(16.)) + .px(space::S2) + .rounded(radius::PILL) + .flex() + .items_center() + .justify_center() + .bg(c.bg) + .text_color(c.fg) + .type_step(text::MICRO) + .child(n.to_string()) +} + +/// Sidebar section caption: "Running", "Stopped", "Machine". +pub fn caption(label: impl Into, ui: &Ui) -> Div { + div() + .px(space::S5) + .pt(space::S6) + .pb(space::S2) + .type_step(text::SMALL_STRONG) + .text_color(ui.p.text_muted) + .child(label.into()) +} + +/// Sidebar project row: dot, name, optional always-on pin. +pub fn project_row( + id: impl Into, + name: impl Into, + status: Status, + always_on: bool, + selected: bool, + ui: &Ui, +) -> Stateful
{ + let dim = matches!(status, Status::Stopped | Status::Unknown) && !selected; + let p = ui.p; + div() + .id(id) + .h(size::ROW) + .flex_none() + .px(space::S5) + .flex() + .items_center() + .gap(space::S4) + .rounded(radius::CONTROL) + .cursor_pointer() + .when(selected, |d| d.bg(p.surface_1)) + .hover(move |d| d.bg(p.surface_1)) + .child(dot(status, ui)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .type_step(if selected { + text::BODY_STRONG + } else { + text::BODY + }) + .text_color(if dim { + p.text_secondary + } else { + p.text_primary + }) + .child(name.into()), + ) + .when(always_on, |d| { + d.child(icon(Glyph::Pin, size::ICON, p.text_muted)) + }) +} + +/// Sidebar machine row: leading icon, label, trailing dot or count. +pub fn machine_row( + id: impl Into, + glyph: Glyph, + label: impl Into, + trailing: impl IntoElement, + ui: &Ui, +) -> Stateful
{ + let p = ui.p; + div() + .id(id) + .h(size::ROW) + .flex_none() + .px(space::S5) + .flex() + .items_center() + .gap(space::S4) + .rounded(radius::CONTROL) + .cursor_pointer() + .hover(move |d| d.bg(p.surface_1)) + .child(icon(glyph, size::ICON_ROW, p.text_secondary)) + .child( + div() + .flex_1() + .type_step(text::BODY) + .text_color(p.text_primary) + .child(label.into()), + ) + .child(trailing) +} + +/// Header action button: icon and label, quiet until hovered. +pub fn action_button( + id: impl Into, + glyph: Glyph, + label: impl Into, + enabled: bool, + ui: &Ui, +) -> Stateful
{ + let p = ui.p; + let fg = if enabled { + p.text_primary + } else { + p.text_muted + }; + div() + .id(id) + .h(size::CONTROL) + .px(space::S5) + .flex() + .items_center() + .gap(space::S3) + .rounded(radius::CONTROL) + .hairline(p.border_strong) + .bg(p.surface) + .text_color(fg) + .type_step(text::BODY) + .when(enabled, |d| { + d.cursor_pointer() + .hover(move |d| d.bg(p.surface_1)) + .active(move |d| d.bg(p.surface_2)) + }) + .child(icon(glyph, size::ICON, fg)) + .child(label.into()) +} + +/// Primary action (Start on a stopped project): filled. +pub fn primary_button( + id: impl Into, + glyph: Glyph, + label: impl Into, + ui: &Ui, +) -> Stateful
{ + let p = ui.p; + let fg = p.surface; + div() + .id(id) + .h(size::CONTROL) + .px(space::S5) + .flex() + .items_center() + .gap(space::S3) + .rounded(radius::CONTROL) + .bg(p.text_primary) + .text_color(fg) + .type_step(text::BODY_STRONG) + .cursor_pointer() + .hover(move |d| d.opacity(0.88)) + .child(icon(glyph, size::ICON, fg)) + .child(label.into()) +} + +/// "Always on" switch (gpui-kit Switch, accent colour, small). +pub fn always_on_switch(checked: bool, enabled: bool, ui: &Ui) -> impl IntoElement { + use gpui_kit::component::Disableable as _; + Switch::new("always-on") + .checked(checked) + .small() + .color(ui.p.accent) + .label("Always on") + .disabled(!enabled) +} + +/// Pill tabs: the selected tab gets a filled background, not an underline. +pub fn pill_tabs(labels: &[&'static str], selected: usize, ui: &Ui) -> Div { + let p = ui.p; + let mut row = div().flex().items_center().gap(space::S2); + for (i, label) in labels.iter().enumerate() { + let sel = i == selected; + row = row.child( + div() + .id(("tab", i)) + .h(size::CONTROL) + .px(space::S5) + .flex() + .items_center() + .rounded(radius::CONTROL) + .cursor_pointer() + .type_step(if sel { text::BODY_STRONG } else { text::BODY }) + .text_color(if sel { + p.text_primary + } else { + p.text_secondary + }) + .when(sel, |d| d.bg(p.surface_1)) + .when(!sel, |d| d.hover(move |d| d.text_color(p.text_primary))) + .child(*label), + ); + } + row +} + +/// Title bar: traffic-light inset (macOS), app name, search field (Cmd-K). +pub fn title_bar(show_traffic_lights: bool, ui: &Ui) -> Div { + let p = ui.p; + let lights = div() + .w(size::TRAFFIC_LIGHTS) + .flex_none() + .flex() + .items_center() + .gap(space::S4) + .pl(space::S6) + .when(show_traffic_lights, |d| { + // Stand-in for the native controls in offscreen renders only. + let l = |c: u32| { + div() + .size(px(12.)) + .rounded_full() + .bg(gpui_kit::rgb(c)) + .hairline(p.border) + }; + d.child(l(0xFF5F57)).child(l(0xFEBC2E)).child(l(0x28C840)) + }); + div() + .h(size::TITLE_BAR) + .flex_none() + .flex() + .items_center() + .bg(p.chrome) + .hairline_b(p.border) + .child(lights) + .child( + div() + .pl(space::S4) + .type_step(text::BODY_STRONG) + .text_color(p.text_primary) + .child("Hack"), + ) + .child(div().flex_1()) + .child(search_field(ui)) + .child(div().w(space::S6)) +} + +/// The Cmd-K search affordance in the title bar. +pub fn search_field(ui: &Ui) -> Stateful
{ + let p = ui.p; + div() + .id("search") + .w(size::SEARCH_W) + .h(px(24.)) + .px(space::S4) + .flex() + .items_center() + .gap(space::S3) + .rounded(radius::CONTROL) + .bg(p.surface) + .hairline(p.border_strong) + .cursor_pointer() + .child(icon(Glyph::Search, size::ICON, p.text_muted)) + .child( + div() + .flex_1() + .type_step(text::BODY) + .text_color(p.text_muted) + .child("Search"), + ) + .child( + div() + .px(space::S2) + .rounded(px(4.)) + .bg(p.surface_1) + .type_step(text::MICRO) + .text_color(p.text_secondary) + .child("⌘K"), + ) +} + +/// Status bar: summary items on the left, version on the right. +pub fn status_bar(items: Vec<(Option, String)>, version: Option, ui: &Ui) -> Div { + let p = ui.p; + let mut left = div().flex().items_center().gap(space::S3); + for (i, (status, label)) in items.into_iter().enumerate() { + if i > 0 { + left = left.child(div().text_color(p.text_muted).child("·")); + } + left = left.child( + div() + .flex() + .items_center() + .gap(space::S3) + .when_some(status, |d, s| d.child(dot(s, ui).size(px(6.)))) + .child(label), + ); + } + div() + .h(size::STATUS_BAR) + .flex_none() + .px(space::S6) + .flex() + .items_center() + .bg(p.chrome) + .hairline_t(p.border) + .type_step(text::SMALL) + .text_color(p.text_secondary) + .child(left) + .child(div().flex_1()) + .children(version) +} + +/// One row of the services table. +pub struct ServiceRow { + pub name: String, + pub status: Status, + pub state: String, + pub cpu: Option, + pub memory: Option, + /// `Ok(route)` is shown in mono; `Err(note)` is a muted note ("internal"). + pub route: Result, +} + +const COL_STATE: f32 = 92.; +const COL_NUM: f32 = 64.; + +/// Services table: Service, State, CPU, Memory, Route. Hairline row +/// separators, no outer box, numbers right-aligned. +pub fn services_table(rows: &[ServiceRow], ui: &Ui) -> Div { + let p = ui.p; + let num = |v: Option, ui: &Ui| { + div() + .w(px(COL_NUM)) + .flex_none() + .flex() + .justify_end() + .pr(space::S6) + .text_color(if v.is_some() { + ui.p.text_primary + } else { + ui.p.text_muted + }) + .child(v.unwrap_or_else(|| "—".into())) + }; + let header = div() + .h(px(26.)) + .flex() + .items_center() + .type_step(text::SMALL) + .text_color(p.text_muted) + .child(div().flex_1().min_w_0().child("Service")) + .child(div().w(px(COL_STATE)).flex_none().child("State")) + .child( + div() + .w(px(COL_NUM)) + .flex_none() + .flex() + .justify_end() + .pr(space::S6) + .child("CPU"), + ) + .child( + div() + .w(px(COL_NUM)) + .flex_none() + .flex() + .justify_end() + .pr(space::S6) + .child("Memory"), + ) + .child(div().flex_1().min_w_0().pl(space::S4).child("Route")); + let mut table = div().flex().flex_col().child(header); + for (i, r) in rows.iter().enumerate() { + let state_color = match r.status { + Status::Failed => p.states.failed.fg, + Status::Degraded => p.states.degraded.fg, + Status::Running => p.text_primary, + _ => p.text_secondary, + }; + let route = match &r.route { + Ok(route) => div() + .font_family(ui.mono.clone()) + .type_step(text::MONO) + .text_color(p.text_secondary) + .child(route.clone()), + Err(note) => div().text_color(p.text_muted).child(note.clone()), + }; + table = table.child( + div() + .id(("svc", i)) + .h(size::TABLE_ROW) + .flex_none() + .flex() + .items_center() + .hairline_t(p.border) + .type_step(text::BODY) + .text_color(p.text_primary) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap(space::S4) + .child(dot(r.status, ui)) + .child(div().truncate().child(r.name.clone())), + ) + .child( + div() + .w(px(COL_STATE)) + .flex_none() + .truncate() + .text_color(state_color) + .child(r.state.clone()), + ) + .child(num(r.cpu.clone(), ui)) + .child(num(r.memory.clone(), ui)) + .child( + div() + .flex_1() + .min_w_0() + .pl(space::S4) + .truncate() + .child(route), + ), + ); + } + table +} + +/// Tone of an inline banner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BannerTone { + Warn, + Error, +} + +/// Inline banner under the title bar: icon, one line of text, optional action. +pub fn banner( + tone: BannerTone, + message: impl Into, + action: Option<(Glyph, &'static str)>, + ui: &Ui, +) -> Div { + let p = ui.p; + let c = match tone { + BannerTone::Warn => p.states.degraded, + BannerTone::Error => p.states.failed, + }; + div() + .flex() + .items_center() + .gap(space::S4) + .px(space::S7) + .py(space::S3) + .bg(c.bg) + .hairline_b(p.border) + .type_step(text::BODY) + .text_color(p.text_primary) + .child(icon(Glyph::Alert, size::ICON, c.fg)) + .child(div().flex_1().min_w_0().truncate().child(message.into())) + .when_some(action, |d, (glyph, label)| { + d.child(action_button("banner-action", glyph, label, true, ui)) + }) +} + +/// Centered empty or error pane: icon, headline, body, optional command. +pub fn empty_pane( + glyph: Glyph, + title: impl Into, + body: impl Into, + command: Option<&str>, + ui: &Ui, +) -> Div { + let p = ui.p; + div() + .flex_1() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(space::S4) + .child( + div() + .size(px(40.)) + .rounded(radius::PANEL) + .bg(p.surface_1) + .flex() + .items_center() + .justify_center() + .child(icon(glyph, px(20.), p.text_secondary)), + ) + .child( + div() + .pt(space::S2) + .type_step(text::HEADLINE) + .text_color(p.text_primary) + .child(title.into()), + ) + .child( + div() + .max_w(px(360.)) + .text_center() + .type_step(text::BODY) + .text_color(p.text_secondary) + .child(body.into()), + ) + .when_some(command, |d, cmd| { + d.child( + div() + .mt(space::S4) + .px(space::S5) + .py(space::S2) + .rounded(radius::CONTROL) + .bg(p.surface_1) + .font_family(ui.mono.clone()) + .type_step(text::MONO) + .text_color(p.text_primary) + .child(cmd.to_owned()), + ) + }) +} + +/// Placeholder skeleton bar for the loading state. +pub fn skeleton(width: f32, ui: &Ui) -> Div { + div() + .h(px(10.)) + .w(px(width)) + .rounded(px(4.)) + .bg(ui.p.surface_1) +} diff --git a/apps/desktop/ui/src/icons.rs b/apps/desktop/ui/src/icons.rs new file mode 100644 index 000000000..97dcadf61 --- /dev/null +++ b/apps/desktop/ui/src/icons.rs @@ -0,0 +1,176 @@ +//! The app's icon set: Lucide icons bundled by gpui-kit-assets (ISC licence, +//! shipped inside the pinned crate). Only the icons listed here are embedded. + +use std::borrow::Cow; + +use gpui_kit::assets::icon_assets; +use gpui_kit::{AssetSource, SharedString}; + +/// Every icon the window uses, and where (see DESIGN-V1.md, "Icons"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Glyph { + /// Title bar search field. + Search, + /// Sidebar: always-on project. + Pin, + /// Sidebar: Routing and DNS. + Globe, + /// Sidebar: Doctor. + Stethoscope, + /// Header: Open in browser. + ExternalLink, + /// Header: Restart. + Restart, + /// Header: Stop. + Stop, + /// Header: Start. + Play, + /// Service row / header: Exec shell (terminal surface, later). + Terminal, + /// Banners. + Alert, + /// Empty state: no projects. + Folder, + /// Error / CLI missing. + CircleAlert, + /// Daemon recovery. + Power, + /// Loading. + Loader, + /// Terminal panel: Logs tab. + Logs, + /// Terminal panel: shell in a container. + Container, + /// Terminal panel: host shell. + HostShell, + /// Terminal panel: close tab. + Close, + /// Terminal panel: new tab. + Plus, + /// Terminal panel: new-tab menu caret. + ChevronDown, + /// Terminal panel: maximize / restore. + Maximize, + Restore, + /// Terminal panel: expand from collapsed. + ChevronUp, +} + +impl Glyph { + #[must_use] + pub fn path(self) -> &'static str { + match self { + Self::Search => "icons/search.svg", + Self::Pin => "icons/pin.svg", + Self::Globe => "icons/globe.svg", + Self::Stethoscope => "icons/stethoscope.svg", + Self::ExternalLink => "icons/external-link.svg", + Self::Restart => "icons/rotate-cw.svg", + Self::Stop => "icons/square.svg", + Self::Play => "icons/play.svg", + Self::Terminal => "icons/square-terminal.svg", + Self::Alert => "icons/triangle-alert.svg", + Self::Folder => "icons/folder-plus.svg", + Self::CircleAlert => "icons/circle-alert.svg", + Self::Power => "icons/power.svg", + Self::Loader => "icons/loader-circle.svg", + Self::Logs => "icons/list.svg", + Self::Container => "icons/box.svg", + Self::HostShell => "icons/terminal.svg", + Self::Close => "icons/x.svg", + Self::Plus => "icons/plus.svg", + Self::ChevronDown => "icons/chevron-down.svg", + Self::Maximize => "icons/maximize-2.svg", + Self::Restore => "icons/minimize-2.svg", + Self::ChevronUp => "icons/chevron-up.svg", + } + } +} + +icon_assets!( + AppIcons, + [ + Search, + Pin, + Globe, + Stethoscope, + ExternalLink, + RotateCw, + Square, + Play, + SquareTerminal, + TriangleAlert, + FolderPlus, + CircleAlert, + Power, + LoaderCircle, + List, + Box, + Terminal, + X, + Plus, + ChevronDown, + Maximize2, + Minimize2, + ChevronUp, + ] +); + +/// The app's icons first, then gpui-kit's default component bundle (for the +/// icons gpui-kit components draw themselves, such as the switch). +pub struct Assets; + +impl AssetSource for Assets { + fn load(&self, path: &str) -> gpui_kit::Result>> { + if let Some(bytes) = AppIcons.load(path)? { + return Ok(Some(bytes)); + } + gpui_kit::assets::Assets.load(path) + } + + fn list(&self, path: &str) -> gpui_kit::Result> { + let mut all = AppIcons.list(path)?; + all.extend(gpui_kit::assets::Assets.list(path)?); + Ok(all) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_glyph_is_embedded() { + for g in [ + Glyph::Search, + Glyph::Pin, + Glyph::Globe, + Glyph::Stethoscope, + Glyph::ExternalLink, + Glyph::Restart, + Glyph::Stop, + Glyph::Play, + Glyph::Terminal, + Glyph::Alert, + Glyph::Folder, + Glyph::CircleAlert, + Glyph::Power, + Glyph::Loader, + Glyph::Logs, + Glyph::Container, + Glyph::HostShell, + Glyph::Close, + Glyph::Plus, + Glyph::ChevronDown, + Glyph::Maximize, + Glyph::Restore, + Glyph::ChevronUp, + ] { + assert!( + Assets.load(g.path()).unwrap().is_some(), + "{} is not embedded", + g.path() + ); + } + } +} diff --git a/apps/desktop/ui/src/lib.rs b/apps/desktop/ui/src/lib.rs new file mode 100644 index 000000000..adefcf8e8 --- /dev/null +++ b/apps/desktop/ui/src/lib.rs @@ -0,0 +1,13 @@ +//! Design v1 for the Hack desktop app (HACK-2252): tokens, icons and small +//! presentational components, and the main window composed from them. +//! +//! The spec is `apps/desktop/DESIGN-V1.md`. This crate draws; it runs no +//! command and holds no state beyond what the caller passes in. The +//! `hack-desktop-gallery` binary renders every fixture with it. + +pub mod components; +pub mod icons; +pub mod panel; +pub mod status; +pub mod tokens; +pub mod window; diff --git a/apps/desktop/ui/src/panel.rs b/apps/desktop/ui/src/panel.rs new file mode 100644 index 000000000..7577ae596 --- /dev/null +++ b/apps/desktop/ui/src/panel.rs @@ -0,0 +1,400 @@ +//! The project terminal panel: an edge-to-edge panel docked at the bottom of +//! the content area, with a drag handle, a tab strip and the terminal +//! surface. This module draws it from plain data; the `term` crate owns the +//! PTYs and the emulator, and the app owns the state. + +use gpui_kit::{Div, Hsla, ParentElement, SharedString, Styled, div, prelude::*, px, relative}; + +use crate::components::{StyledExt as _, Ui, icon}; +use crate::icons::Glyph; +use crate::tokens::{TermPalette, panel, radius, size, space, text}; + +/// How much of the content area the panel takes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelState { + /// Only the tab strip is shown, at the bottom of the content area. + Collapsed, + /// The remembered height (default half the content area). + Open { fraction_milli: u16 }, + /// Fills the content area; the project header collapses to one row. + Maximized, +} + +impl PanelState { + #[must_use] + pub fn half() -> Self { + Self::Open { + fraction_milli: (panel::DEFAULT_FRACTION * 1000.) as u16, + } + } +} + +/// What a terminal tab runs. Every kind has a fixed label; an injected +/// environment adds a badge with names only, never values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TabKind { + /// Read-only stream of the project's logs (always the first tab). + Logs, + /// `hack exec -- `. + ContainerShell { service: String, shell: String }, + /// The user's login shell in the project root. + HostShell { shell: String }, + /// `hack host shell --env --scope `. + HostShellWithEnv { + shell: String, + overlay: String, + scope: String, + /// The overlay is the project's default. Otherwise the badge warns. + is_default: bool, + }, +} + +impl TabKind { + #[must_use] + pub fn label(&self) -> String { + match self { + Self::Logs => "Logs".into(), + Self::ContainerShell { service, shell } => format!("{service} · {shell}"), + Self::HostShell { shell } | Self::HostShellWithEnv { shell, .. } => { + format!("host · {shell}") + } + } + } + + #[must_use] + pub fn glyph(&self) -> Glyph { + match self { + Self::Logs => Glyph::Logs, + Self::ContainerShell { .. } => Glyph::Container, + Self::HostShell { .. } | Self::HostShellWithEnv { .. } => Glyph::HostShell, + } + } + + /// Env badge text and whether it warns: "env staging · api". + #[must_use] + pub fn badge(&self) -> Option<(String, bool)> { + match self { + Self::HostShellWithEnv { + overlay, + scope, + is_default, + .. + } => Some((format!("env {overlay} · {scope}"), !is_default)), + _ => None, + } + } + + #[must_use] + pub fn closable(&self) -> bool { + !matches!(self, Self::Logs) + } +} + +/// Colour of one run of terminal text. Real output keeps the colours the +/// program printed; these name the fallback theme's ANSI slots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ansi { + Fg, + Dim, + Green, + BrightGreen, + Blue, + Magenta, + Yellow, + Red, + Cyan, +} + +impl Ansi { + fn color(self, t: &TermPalette) -> Hsla { + match self { + Self::Fg => t.fg, + Self::Dim => t.dim, + Self::Green => t.green, + Self::BrightGreen => t.bright_green, + Self::Blue => t.blue, + Self::Magenta => t.magenta, + Self::Yellow => t.yellow, + Self::Red => t.red, + Self::Cyan => t.cyan, + } + } +} + +/// A run of terminal text with one style. +#[derive(Debug, Clone)] +pub struct Span { + pub text: String, + pub fg: Ansi, + pub bold: bool, +} + +#[must_use] +pub fn span(text: impl Into, fg: Ansi) -> Span { + Span { + text: text.into(), + fg, + bold: false, + } +} + +/// Everything the panel shows. +pub struct PanelView { + pub state: PanelState, + pub tabs: Vec, + pub selected: usize, + /// Lines of the selected tab (the emulator's visible rows). + pub lines: Vec>, + /// Draw a block cursor after the last line (interactive tabs). + pub cursor: bool, + /// Show the new-tab menu (gallery only; the app opens it on click). + pub menu: Option>, + /// Terminal font family (Ghostty `font-family`, else the app's mono). + pub font: SharedString, + pub term: TermPalette, +} + +/// The drag handle on the panel's top edge. Drag to resize; drag to the top +/// (or double-click) to maximize; drag down to collapse. +fn handle(ui: &Ui) -> Div { + let p = ui.p; + div() + .h(panel::HANDLE) + .flex_none() + .flex() + .items_center() + .justify_center() + .bg(p.surface_1) + .hairline_t(p.border_strong) + .cursor_row_resize() + .child( + div() + .w(panel::GRIP_W) + .h(panel::GRIP_H) + .rounded(px(2.)) + .bg(p.border_strong), + ) +} + +fn badge(label: String, warn: bool, t: &TermPalette) -> Div { + let (bg, fg) = if warn { + (t.badge_warn_bg, t.badge_warn_fg) + } else { + (t.badge_bg, t.badge_fg) + }; + div() + .flex_none() + .px(space::S3) + .rounded(radius::PILL) + .bg(bg) + .text_color(fg) + .type_step(text::MICRO) + .child(label) +} + +fn strip(v: &PanelView, ui: &Ui) -> Div { + let t = v.term; + let mut tabs = div().flex().items_stretch().min_w_0().overflow_hidden(); + for (i, kind) in v.tabs.iter().enumerate() { + let sel = i == v.selected; + let fg = if sel { t.fg } else { t.tab_fg }; + tabs = tabs.child( + div() + .id(("term-tab", i)) + .flex() + .items_center() + .gap(space::S3) + .px(space::S5) + .hairline_r(t.strip_border) + .when(sel, |d| d.bg(t.bg)) + .text_color(fg) + .type_step(if sel { text::SMALL_STRONG } else { text::SMALL }) + .whitespace_nowrap() + .cursor_pointer() + .child(icon(kind.glyph(), size::ICON, fg)) + .child(kind.label()) + .when_some(kind.badge(), |d, (label, warn)| { + d.child(badge(label, warn, &t)) + }) + .when(kind.closable(), |d| { + d.child(icon(Glyph::Close, px(12.), t.tab_fg)) + }), + ); + } + // Outside the clipped tab row, so its menu can open above the strip. + let new_tab = div() + .id("term-new") + .relative() + .flex_none() + .flex() + .items_center() + .gap(space::S1) + .px(space::S5) + .cursor_pointer() + .child(icon(Glyph::Plus, size::ICON, t.tab_fg)) + .child(icon(Glyph::ChevronDown, px(12.), t.tab_fg)) + .when_some(v.menu.as_ref(), |d, items| { + // Opens toward the larger space: above the strip when the panel + // is docked low, below it when the panel is maximized (the strip + // is then near the top of the window). + let anchor = div().absolute().left(px(0.)); + let anchor = if v.state == PanelState::Maximized { + anchor.top(panel::STRIP + px(4.)) + } else { + anchor.bottom(panel::STRIP + px(4.)) + }; + // Deferred so it paints above the terminal surface drawn after it. + d.child(gpui_kit::deferred(anchor.child(menu(items, ui))).with_priority(1)) + }); + let (size_glyph, find) = match v.state { + PanelState::Collapsed => (Glyph::ChevronUp, false), + PanelState::Open { .. } => (Glyph::Maximize, true), + PanelState::Maximized => (Glyph::Restore, true), + }; + div() + .h(panel::STRIP) + .flex_none() + .flex() + .items_stretch() + .bg(t.strip) + .hairline_b(t.strip_border) + .child(tabs) + .child(new_tab) + .child(div().flex_1()) + .child( + div() + .flex() + .items_center() + .gap(space::S5) + .px(space::S5) + .when(find, |d| d.child(icon(Glyph::Search, size::ICON, t.tab_fg))) + .child(icon(size_glyph, size::ICON, t.tab_fg)), + ) +} + +fn surface(v: &PanelView) -> Div { + let t = v.term; + let mut body = div() + .id("term-surface") + .flex_1() + .min_h_0() + .overflow_hidden() + .flex() + .flex_col() + .px(panel::PAD_X) + .py(panel::PAD_Y) + .bg(t.bg) + .text_color(t.fg) + .font_family(v.font.clone()) + .text_size(panel::FONT) + .line_height(panel::LINE); + let last = v.lines.len().saturating_sub(1); + for (i, line) in v.lines.iter().enumerate() { + let mut row = div().flex().whitespace_nowrap(); + for s in line { + row = row.child( + div() + .text_color(s.fg.color(&t)) + .when(s.bold, |d| d.font_weight(gpui_kit::FontWeight::BOLD)) + .child(s.text.clone()), + ); + } + if v.cursor && i == last { + row = row.child(div().w(px(7.5)).h(panel::LINE).bg(t.cursor)); + } + body = body.child(row); + } + div().flex_1().min_h_0().flex().flex_col().child(body) +} + +fn menu(items: &[String], ui: &Ui) -> gpui_kit::Stateful
{ + let p = ui.p; + let mut m = div() + .id("term-menu") + .w(px(220.)) + .max_h(panel::MENU_MAX_H) + .overflow_y_scroll() + .py(space::S2) + .rounded(radius::PANEL) + .bg(p.surface) + .hairline(p.border_strong) + .shadow_lg() + .type_step(text::BODY) + .text_color(p.text_primary); + for (i, item) in items.iter().enumerate() { + if item.is_empty() { + m = m.child(div().my(space::S2).hairline_t(p.border)); + continue; + } + let glyph = if item.starts_with("Shell in") { + Glyph::Container + } else { + Glyph::HostShell + }; + m = m.child( + div() + .id(("term-menu", i)) + .mx(space::S2) + .h(size::ROW) + .px(space::S4) + .flex() + .items_center() + .gap(space::S4) + .rounded(radius::CONTROL) + .when(i == 0, |d| d.bg(p.surface_1)) + .child(icon(glyph, size::ICON, p.text_secondary)) + .child(item.clone()), + ); + } + m +} + +/// The panel: handle, tab strip, terminal. The caller places it at the +/// bottom of the content column; its height follows [`PanelState`]. +pub fn terminal_panel(v: &PanelView, ui: &Ui) -> Div { + let base = div().flex().flex_col().min_h_0().relative(); + match v.state { + PanelState::Collapsed => base.flex_none().child(handle(ui)).child(strip(v, ui)), + PanelState::Open { fraction_milli } => base + .flex_none() + .h(relative(f32::from(fraction_milli) / 1000.)) + .child(handle(ui)) + .child(strip(v, ui)) + .child(surface(v)), + PanelState::Maximized => base.flex_1().child(strip(v, ui)).child(surface(v)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_and_badges_carry_names_only() { + let t = TabKind::HostShellWithEnv { + shell: "zsh".into(), + overlay: "staging".into(), + scope: "api".into(), + is_default: false, + }; + assert_eq!(t.label(), "host · zsh"); + assert_eq!(t.badge(), Some(("env staging · api".into(), true))); + let d = TabKind::HostShellWithEnv { + shell: "zsh".into(), + overlay: "local".into(), + scope: "web".into(), + is_default: true, + }; + assert_eq!(d.badge(), Some(("env local · web".into(), false))); + assert_eq!( + TabKind::ContainerShell { + service: "api".into(), + shell: "sh".into() + } + .label(), + "api · sh" + ); + assert!(TabKind::Logs.badge().is_none()); + assert!(!TabKind::Logs.closable()); + } +} diff --git a/apps/desktop/ui/src/status.rs b/apps/desktop/ui/src/status.rs new file mode 100644 index 000000000..89f4057e7 --- /dev/null +++ b/apps/desktop/ui/src/status.rs @@ -0,0 +1,243 @@ +//! Display status: the six visual states and their short labels. +//! +//! Maps the core's [`ProjectState`] and [`ServiceView`] to what the window +//! shows. Labels are short and lowercase, like the approved mockup +//! ("running · 3d 4h"); the full reason stays in the reason line or tooltip. + +use hack_desktop_core::state::{ProjectState, ServiceView}; + +use crate::tokens::{Palette, StateColors}; + +/// The visual states. Each has one dot colour and one pill style. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Running, + /// Starting or stopping: an action is in flight. + Busy, + Degraded, + Failed, + Stopped, + /// Not observed. Drawn as a ring, never as a solid "stopped" dot. + Unknown, +} + +impl Status { + #[must_use] + pub fn of(state: &ProjectState) -> Self { + match state { + ProjectState::Running { .. } => Self::Running, + ProjectState::Starting | ProjectState::Stopping => Self::Busy, + ProjectState::Degraded { .. } => Self::Degraded, + ProjectState::FailedStart { .. } | ProjectState::NeedsAttention { .. } => Self::Failed, + ProjectState::Stopped => Self::Stopped, + ProjectState::Unknown { .. } => Self::Unknown, + } + } + + #[must_use] + pub fn of_service(s: &ServiceView) -> Self { + Self::service(s).0 + } + + /// Visual status and short label for a service, from one decision so + /// the dot and the text never disagree. Order matters: an unobserved + /// service is Unknown whatever was last known about it; unhealthy + /// running is Degraded; restarting or dead is Failed; a container in any + /// state the app does not recognise (paused, created, removing) is + /// Unknown, never Stopped. + fn service(s: &ServiceView) -> (Self, String) { + let lower = s.status.to_ascii_lowercase(); + if lower.contains("status unknown") || lower == "unknown" { + return (Self::Unknown, "unknown".into()); + } + if s.running { + if lower.contains("unhealthy") { + return (Self::Degraded, "unhealthy".into()); + } + return (Self::Running, "running".into()); + } + if lower.starts_with("restarting") { + return (Self::Failed, "restarting".into()); + } + if lower.starts_with("dead") { + return (Self::Failed, "dead".into()); + } + if let Some(code) = s.exit_code { + let status = if code == 0 { + Self::Stopped + } else { + Self::Failed + }; + return (status, format!("exited {code}")); + } + if !s.created { + return (Self::Stopped, "not created".into()); + } + if lower.contains("paused") { + return (Self::Unknown, "paused".into()); + } + (Self::Unknown, "unknown".into()) + } + + #[must_use] + pub fn colors(self, p: &Palette) -> StateColors { + match self { + Self::Running => p.states.running, + Self::Busy => p.states.busy, + Self::Degraded => p.states.degraded, + Self::Failed => p.states.failed, + Self::Stopped => p.states.stopped, + Self::Unknown => p.states.unknown, + } + } +} + +/// Short pill label for a project: "running · 2h", "degraded · worker", +/// "failed · api, worker", "needs attention". +#[must_use] +pub fn project_label(state: &ProjectState) -> String { + match state { + ProjectState::Running { + uptime: Some(u), .. + } => format!("running · {}", short_duration(u)), + ProjectState::Running { uptime: None, .. } => "running".into(), + ProjectState::Starting => "starting…".into(), + ProjectState::Stopping => "stopping…".into(), + ProjectState::Degraded { down } => format!("degraded · {} down", list(down)), + ProjectState::FailedStart { services } => format!("failed · {}", list(services)), + ProjectState::NeedsAttention { .. } => "needs attention".into(), + ProjectState::Stopped => "stopped".into(), + ProjectState::Unknown { .. } => "unknown".into(), + } +} + +/// Short state cell for a service row: "running", "unhealthy", "exited 1", +/// "not created", "paused", "unknown". +#[must_use] +pub fn service_label(s: &ServiceView) -> String { + Status::service(s).1 +} + +fn list(names: &[String]) -> String { + match names { + [] => String::new(), + [one] => one.clone(), + [a, b] => format!("{a}, {b}"), + [a, rest @ ..] => format!("{a} +{}", rest.len()), + } +} + +/// "2 hours" -> "2h", "3 days" -> "3d", "About a minute" -> "1m". +/// Anything unrecognised is returned unchanged. +#[must_use] +pub fn short_duration(text: &str) -> String { + let lower = text.trim().to_ascii_lowercase(); + let mut words = lower.split_whitespace(); + let (Some(first), Some(unit)) = (words.next(), words.next()) else { + return text.to_owned(); + }; + let n = match first { + "a" | "an" | "about" => "1".to_owned(), + n if n.chars().all(|c| c.is_ascii_digit()) => n.to_owned(), + _ => return text.to_owned(), + }; + let unit = if first == "about" { + words.next().unwrap_or(unit) + } else { + unit + }; + let suffix = match unit.trim_end_matches('s') { + "second" => "s", + "minute" => "m", + "hour" => "h", + "day" => "d", + "week" => "w", + "month" => "mo", + "year" => "y", + _ => return text.to_owned(), + }; + format!("{n}{suffix}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn durations_shorten() { + assert_eq!(short_duration("2 hours"), "2h"); + assert_eq!(short_duration("3 days"), "3d"); + assert_eq!(short_duration("About a minute"), "1m"); + assert_eq!(short_duration("an hour"), "1h"); + assert_eq!(short_duration("forever"), "forever"); + } + + #[test] + fn project_labels_are_short() { + let s = ProjectState::FailedStart { + services: vec!["api".into(), "worker".into(), "db".into()], + }; + assert_eq!(project_label(&s), "failed · api +2"); + let r = ProjectState::Running { + uptime: Some("2 hours".into()), + exited_ok: vec![], + }; + assert_eq!(project_label(&r), "running · 2h"); + } + + fn svc(running: bool, status: &str, exit: Option, created: bool) -> ServiceView { + ServiceView { + name: "api".into(), + running, + status: status.into(), + exit_code: exit, + completed: !running && exit == Some(0), + created, + route: None, + } + } + + #[test] + fn unobserved_service_is_unknown_even_if_never_created() { + let s = svc(false, "Status unknown", None, false); + assert_eq!(Status::of_service(&s), Status::Unknown); + assert_eq!(service_label(&s), "unknown"); + } + + #[test] + fn unhealthy_running_service_is_degraded() { + let s = svc(true, "Up 2 minutes (unhealthy)", None, true); + assert_eq!(Status::of_service(&s), Status::Degraded); + assert_eq!(service_label(&s), "unhealthy"); + } + + #[test] + fn paused_or_unrecognised_container_is_not_stopped() { + let paused = svc(false, "Up 2 minutes (Paused)", None, true); + assert_eq!(Status::of_service(&paused), Status::Unknown); + assert_eq!(service_label(&paused), "paused"); + let created = svc(false, "Created", None, true); + assert_eq!(Status::of_service(&created), Status::Unknown); + } + + #[test] + fn restarting_and_failed_exits_are_failed() { + let r = svc(false, "Restarting (1) 5 seconds ago", Some(1), true); + assert_eq!(service_label(&r), "restarting"); + assert_eq!(Status::of_service(&r), Status::Failed); + let e = svc(false, "Exited (2) 20 seconds ago", Some(2), true); + assert_eq!( + (Status::of_service(&e), service_label(&e).as_str()), + (Status::Failed, "exited 2") + ); + let ok = svc(false, "Exited (0) 20 seconds ago", Some(0), true); + assert_eq!(Status::of_service(&ok), Status::Stopped); + } + + #[test] + fn unknown_is_not_stopped() { + let u = ProjectState::Unknown { reason: "x".into() }; + assert_eq!(Status::of(&u), Status::Unknown); + assert_ne!(Status::of(&u), Status::of(&ProjectState::Stopped)); + } +} diff --git a/apps/desktop/ui/src/tokens.rs b/apps/desktop/ui/src/tokens.rs new file mode 100644 index 000000000..1a5365599 --- /dev/null +++ b/apps/desktop/ui/src/tokens.rs @@ -0,0 +1,321 @@ +//! Design v1 tokens: type, spacing, radii, borders and colour, light and dark. +//! +//! Every value here is named in `apps/desktop/DESIGN-V1.md`; change both +//! together. Components read tokens, never raw numbers or gpui-kit theme +//! colours, so the window keeps one look whatever gpui-kit's defaults are. + +use gpui_kit::{Hsla, Pixels, Rgba, px, rgb, rgba}; + +/// Light or dark. The app follows the system unless `--theme` says otherwise. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Light, + Dark, +} + +/// One step of the type scale: size, line height and weight. +#[derive(Debug, Clone, Copy)] +pub struct TypeStep { + pub size: Pixels, + pub line: Pixels, + pub weight: gpui_kit::FontWeight, +} + +const fn step(size: f32, line: f32, weight: gpui_kit::FontWeight) -> TypeStep { + TypeStep { + size: px(size), + line: px(line), + weight, + } +} + +/// Type scale. UI text uses the platform UI font (SF Pro on macOS); code, +/// URLs, routes and logs use the platform monospace font. +pub mod text { + use super::{TypeStep, step}; + use gpui_kit::FontWeight; + + /// Project name in the detail header. + pub const TITLE: TypeStep = step(17., 22., FontWeight::SEMIBOLD); + /// Empty and error pane headline. + pub const HEADLINE: TypeStep = step(15., 20., FontWeight::SEMIBOLD); + /// Body: rows, tabs, buttons, table cells. + pub const BODY: TypeStep = step(13., 18., FontWeight::NORMAL); + /// Selected row, selected tab, window title. + pub const BODY_STRONG: TypeStep = step(13., 18., FontWeight::MEDIUM); + /// Captions, table header, pills, status bar. + pub const SMALL: TypeStep = step(12., 16., FontWeight::NORMAL); + pub const SMALL_STRONG: TypeStep = step(12., 16., FontWeight::MEDIUM); + /// URLs, routes and log lines (monospace). + pub const MONO: TypeStep = step(12., 18., FontWeight::NORMAL); + /// Count inside a pill, keyboard hint. + pub const MICRO: TypeStep = step(11., 14., FontWeight::MEDIUM); +} + +/// 4-point spacing scale. +pub mod space { + use gpui_kit::{Pixels, px}; + pub const S1: Pixels = px(2.); + pub const S2: Pixels = px(4.); + pub const S3: Pixels = px(6.); + pub const S4: Pixels = px(8.); + pub const S5: Pixels = px(10.); + pub const S6: Pixels = px(12.); + pub const S7: Pixels = px(16.); + pub const S8: Pixels = px(20.); + pub const S9: Pixels = px(24.); +} + +/// Fixed sizes. +pub mod size { + use gpui_kit::{Pixels, px}; + pub const TITLE_BAR: Pixels = px(38.); + pub const STATUS_BAR: Pixels = px(26.); + pub const SIDEBAR: Pixels = px(208.); + pub const ROW: Pixels = px(28.); + pub const TABLE_ROW: Pixels = px(32.); + pub const CONTROL: Pixels = px(26.); + pub const DOT: Pixels = px(8.); + pub const ICON: Pixels = px(14.); + pub const ICON_ROW: Pixels = px(15.); + pub const SEARCH_W: Pixels = px(240.); + /// Leaves room for the macOS traffic lights in the title bar. + pub const TRAFFIC_LIGHTS: Pixels = px(76.); +} + +/// Corner radii. +pub mod radius { + use gpui_kit::{Pixels, px}; + pub const CONTROL: Pixels = px(6.); + pub const PILL: Pixels = px(5.); + pub const PANEL: Pixels = px(8.); + pub const WINDOW: Pixels = px(12.); +} + +/// Hairline border width: one device pixel on a 2x display. +pub const HAIRLINE: Pixels = px(0.5); + +/// Colour tokens for one mode. +#[derive(Debug, Clone, Copy)] +pub struct Palette { + pub mode: Mode, + /// Window and detail pane. + pub surface: Hsla, + /// Sidebar, title bar and status bar. + pub chrome: Hsla, + /// Hover, selected row, selected tab, log block, neutral pills. + pub surface_1: Hsla, + /// Pressed or selected-and-hovered. + pub surface_2: Hsla, + pub border: Hsla, + pub border_strong: Hsla, + pub text_primary: Hsla, + pub text_secondary: Hsla, + pub text_muted: Hsla, + pub accent: Hsla, + pub states: States, +} + +/// Dot, pill background and pill text for each state. +#[derive(Debug, Clone, Copy)] +pub struct StateColors { + pub dot: Hsla, + pub bg: Hsla, + pub fg: Hsla, +} + +#[derive(Debug, Clone, Copy)] +pub struct States { + pub running: StateColors, + pub busy: StateColors, + pub degraded: StateColors, + pub failed: StateColors, + pub stopped: StateColors, + pub unknown: StateColors, +} + +fn c(v: Rgba) -> Hsla { + v.into() +} + +impl Palette { + #[must_use] + pub fn for_mode(mode: Mode) -> Self { + match mode { + Mode::Light => Self::light(), + Mode::Dark => Self::dark(), + } + } + + #[must_use] + pub fn light() -> Self { + Self { + mode: Mode::Light, + surface: c(rgb(0xFFFFFF)), + chrome: c(rgb(0xF7F7F5)), + surface_1: c(rgb(0xEFEFEC)), + surface_2: c(rgb(0xE6E6E2)), + border: c(rgba(0x0000_0017)), + border_strong: c(rgba(0x0000_002E)), + text_primary: c(rgb(0x1C1C1A)), + text_secondary: c(rgb(0x5C5B57)), + text_muted: c(rgb(0x8B8A85)), + accent: c(rgb(0x2F6FDB)), + states: States { + running: StateColors { + dot: c(rgb(0x1D9E75)), + bg: c(rgb(0xE2F3EC)), + fg: c(rgb(0x146B50)), + }, + busy: StateColors { + dot: c(rgb(0x378ADD)), + bg: c(rgb(0xE5EEFA)), + fg: c(rgb(0x235E9E)), + }, + degraded: StateColors { + dot: c(rgb(0xEF9F27)), + bg: c(rgb(0xFBEFDA)), + fg: c(rgb(0x8A5608)), + }, + failed: StateColors { + dot: c(rgb(0xE24B4A)), + bg: c(rgb(0xFBE6E5)), + fg: c(rgb(0xA82E2D)), + }, + stopped: StateColors { + dot: c(rgb(0xBDBCB6)), + bg: c(rgb(0xEFEFEC)), + fg: c(rgb(0x5C5B57)), + }, + unknown: StateColors { + dot: c(rgb(0x9C9B95)), + bg: c(rgb(0xEFEFEC)), + fg: c(rgb(0x5C5B57)), + }, + }, + } + } + + #[must_use] + pub fn dark() -> Self { + Self { + mode: Mode::Dark, + surface: c(rgb(0x1B1B1A)), + chrome: c(rgb(0x161615)), + surface_1: c(rgb(0x272725)), + surface_2: c(rgb(0x31312E)), + border: c(rgba(0xFFFF_FF14)), + border_strong: c(rgba(0xFFFF_FF26)), + text_primary: c(rgb(0xEDEDEA)), + text_secondary: c(rgb(0xA9A8A2)), + text_muted: c(rgb(0x7B7A75)), + accent: c(rgb(0x6EA2F5)), + states: States { + running: StateColors { + dot: c(rgb(0x2DB386)), + bg: c(rgba(0x1D9E_7530)), + fg: c(rgb(0x6FD3AE)), + }, + busy: StateColors { + dot: c(rgb(0x4C98E6)), + bg: c(rgba(0x378A_DD30)), + fg: c(rgb(0x8DBDF0)), + }, + degraded: StateColors { + dot: c(rgb(0xF0A83C)), + bg: c(rgba(0xEF9F_272E)), + fg: c(rgb(0xF3C27A)), + }, + failed: StateColors { + dot: c(rgb(0xEC5D5C)), + bg: c(rgba(0xE24B_4A30)), + fg: c(rgb(0xF29A99)), + }, + stopped: StateColors { + dot: c(rgb(0x5E5D59)), + bg: c(rgb(0x272725)), + fg: c(rgb(0xA9A8A2)), + }, + unknown: StateColors { + dot: c(rgb(0x7B7A75)), + bg: c(rgb(0x272725)), + fg: c(rgb(0xA9A8A2)), + }, + }, + } + } +} + +/// Fallback terminal theme, used when the user has no Ghostty config. The +/// panel is dark in both app modes (as in the approved mockup); a Ghostty +/// theme replaces every value here. +#[derive(Debug, Clone, Copy)] +pub struct TermPalette { + pub bg: Hsla, + pub fg: Hsla, + pub dim: Hsla, + /// Tab strip and its separators. + pub strip: Hsla, + pub strip_border: Hsla, + pub tab_fg: Hsla, + pub green: Hsla, + pub bright_green: Hsla, + pub blue: Hsla, + pub magenta: Hsla, + pub yellow: Hsla, + pub red: Hsla, + pub cyan: Hsla, + pub cursor: Hsla, + /// Env badge: non-default overlay (warning) and default overlay. + pub badge_warn_bg: Hsla, + pub badge_warn_fg: Hsla, + pub badge_bg: Hsla, + pub badge_fg: Hsla, +} + +impl TermPalette { + #[must_use] + pub fn fallback() -> Self { + Self { + bg: c(rgb(0x1C1C1A)), + fg: c(rgb(0xE8E6DC)), + dim: c(rgb(0x8B8A85)), + strip: c(rgb(0x2A2A28)), + strip_border: c(rgb(0x3A3A37)), + tab_fg: c(rgb(0xB4B2A9)), + green: c(rgb(0x5DCAA5)), + bright_green: c(rgb(0x97C459)), + blue: c(rgb(0x85B7EB)), + magenta: c(rgb(0xAFA9EC)), + yellow: c(rgb(0xEF9F27)), + red: c(rgb(0xF07B7A)), + cyan: c(rgb(0x6CCFD9)), + cursor: c(rgb(0xE8E6DC)), + badge_warn_bg: c(rgb(0x633806)), + badge_warn_fg: c(rgb(0xFAC775)), + badge_bg: c(rgb(0x3A3A37)), + badge_fg: c(rgb(0xD3D1C7)), + } + } +} + +/// Terminal panel sizes. +pub mod panel { + use gpui_kit::{Pixels, px}; + /// Drag handle on the panel's top edge. + pub const HANDLE: Pixels = px(9.); + pub const GRIP_W: Pixels = px(36.); + pub const GRIP_H: Pixels = px(3.); + /// Tab strip height (also the collapsed panel height). + pub const STRIP: Pixels = px(32.); + /// Default height as a fraction of the content area. + pub const DEFAULT_FRACTION: f32 = 0.5; + /// Terminal text inset. + pub const PAD_X: Pixels = px(14.); + pub const PAD_Y: Pixels = px(10.); + /// Fallback terminal font size and line height. + pub const FONT: Pixels = px(12.5); + pub const LINE: Pixels = px(19.); + /// New-tab menu: longer service lists scroll. + pub const MENU_MAX_H: Pixels = px(300.); +} diff --git a/apps/desktop/ui/src/window.rs b/apps/desktop/ui/src/window.rs new file mode 100644 index 000000000..50972c6c9 --- /dev/null +++ b/apps/desktop/ui/src/window.rs @@ -0,0 +1,851 @@ +//! The main window composed from design v1 components. +//! +//! [`MainWindow`] renders a [`WindowData`]: the same list and detail view +//! models the current app renders, so the gallery can be judged against the +//! live window without touching it. It runs no action. + +use std::collections::HashMap; + +use gpui_kit::{ + AnyElement, Context, Div, IntoElement, ParentElement, Render, SharedString, Styled, Window, + div, prelude::*, +}; +use hack_desktop_core::fixtures::{self, Fixture}; +use hack_desktop_core::models::LogEntry; +use hack_desktop_core::source::{DaemonRecovery, SourceError}; +use hack_desktop_core::state::{Action, ProjectState}; +use hack_desktop_core::viewmodel::{AppModel, Banner, DetailView, Event, ListView, Phase, Row}; + +use crate::components::{ + BannerTone, ServiceRow, StyledExt as _, Ui, action_button, always_on_switch, banner, caption, + count_pill, dot, empty_pane, machine_row, neutral_pill, pill_tabs, primary_button, project_row, + services_table, skeleton, state_pill, status_bar, title_bar, +}; +use crate::icons::Glyph; +use crate::panel::{Ansi, PanelState, PanelView, Span, TabKind, span, terminal_panel}; +use crate::status::{Status, project_label, service_label}; +use crate::tokens::{Mode, Palette, TermPalette, size, space, text}; + +/// Per-service CPU and memory, when known. The core does not read usage yet; +/// the gallery passes labelled sample values (see DESIGN-V1.md). +pub type Usage = HashMap; + +/// Everything the window shows. +pub struct WindowData { + pub list: ListView, + pub detail: Option, + pub tail: Result, String>, + pub recovery: Option, + pub engine_observed: bool, + pub usage: Usage, + /// Routing and DNS state, when Doctor has reported routing checks. + pub routing: Option, +} + +impl WindowData { + /// The window's data for one fixture, built exactly as the app builds it. + #[must_use] + pub fn from_fixture(f: Fixture) -> Self { + let mut model = AppModel::new(); + model.apply(Event::Refreshed(fixtures::snapshot(f))); + model.apply(Event::Select(fixtures::selected(f).to_owned())); + let mut tail = Ok(fixtures::log_tail(f)); + if matches!(f, Fixture::DaemonDown | Fixture::DaemonMismatch) { + let mismatch = f == Fixture::DaemonMismatch; + model.apply(Event::Refreshed(Err(SourceError::DaemonUnavailable { + cli: fixtures::CLI_VERSION.into(), + daemon: mismatch.then(|| "4.3.0".into()), + message: if mismatch { + "hackd v4.3.0, CLI v5.0.0: restart hackd" + } else { + "hackd is off or unreachable: start hackd" + } + .into(), + }))); + tail = Err("Project status could not be read".into()); + } + if let Some(action) = fixtures::local(f).in_flight + && let Some(name) = model.detail().map(|d| d.name) + { + model.apply(Event::ActionStarted { + project: name, + action, + }); + } + let routing = fixtures::snapshot(f) + .ok() + .and_then(|s| s.doctor) + .and_then(|d| { + routing_status(d.checks.iter().map(|c| (c.id.as_str(), c.status.as_str()))) + }); + let detail = model.detail(); + let usage = detail + .as_ref() + .map(|d| sample_usage(&d.state, d.services.iter().map(|s| (&s.name, s.running)))) + .unwrap_or_default(); + Self { + list: model.list(), + detail, + tail, + recovery: model.daemon_recovery(), + engine_observed: model.engine_observed(), + usage, + routing, + } + } +} + +/// Machine-scope Doctor checks in the `routing_dns` and `certificates` areas +/// of Doctor's canonical classification (`MACHINE_AREAS` in +/// `src/lib/doctor-metadata.ts`), without optional tool probes. A test keeps +/// this list equal to that table. +const ROUTING_CHECKS: [&str; 14] = [ + "caddy local ca", + "coredns forwarding", + "global files", + "host tls trust", + "linux dns route", + "local ca trust", + "native ca repair", + "native hack.local repair", + "native hack.local route", + "native root trust", + "proxy network", + "proxy ports", + "superseded caddy roots", + "dnsmasq:53", +]; +/// Prefixes Doctor classifies as `routing_dns`. +const ROUTING_PREFIXES: [&str; 5] = ["network:", "resolver:", "dnsmasq.conf:", "dnsmasq:", "dns:"]; + +fn is_routing_check(id: &str) -> bool { + let id = id.trim().to_ascii_lowercase(); + ROUTING_CHECKS.contains(&id.as_str()) || ROUTING_PREFIXES.iter().any(|p| id.starts_with(p)) +} + +/// Routing and DNS state from Doctor's routing checks (worst wins). `None` +/// when Doctor has reported none of them: the row shows a ring. The live app +/// needs the core to expose this (follow-up); a structured category from +/// Doctor would replace the name list. +#[must_use] +pub fn routing_status<'a>(checks: impl Iterator) -> Option { + let rank = |s: Status| match s { + Status::Failed => 3, + Status::Degraded => 2, + Status::Unknown => 1, + _ => 0, + }; + let mut worst: Option = None; + for (id, status) in checks { + if !is_routing_check(id) { + continue; + } + let s = match status { + "ok" => Status::Running, + "warn" => Status::Degraded, + "error" => Status::Failed, + _ => Status::Unknown, + }; + if worst.is_none_or(|w| rank(s) > rank(w)) { + worst = Some(s); + } + } + worst +} + +/// Sample CPU and memory for running services, so the table can be judged +/// at its intended density. Placeholder values, not measurements. +fn sample_usage<'a>( + state: &ProjectState, + services: impl Iterator, +) -> Usage { + const SAMPLES: [(&str, &str); 6] = [ + ("3%", "212 MB"), + ("1%", "148 MB"), + ("0%", "96 MB"), + ("0%", "31 MB"), + ("2%", "64 MB"), + ("0%", "18 MB"), + ]; + if matches!(state, ProjectState::Stopped | ProjectState::Unknown { .. }) { + return Usage::new(); + } + services + .filter(|(_, running)| *running) + .enumerate() + .map(|(i, (name, _))| { + let (cpu, mem) = SAMPLES[i % SAMPLES.len()]; + (name.clone(), (cpu.to_owned(), mem.to_owned())) + }) + .collect() +} + +/// The design v1 main window. +pub struct MainWindow { + pub data: WindowData, + pub mode: Mode, + /// Draw stand-in traffic lights (offscreen macOS-style renders). + pub traffic_lights: bool, + pub tab: usize, + /// Terminal panel state and tabs. Logs is always tab 0. + pub panel: PanelState, + pub panel_tabs: Vec, + pub panel_selected: usize, + pub panel_menu: bool, + /// Terminal font family (from the user's Ghostty config, else mono). + pub term_font: Option, +} + +impl MainWindow { + /// The window for `data` with the panel half open on its Logs tab. + #[must_use] + pub fn new(data: WindowData, mode: Mode) -> Self { + Self { + data, + mode, + traffic_lights: false, + tab: 0, + panel: PanelState::half(), + panel_tabs: default_tabs(), + panel_selected: 0, + panel_menu: false, + term_font: None, + } + } + + fn ui(&self, cx: &gpui_kit::App) -> Ui { + use gpui_kit::component::ActiveTheme as _; + let t = cx.theme(); + Ui { + p: Palette::for_mode(self.mode), + font: t.font_family.clone(), + mono: t.mono_font_family.clone(), + } + } + + fn sidebar(&self, ui: &Ui) -> Div { + let p = ui.p; + let list = &self.data.list; + let mut projects = div() + .id("projects") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .flex() + .flex_col() + .px(space::S3) + .pb(space::S4); + match &list.phase { + Phase::Loading => { + projects = projects.child(caption("Projects", ui)); + for w in [120., 96., 132.] { + projects = projects.child( + div() + .h(size::ROW) + .px(space::S5) + .flex() + .items_center() + .gap(space::S4) + .child(div().size(size::DOT).rounded_full().bg(p.surface_1)) + .child(skeleton(w, ui)), + ); + } + } + Phase::Ready => { + let group = |name: &'static str, rows: &[Row], mut col: gpui_kit::Stateful
| { + if rows.is_empty() { + return col; + } + col = col.child(caption(name, ui)); + for r in rows { + col = col.child(project_row( + SharedString::from(format!("project-{}", r.name)), + r.name.clone(), + Status::of(&r.state), + r.always_on, + r.selected, + ui, + )); + } + col + }; + let all_unknown = !list.stopped.is_empty() + && list + .stopped + .iter() + .all(|r| matches!(r.state, ProjectState::Unknown { .. })); + projects = group("Running", &list.running, projects); + projects = group( + if all_unknown { + "Not observed" + } else { + "Stopped" + }, + &list.stopped, + projects, + ); + } + Phase::Empty | Phase::CliMissing | Phase::Error { .. } => {} + } + let doctor_trailing: AnyElement = match (list.doctor_known, list.doctor_count) { + (true, n) if n > 0 => count_pill(n, ui).into_any_element(), + (true, _) => dot(Status::Running, ui).into_any_element(), + (false, _) => dot(Status::Unknown, ui).into_any_element(), + }; + let machine = div() + .flex_none() + .flex() + .flex_col() + .px(space::S3) + .pb(space::S4) + .child(caption("Machine", ui)) + // A real dot when Doctor reported routing; a ring until then. + .child(machine_row( + "routing", + Glyph::Globe, + "Routing and DNS", + dot(self.data.routing.unwrap_or(Status::Unknown), ui), + ui, + )) + .child(machine_row( + "doctor", + Glyph::Stethoscope, + "Doctor", + doctor_trailing, + ui, + )); + div() + .w(size::SIDEBAR) + .h_full() + .flex_none() + .flex() + .flex_col() + .bg(p.chrome) + .hairline_r(p.border) + .child(projects) + .child(machine) + } + + fn banners(&self, ui: &Ui) -> Div { + let mut col = div().flex().flex_col(); + let mut recovery_shown = false; + for b in &self.data.list.banners { + let (tone, msg, action) = match b { + Banner::DaemonOff => ( + BannerTone::Warn, + "hackd is not running. Project states are not observed.".to_owned(), + Some((Glyph::Power, "Start hackd")), + ), + Banner::DaemonMismatch { daemon, cli } => ( + BannerTone::Warn, + format!("hackd {daemon} is running; this CLI is {cli}."), + Some((Glyph::Restart, "Restart hackd")), + ), + Banner::EngineDown { error } => ( + BannerTone::Error, + format!("Docker is not reachable: {error}"), + None, + ), + Banner::DaemonReadFailed { error } => (BannerTone::Warn, error.clone(), None), + // With a daemon banner, the stale banner repeats its cause. + // Otherwise it is the only sign the latest read failed. + Banner::Stale { .. } if self.daemon_banner() => continue, + Banner::Stale { error } => ( + BannerTone::Warn, + format!("Showing last known state. The latest read failed: {error}"), + None, + ), + }; + let action = if action.is_some() && self.data.recovery.is_some() && !recovery_shown { + recovery_shown = true; + action + } else { + None + }; + col = col.child(banner(tone, msg, action, ui)); + } + col + } + + fn header(&self, d: &DetailView, compact: bool, ui: &Ui) -> Div { + let p = ui.p; + let status = Status::of(&d.state); + let mut actions = div().flex().items_center().gap(space::S3); + for a in [Action::Open, Action::Restart, Action::Stop, Action::Start] { + if !d.actions.contains(&a) { + continue; + } + let enabled = !d.busy; + let (glyph, id) = match a { + Action::Open => (Glyph::ExternalLink, "open"), + Action::Restart => (Glyph::Restart, "restart"), + Action::Stop => (Glyph::Stop, "stop"), + Action::Start => (Glyph::Play, "start"), + }; + actions = actions.child(if a == Action::Start && enabled { + primary_button(id, glyph, a.label(), ui) + } else { + action_button(id, glyph, a.label(), enabled, ui) + }); + } + let reason = match (&d.state, &d.last_error) { + (_, Some(e)) => Some(format!("Last action failed: {e}")), + (ProjectState::NeedsAttention { reason }, None) => Some(reason.clone()), + // A daemon banner already explains an unobserved project. + (ProjectState::Unknown { reason }, None) if !self.daemon_banner() => { + Some(reason.clone()) + } + _ => None, + }; + let title_row = div() + .flex() + .items_center() + .gap(space::S4) + .child( + div() + .type_step(if compact { + text::BODY_STRONG + } else { + text::TITLE + }) + .text_color(p.text_primary) + .child(d.name.clone()), + ) + .child(state_pill(status, project_label(&d.state), ui)) + .child(neutral_pill(d.backend.clone(), ui)) + .child(div().flex_1()) + .child(actions); + if compact { + return title_row; + } + div() + .flex() + .flex_col() + .gap(space::S4) + .child(title_row) + .child( + div() + .flex() + .items_center() + .gap(space::S5) + .type_step(text::BODY) + .text_color(p.text_secondary) + .children(d.url.clone().map(|u| { + div() + .font_family(ui.mono.clone()) + .type_step(text::MONO) + .text_color(p.text_secondary) + .child(u) + })) + .child(div().flex_1()) + .child(always_on_switch(d.always_on, false, ui)), + ) + .children(reason.map(|r| { + let c = if matches!(status, Status::Failed) { + p.states.failed.fg + } else { + p.text_secondary + }; + div().type_step(text::BODY).text_color(c).child(r) + })) + .children(d.note.clone().map(|n| { + div() + .type_step(text::SMALL) + .text_color(p.text_muted) + .child(n) + })) + } + + fn detail(&self, d: &DetailView, ui: &Ui) -> Div { + let rows: Vec = d + .services + .iter() + .map(|s| { + let usage = self.data.usage.get(&s.name); + ServiceRow { + name: s.name.clone(), + status: Status::of_service(s), + state: service_label(s), + cpu: usage.map(|u| u.0.clone()), + memory: usage.map(|u| u.1.clone()), + route: s.route.clone().ok_or_else(|| "internal".to_owned()), + } + }) + .collect(); + let max = self.panel == PanelState::Maximized; + let panel = PanelView { + state: self.panel, + tabs: self.panel_tabs.clone(), + selected: self.panel_selected, + lines: self.panel_lines(), + cursor: self + .panel_tabs + .get(self.panel_selected) + .is_some_and(|t| *t != TabKind::Logs), + menu: self.panel_menu.then(|| new_tab_menu(d)), + font: self.term_font.clone().unwrap_or_else(|| ui.mono.clone()), + term: TermPalette::fallback(), + }; + div() + .flex_1() + .min_w_0() + .min_h_0() + .flex() + .flex_col() + .child( + div() + .px(space::S8) + .pt(if max { space::S5 } else { space::S7 }) + .pb(space::S5) + .when(max, |d| d.hairline_b(ui.p.border)) + .child(self.header(d, max, ui)), + ) + .when(!max, |col| { + col.child(div().px(space::S7).pb(space::S3).child(pill_tabs( + &["Services", "Env", "Activity"], + self.tab, + ui, + ))) + .child( + div() + .id("detail-body") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px(space::S8) + .pb(space::S5) + .child(services_table(&rows, ui)), + ) + }) + .child(terminal_panel(&panel, ui)) + } + + /// Visible lines of the selected panel tab. Logs come from the core's + /// log tail; shell tabs show a labelled sample until the `term` crate + /// is wired in (PR 3). + fn panel_lines(&self) -> Vec> { + match self.panel_tabs.get(self.panel_selected) { + Some(TabKind::Logs) | None => log_lines(&self.data.tail), + Some(TabKind::ContainerShell { service, .. }) => vec![ + vec![span("/app # ", Ansi::Fg), span("ls", Ansi::Fg)], + vec![ + span("dist ", Ansi::Blue), + span("node_modules ", Ansi::Blue), + span("package.json server.js", Ansi::Fg), + ], + vec![ + span("/app # ", Ansi::Fg), + span( + format!("env | grep -c ^{}", service.to_uppercase()), + Ansi::Fg, + ), + ], + vec![span("3", Ansi::Fg)], + vec![span("/app # ", Ansi::Fg)], + ], + Some(TabKind::HostShell { .. } | TabKind::HostShellWithEnv { .. }) => { + let prompt = |extra: bool| { + let mut l = vec![ + span("~/dev/storefront ", Ansi::Green), + span("on \u{e0a0} main ", Ansi::Magenta), + ]; + if extra { + l.push(span("via \u{e718} v22 ", Ansi::Yellow)); + } + l + }; + vec![ + prompt(true), + vec![ + span("❯ ", Ansi::Green), + span("bun run db:migrate", Ansi::Fg), + ], + vec![span("applying 0042_add_orders_index.sql", Ansi::Dim)], + vec![ + span("✓ ", Ansi::BrightGreen), + span("1 migration applied in 212 ms", Ansi::Fg), + ], + prompt(false), + vec![span("❯ ", Ansi::Green)], + ] + } + } + } + + fn daemon_banner(&self) -> bool { + self.data + .list + .banners + .iter() + .any(|b| matches!(b, Banner::DaemonOff | Banner::DaemonMismatch { .. })) + } + + fn status_items(&self) -> Vec<(Option, String)> { + let list = &self.data.list; + if list.cli_version.is_none() { + return vec![(None, "not connected".into())]; + } + let c = list.counts; + let always_on = list + .running + .iter() + .chain(&list.stopped) + .filter(|r| r.always_on) + .count(); + let mut items = vec![]; + if c.unknown > 0 && c.running == 0 { + items.push((None, format!("{} not observed", c.unknown))); + } else { + items.push((None, format!("{} running", c.running))); + } + items.push((None, format!("{always_on} always on"))); + let banners = &list.banners; + let engine = if !self.data.engine_observed { + (Status::Unknown, "engine unknown") + } else if banners + .iter() + .any(|b| matches!(b, Banner::EngineDown { .. })) + { + (Status::Failed, "engine down") + } else { + (Status::Running, "engine ok") + }; + items.push((Some(engine.0), engine.1.into())); + let daemon = if banners.iter().any(|b| matches!(b, Banner::DaemonOff)) { + Some((Status::Stopped, "hackd off")) + } else if banners + .iter() + .any(|b| matches!(b, Banner::DaemonMismatch { .. })) + { + Some((Status::Degraded, "hackd mismatch")) + } else { + None + }; + if let Some((s, l)) = daemon { + items.push((Some(s), l.into())); + } + items + } +} + +impl Render for MainWindow { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui = self.ui(cx); + let p = ui.p; + let list = &self.data.list; + let main: AnyElement = match &list.phase { + Phase::CliMissing => empty_pane( + Glyph::CircleAlert, + "Hack CLI not found", + "The app runs the hack command for everything. Install it, then reopen the app.", + Some("brew install hack-dance/tap/hack"), + &ui, + ) + .into_any_element(), + Phase::Error { code, message } => empty_pane( + Glyph::CircleAlert, + "Could not read projects", + format!("{message} ({code})"), + Some("hack projects --json"), + &ui, + ) + .into_any_element(), + Phase::Empty => empty_pane( + Glyph::Folder, + "No projects yet", + "Set up a repository with Hack and it shows up here.", + Some("hack init"), + &ui, + ) + .into_any_element(), + Phase::Loading => { + empty_pane(Glyph::Loader, "Loading projects…", "", None, &ui).into_any_element() + } + Phase::Ready => match &self.data.detail { + Some(d) => self.detail(d, &ui).into_any_element(), + None => { + empty_pane(Glyph::Folder, "Select a project", "", None, &ui).into_any_element() + } + }, + }; + div() + .size_full() + .flex() + .flex_col() + .bg(p.surface) + .text_color(p.text_primary) + .font_family(ui.font.clone()) + .child(title_bar(self.traffic_lights, &ui)) + .child( + div() + .flex_1() + .min_h_0() + .flex() + .child(self.sidebar(&ui)) + .child( + div() + .flex_1() + .h_full() + .min_w_0() + .flex() + .flex_col() + .child(self.banners(&ui)) + .child(main), + ), + ) + .child(status_bar( + self.status_items(), + list.cli_version.clone().map(|v| format!("hack {v}")), + &ui, + )) + } +} + +/// Logs tab content from the core's log entries. The live tab shows the +/// stream's bytes exactly as printed (ANSI kept); this stands in with the +/// colours a typical service prints. +fn log_lines(tail: &Result, String>) -> Vec> { + let entries = match tail { + Ok(e) if e.is_empty() => { + return vec![vec![span("No log lines yet. Following…", Ansi::Dim)]]; + } + Ok(e) => e, + Err(why) => return vec![vec![span(why.clone(), Ansi::Dim)]], + }; + let services = [Ansi::Cyan, Ansi::Magenta, Ansi::Blue, Ansi::Green]; + entries + .iter() + .map(|e| { + let svc = e.service.clone().unwrap_or_default(); + let color = services[svc.bytes().map(usize::from).sum::() % services.len()]; + let msg = match e.level.as_deref() { + Some("error" | "fatal") => Ansi::Red, + Some("warn" | "warning") => Ansi::Yellow, + _ => Ansi::Fg, + }; + vec![ + span( + format!("{} ", e.timestamp.clone().unwrap_or_default()), + Ansi::Dim, + ), + span(format!("{svc:<7}| "), color), + span(e.message.clone(), msg), + ] + }) + .collect() +} + +/// The "+" menu: a shell in each running service, a host shell, and a host +/// shell with an env overlay. An empty string is a separator. +#[must_use] +pub fn new_tab_menu(d: &DetailView) -> Vec { + let mut items: Vec = d + .services + .iter() + .filter(|s| s.running) + .map(|s| format!("Shell in {}", s.name)) + .collect(); + if !items.is_empty() { + items.push(String::new()); + } + items.push("Host shell".into()); + items.push("Host shell with env…".into()); + items +} + +/// Default panel tabs for a project: Logs, then any open sessions. +#[must_use] +pub fn default_tabs() -> Vec { + vec![TabKind::Logs] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn routing_dot_is_known_only_from_routing_checks() { + assert_eq!(routing_status([("docker daemon", "ok")].into_iter()), None); + assert_eq!( + routing_status([("caddy (optional)", "warn")].into_iter()), + None, + "optional tool probes are not routing state" + ); + assert_eq!( + routing_status([("caddy local ca", "warn"), ("dns:hack", "ok")].into_iter()), + Some(Status::Degraded) + ); + assert_eq!( + routing_status([("dns:hack", "ok")].into_iter()), + Some(Status::Running) + ); + assert_eq!( + routing_status([("dns:hack", "ok"), ("local ca trust", "error")].into_iter()), + Some(Status::Failed) + ); + assert_eq!( + routing_status( + [("resolver:hack", "ok"), ("native hack.local route", "warn")].into_iter() + ), + Some(Status::Degraded) + ); + assert_eq!( + routing_status([("proxy ports", "error"), ("coredns forwarding", "ok")].into_iter()), + Some(Status::Failed) + ); + } + + #[test] + fn every_routing_area_check_counts() { + for id in [ + "linux dns route", + "proxy network", + "host tls trust", + "network:hack subnet", + ] { + assert_eq!( + routing_status([("dns:hack", "ok"), (id, "error")].into_iter()), + Some(Status::Failed), + "{id}" + ); + } + } + + /// The list must match Doctor's canonical machine classification. + #[test] + fn routing_list_matches_doctor_metadata() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../src/lib/doctor-metadata.ts" + ); + let src = std::fs::read_to_string(path).expect("read doctor-metadata.ts"); + let start = src.find("const MACHINE_AREAS").expect("MACHINE_AREAS"); + let block = &src[start..start + src[start..].find("};").expect("end of table")]; + let mut doctor: Vec = block + .lines() + .filter(|l| l.contains("\"routing_dns\"") || l.contains("\"certificates\"")) + .filter_map(|l| l.trim().split('"').nth(1).map(str::to_owned)) + .filter(|id| !id.contains("(optional)")) + .collect(); + doctor.sort(); + let mut ours: Vec = ROUTING_CHECKS + .iter() + .filter(|id| !id.contains(':')) + .map(|s| (*s).to_owned()) + .collect(); + ours.sort(); + assert_eq!( + ours, doctor, + "update ROUTING_CHECKS from doctor-metadata.ts" + ); + } + + #[test] + fn new_tab_menu_lists_running_services_then_host_shells() { + let d = WindowData::from_fixture(Fixture::Degraded).detail.unwrap(); + let m = new_tab_menu(&d); + assert!(m.iter().any(|i| i == "Shell in api")); + assert!(!m.iter().any(|i| i == "Shell in worker"), "worker is down"); + assert_eq!(m.last().map(String::as_str), Some("Host shell with env…")); + } +}