diff --git a/DOCS/RUNNING.md b/DOCS/RUNNING.md index c36a1ca..14bae32 100644 --- a/DOCS/RUNNING.md +++ b/DOCS/RUNNING.md @@ -163,6 +163,11 @@ S shutdown ### M4 — CLI - `spacesh status --json` против живого демона; `spacesh notify` без демона → молча `exit 0`; `spacesh completions zsh` печатает скрипт. +### SP1/SP3/SP4 — health, поиск, zoom +- **Health (SP1):** футер сайдбара показывает `spaceshd · live` с зелёной точкой и аптайм (`3d 4h`); версия демона — в tooltip. При падении демона точка сереет, текст `offline`, аптайм пропадает. +- **Поиск (SP3):** `⌘F` (или клик по пилюле «Search scrollback» над гридом) открывает строку поиска для активной панели. Печатай запрос и жми `Enter` → совпадения подсвечиваются, `Enter`/`Shift+Enter` — next/prev, счётчик `i/N`, `Esc` или `✕` — закрыть. Повторный `⌘F` при открытой строке — фокус+выделение поля. Поиск идёт по буферу xterm активной панели (scrollback до 10000 строк). +- **Zoom (SP4):** иконка `⤢` в шапке панели разворачивает её на весь грид (панель становится активной); `⤡` возвращает. Состояние персистится в `~/.spacesh/state.json` — переживает рестарт демона. При закрытии развёрнутой панели zoom сбрасывается; если процесс в развёрнутой панели завершился — в карточке «Process exited» есть кнопка «Exit zoom». + --- ## 7. Где что лежит / сброс @@ -203,7 +208,8 @@ rm -rf ~/.spacesh # сбрасывает сокет, лок, state.json, - **Клик по нативному уведомлению** не фокусит конкретную панель (клик по записи в Event Center — фокусит). - **Event Center** — лента хранится в демоне и персистируется в `~/.spacesh/events.json` (переживает перезапуск GUI и холодный рестарт демона). Вкладки `Unread`/`Errors` и бейдж `bell` работают по реальным данным (флаги прочтения на уровне события). По-прежнему не реализованы: каналы Telegram/MAX в футере Event Center (SP5), а также `search`/`settings` и меню аккаунта в топ-баре. - **Статус эфемерен** (work/wait/done/error/idle) — не персистится; после холодного рестарта демона панель `stopped`, статус `idle`. -- Авторизация / личный кабинет / внешние нотификации (Telegram/MAX) / зум / поиск по скроллбэку / diff-вьюер / remote — **не реализованы** (M5/M6/auth, см. `DOCS/MAIN.md`). +- **Поиск по скроллбэку (SP3)** работает в пределах xterm-буфера активной панели (до 10000 строк); поиск по демон-сайд / CLI-сетке (`alacritty_terminal` grid) остаётся задачей будущего. +- Авторизация / личный кабинет / внешние нотификации (Telegram/MAX) / diff-вьюер / remote — **не реализованы** (M5/M6/auth, см. `DOCS/MAIN.md`). --- diff --git a/DOCS/superpowers/plans/2026-06-10-spacesh-sp1-sp3-sp4.md b/DOCS/superpowers/plans/2026-06-10-spacesh-sp1-sp3-sp4.md new file mode 100644 index 0000000..6263ebc --- /dev/null +++ b/DOCS/superpowers/plans/2026-06-10-spacesh-sp1-sp3-sp4.md @@ -0,0 +1,809 @@ +# spacesh SP1 + SP3 + SP4 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace three frontend mocks with real features — daemon health/uptime in the sidebar footer (SP1), working `⌘F` scrollback search on the focused panel (SP3), and persisted single-panel zoom (SP4). + +**Architecture:** SP1 adds a `Cmd::Health` returning version/pid/started_at; the GUI derives a ticking uptime. SP4 stores `zoomed: Option` per workspace (persisted, broadcast on change, auto-cleared on surface removal) and the GUI renders only the zoomed panel when set. SP3 is client-only: xterm.js's `@xterm/addon-search` on the focused panel's terminal, driven by a small search bar, with a module-level registry mapping surfaceId → addon. + +**Tech Stack:** Rust (tokio, serde) for `spacesh-proto`/`spaceshd`; React + TypeScript + Tauri 2 + xterm.js for the app. + +**Design spec:** `DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md` + +--- + +## File Structure + +**SP1** +- Modify `crates/spacesh-proto/src/message.rs` — `Cmd::Health` variant + test. +- Modify `crates/spaceshd/src/server.rs` — capture `started_at_ms`, thread through serve/router/handle_request, `Health` arm, test. +- Modify `app/src-tauri/src/bridge.rs` + `app/src-tauri/src/lib.rs` — `health` command. +- Modify `app/src/socketBridge.ts` — `getHealth`. +- Modify `app/src/App.tsx` — `connected` + `health` state, pass to Sidebar. +- Modify `app/src/Sidebar.tsx` — real footer (live dot, uptime, version tooltip). + +**SP4** +- Modify `crates/spacesh-proto/src/workspace.rs` — `zoomed` on `Workspace` + `WorkspaceView`. +- Modify `crates/spacesh-proto/src/message.rs` — `Cmd::SetZoom` + test. +- Modify `crates/spaceshd/src/registry.rs` — `to_view` includes `zoomed`; `open_workspace` sets `zoomed: None`; `remove_surface` clears stale zoom. +- Modify `crates/spaceshd/src/server.rs` — `SetZoom` arm + test. +- Modify `app/src-tauri/src/bridge.rs` + `lib.rs` — `set_zoom` command. +- Modify `app/src/layoutTypes.ts` — `zoomed` on `WorkspaceView`. +- Modify `app/src/socketBridge.ts` — `setZoom`. +- Modify `app/src/LayoutEngine.tsx` — zoom render + header toggle. +- Modify `app/src/App.tsx` — pass `active.zoomed`. + +**SP3** +- Add dependency `@xterm/addon-search`. +- Create `app/src/searchRegistry.ts` — `Map` register/unregister/get. +- Modify `app/src/TerminalView.tsx` — `scrollback: 10000`, load `SearchAddon`, register. +- Create `app/src/SearchBar.tsx` — overlay search input. +- Modify `app/src/CenterToolbar.tsx` — pill `onOpenSearch` callback. +- Modify `app/src/App.tsx` — `searchOpen` state, `⌘F` handler, render `SearchBar`. + +**Final** +- Modify `DOCS/RUNNING.md` — manual scenarios. + +--- + +# SP1 — Daemon observability + +## Task 1: proto Cmd::Health + +**Files:** Modify `crates/spacesh-proto/src/message.rs` + +- [ ] **Step 1: Add the variant** + +In the `Cmd` enum, immediately before `Status,` add: + +```rust + Health, +``` + +- [ ] **Step 2: Add the test** + +Append to the `tests` module in `message.rs`: + +```rust + #[test] + fn health_cmd_round_trips() { + let env = Envelope::Req { id: 1, cmd: Cmd::Health }; + let j = serde_json::to_string(&env).unwrap(); + assert!(j.contains(r#""cmd":"health""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, env); + } +``` + +- [ ] **Step 3: Run** + +Run: `cargo test -p spacesh-proto message::health_cmd_round_trips` +Expected: PASS. + +> Note: this adds a `Cmd::Health` variant. `cargo build -p spaceshd` will FAIL until Task 2 adds the matching arm (the daemon's `handle_request` match is exhaustive). That is expected — verify only the proto crate in this task; Task 2 restores a green workspace build. + +- [ ] **Step 4: Commit** + +```bash +git add crates/spacesh-proto/src/message.rs +git commit -m "feat(proto): Health command" +``` + +## Task 2: daemon Health handler + +**Files:** Modify `crates/spaceshd/src/server.rs` + +Context: post-SP2, `serve(socket, store, event_store)`; `router(rx, router_tx, exit_tx, state_tx, persister, initial, event_persister, event_initial)`; `handle_request(id, cmd, client, out, reg, subs, clients, router_tx, exit_tx, state_tx, persister, event_log, event_persister)`. We thread a `started_at_ms: u64` through all three. + +- [ ] **Step 1: Capture started_at in `serve` and pass to router** + +In `serve`, just before the `router` is spawned (where `persister`/`initial` are set up), add: + +```rust + let started_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); +``` + +Then add `started_at_ms` as the final argument to the `tokio::spawn(router(...))` call. + +- [ ] **Step 2: Accept it in `router` and pass to `handle_request`** + +Add `started_at_ms: u64,` as the final parameter of `router`. In the `ServerMsg::Request` arm, pass `started_at_ms` as the final argument to `handle_request(...)`. + +- [ ] **Step 3: Accept it in `handle_request`** + +Add `started_at_ms: u64,` as the final parameter of `handle_request` (after `event_persister: &EventPersister,`). + +- [ ] **Step 4: Add the Health arm** + +Immediately before `Cmd::Status => {` add: + +```rust + Cmd::Health => { + let _ = out.send(ok(id, serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "pid": std::process::id(), + "started_at_ms": started_at_ms, + }))).await; + } +``` + +- [ ] **Step 5: Write the integration test** + +Add to the `mod tests` block in `server.rs`, following the existing pattern (tempdir_path, make_event_store(&dir), serve, wait_for_socket, req, res_data): + +```rust + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn health_reports_version_pid_started() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store = make_event_store(&dir); + let sock_for_task = sock.clone(); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; }); + wait_for_socket(&sock).await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; + let r = req(&mut s, 1, Cmd::Health).await; + let d = res_data(&r); + assert!(!d["version"].as_str().unwrap().is_empty()); + assert!(d["pid"].as_u64().unwrap() > 0); + let started = d["started_at_ms"].as_u64().unwrap(); + assert!(started > 0 && started <= now + 1000, "started_at_ms plausible"); + } +``` + +- [ ] **Step 6: Run + commit** + +Run: `cargo test -p spaceshd health_reports_version_pid_started` → PASS. Then `cargo test -p spaceshd` → all pass (kill a stale daemon + `rm -f ~/.spacesh/daemon.lock` if ONLY `lock_is_exclusive_within_process` fails). + +```bash +git add crates/spaceshd/src/server.rs +git commit -m "feat(daemon): Health command (version, pid, started_at)" +``` + +## Task 3: GUI health footer + +**Files:** Modify `app/src-tauri/src/bridge.rs`, `app/src-tauri/src/lib.rs`, `app/src/socketBridge.ts`, `app/src/App.tsx`, `app/src/Sidebar.tsx` + +- [ ] **Step 1: Tauri bridge command** + +In `app/src-tauri/src/bridge.rs`, after the `status` command add: + +```rust +#[tauri::command] +pub async fn health(state: BridgeState<'_>) -> Result { + data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?) +} +``` + +In `app/src-tauri/src/lib.rs`, add `bridge::health,` to the `tauri::generate_handler![...]` list. + +- [ ] **Step 2: socketBridge** + +In `app/src/socketBridge.ts` add: + +```ts +export interface DaemonHealth { version: string; pid: number; started_at_ms: number } + +export async function getHealth(): Promise { + return await invoke("health"); +} +``` + +- [ ] **Step 3: App tracks connected + health** + +In `app/src/App.tsx`: +- Import `getHealth` and the `DaemonHealth` type from `./socketBridge`. +- Add state: + +```tsx + const [health, setHealth] = useState(null); + const [connected, setConnected] = useState(false); +``` + +- In the initial `useEffect`, after `void refresh();`/`void seedEvents();`, add a health fetch that also flips `connected`: + +```tsx + const loadHealth = async () => { + try { setHealth(await getHealth()); setConnected(true); } + catch { setConnected(false); } + }; + void loadHealth(); +``` + +- In the `onDaemonRawEvent("spacesh:disconnected", ...)` handler, also `setConnected(false);`. In the reconnect path call `void loadHealth();` again. (Define `loadHealth` with `useCallback(async () => {...}, [])` so it can be referenced in both places and listed in deps.) +- Pass to Sidebar: ` setWizard(true)} health={health} connected={connected} />` + +- [ ] **Step 4: Sidebar footer** + +In `app/src/Sidebar.tsx`: +- Import: `import { useState, useEffect } from "react";` (extend the existing import) and `import type { DaemonHealth } from "./socketBridge";`. +- Extend props with `health: DaemonHealth | null; connected: boolean;`. +- Add an uptime formatter and a 30s ticker above the `return`: + +```tsx +function fmtUptime(startedMs: number): string { + const s = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; +} +``` + +Inside the component body, add a tick to re-render every 30s: + +```tsx + const [, setTick] = useState(0); + useEffect(() => { + const t = setInterval(() => setTick((n) => n + 1), 30000); + return () => clearInterval(t); + }, []); +``` + +Replace the hardcoded footer block with: + +```tsx +
+ + {connected ? "spaceshd · live" : "spaceshd · offline"} + + {health ? fmtUptime(health.started_at_ms) : ""} +
+``` + +- [ ] **Step 5: Build + commit** + +Run: `cd app && npm run build` → clean. `cd` back. + +```bash +git add app/src-tauri/src/bridge.rs app/src-tauri/src/lib.rs app/src/socketBridge.ts app/src/App.tsx app/src/Sidebar.tsx +git commit -m "feat(app): real daemon health footer (live, uptime, version)" +``` + +--- + +# SP4 — Panel zoom (persisted) + +## Task 4: proto zoom field + SetZoom + +**Files:** Modify `crates/spacesh-proto/src/workspace.rs`, `crates/spacesh-proto/src/message.rs` + +- [ ] **Step 1: Add `zoomed` to Workspace and WorkspaceView** + +In `crates/spacesh-proto/src/workspace.rs`, add to `Workspace` (after the `layout` field): + +```rust + /// The single maximized surface for this workspace, if any. + #[serde(default)] + pub zoomed: Option, +``` + +And to `WorkspaceView` (after its `layout` field): + +```rust + #[serde(default)] + pub zoomed: Option, +``` + +- [ ] **Step 2: Fix the existing workspace test constructors** + +Both `workspace_round_trips_with_empty_layout` (in workspace.rs) and any other literal `Workspace { ... }` / `WorkspaceView { ... }` constructions in the proto crate must add `zoomed: None,`. Update `workspace_round_trips_with_empty_layout`'s `Workspace { ... }` literal to include `zoomed: None,`. + +- [ ] **Step 3: Add Cmd::SetZoom** + +In `crates/spacesh-proto/src/message.rs`, in the `Cmd` enum before `Status,`: + +```rust + SetZoom { + workspace_id: WorkspaceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + surface_id: Option, + }, +``` + +- [ ] **Step 4: Tests** + +Append to `message.rs` tests: + +```rust + #[test] + fn set_zoom_cmd_round_trips() { + let z = Envelope::Req { id: 1, cmd: Cmd::SetZoom { + workspace_id: WorkspaceId("w_1".into()), surface_id: Some(SurfaceId("s_1".into())) } }; + let j = serde_json::to_string(&z).unwrap(); + assert!(j.contains(r#""cmd":"set_zoom""#)); + assert_eq!(serde_json::from_str::(&j).unwrap(), z); + let unz = Envelope::Req { id: 2, cmd: Cmd::SetZoom { + workspace_id: WorkspaceId("w_1".into()), surface_id: None } }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&unz).unwrap()).unwrap(), unz); + } +``` + +Append to `workspace.rs` tests: + +```rust + #[test] + fn workspace_round_trips_with_zoom() { + let w = Workspace { + id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), + group_id: None, order: 0, unread: false, layout: None, + zoomed: Some(SurfaceId("s_1".into())), surfaces: HashMap::new(), + }; + let back: Workspace = serde_json::from_str(&serde_json::to_string(&w).unwrap()).unwrap(); + assert_eq!(back, w); + } +``` + +- [ ] **Step 5: Run + commit** + +Run: `cargo test -p spacesh-proto` → all pass. + +> Note: this adds a `Cmd::SetZoom` variant. `cargo build -p spaceshd` will FAIL until Task 5 adds the matching arm. Expected — verify only the proto crate here; Task 5 restores a green workspace build. + +```bash +git add crates/spacesh-proto/src/workspace.rs crates/spacesh-proto/src/message.rs +git commit -m "feat(proto): workspace zoomed field + SetZoom command" +``` + +## Task 5: daemon zoom state + handler + +**Files:** Modify `crates/spaceshd/src/registry.rs`, `crates/spaceshd/src/server.rs` + +- [ ] **Step 1: Construct and surface `zoomed` in the registry** + +In `crates/spaceshd/src/registry.rs`: +- In `open_workspace`, the `Workspace { ... }` literal: add `zoomed: None,`. +- In `to_view`, the returned `WorkspaceView { ... }`: add `zoomed: w.zoomed.clone(),`. +- In `remove_surface`, inside `if let Some(w) = self.workspaces.get_mut(&ws) { ... }`, after `w.surfaces.remove(sid);` add: + +```rust + if w.zoomed.as_ref() == Some(sid) { w.zoomed = None; } +``` + +- [ ] **Step 2: Registry test for zoom auto-clear** + +Append to `registry.rs` tests: + +```rust + #[test] + fn remove_surface_clears_zoom() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let s1 = r.new_surface_id(); + r.add_surface_spec(&ws, s1.clone(), spec()); + r.workspace_mut(&ws).unwrap().zoomed = Some(s1.clone()); + r.remove_surface(&s1); + assert!(r.workspace(&ws).unwrap().zoomed.is_none()); + } +``` + +- [ ] **Step 3: SetZoom handler in server.rs** + +Immediately before `Cmd::Status => {` (and after the SP1 `Cmd::Health` arm) add: + +```rust + Cmd::SetZoom { workspace_id, surface_id } => { + let Some(w) = reg.workspace(&workspace_id) else { + let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; + }; + if let Some(sid) = &surface_id { + if !w.surfaces.contains_key(sid) { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; + } + } + if let Some(w) = reg.workspace_mut(&workspace_id) { + w.zoomed = surface_id.clone(); + } + if let Some(view) = reg.workspace_view(&workspace_id) { + broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view })); + } + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } +``` + +- [ ] **Step 4: Integration test** + +Add to `server.rs` tests (uses an observer connection for the WorkspaceChanged broadcast; mirrors existing tests): + +```rust + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_zoom_sets_and_clears_and_autoclears() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store = make_event_store(&dir); + let sock_for_task = sock.clone(); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; }); + wait_for_socket(&sock).await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + + let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + let r = req(&mut s, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + + // Zoom it. + let _ = req(&mut s, 3, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + let st = req(&mut s, 4, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert_eq!(w0["zoomed"], sid); + + // Unzoom. + let _ = req(&mut s, 5, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), surface_id: None, + }).await; + let st = req(&mut s, 6, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert!(w0["zoomed"].is_null()); + + // Re-zoom then close the surface → auto-clear. + let _ = req(&mut s, 7, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + let _ = req(&mut s, 8, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await; + let st = req(&mut s, 9, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert!(w0["zoomed"].is_null(), "closing the zoomed surface clears zoom"); + } +``` + +- [ ] **Step 5: Run + commit** + +Run: `cargo test -p spaceshd set_zoom_sets_and_clears_and_autoclears` → PASS; `cargo test -p spaceshd` → all pass (handle the env lock test as before). Note: `Cmd::SetZoom` will trigger a non-exhaustive match warning/error elsewhere only if other match sites exist — there are none besides handle_request, so the build is clean. + +```bash +git add crates/spaceshd/src/registry.rs crates/spaceshd/src/server.rs +git commit -m "feat(daemon): SetZoom command + persisted workspace zoom with auto-clear" +``` + +## Task 6: GUI zoom + +**Files:** Modify `app/src-tauri/src/bridge.rs`, `app/src-tauri/src/lib.rs`, `app/src/layoutTypes.ts`, `app/src/socketBridge.ts`, `app/src/LayoutEngine.tsx`, `app/src/App.tsx` + +- [ ] **Step 1: Tauri bridge command** + +In `app/src-tauri/src/bridge.rs`, after the `set_ratios`/`move_surface` area (any spot among the commands) add: + +```rust +#[tauri::command] +pub async fn set_zoom(state: BridgeState<'_>, workspace_id: String, surface_id: Option) -> Result { + let cmd = Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(workspace_id), + surface_id: surface_id.map(spacesh_proto::SurfaceId), + }; + data_of(state.request(cmd).await.map_err(|e| e.to_string())?) +} +``` + +In `app/src-tauri/src/lib.rs`, add `bridge::set_zoom,` to `generate_handler![...]`. + +- [ ] **Step 2: layoutTypes** + +In `app/src/layoutTypes.ts`, add to the `WorkspaceView` interface (after `layout`): + +```ts + zoomed: string | null; +``` + +- [ ] **Step 3: socketBridge** + +In `app/src/socketBridge.ts` add: + +```ts +export async function setZoom(workspaceId: string, surfaceId: string | null): Promise { + await invoke("set_zoom", { workspaceId, surfaceId }); +} +``` + +- [ ] **Step 4: LayoutEngine zoom render + header toggle** + +In `app/src/LayoutEngine.tsx`: +- Import `Minimize2` alongside `Maximize2` from lucide-react; import `setZoom` from `./socketBridge`. +- Add `zoomed` to the `Props` interface and the `LayoutEngine` signature: `zoomed: string | null;` and thread `workspaceId`+`zoomed` down (they are already in scope in `LayoutEngine`; pass `zoomed` to the top-level `Node` via a new prop OR short-circuit before rendering the tree). +- At the top of `LayoutEngine`, after the `if (!layout)` guard, short-circuit on zoom: + +```tsx + if (zoomed) { + return ( +
+ +
+ ); + } +``` + +- Thread a `zoomed: string | null` prop through `Node` (add to its destructured props and its type, and pass `zoomed={zoomed}` in the recursive `Node` render inside the split `.map`). In the leaf branch, replace the mock zoom icon line: + +```tsx + +``` + +with a real toggle: + +```tsx + {zoomed === id + ? { e.stopPropagation(); void setZoom(workspaceId, null); }} /> + : { e.stopPropagation(); void setZoom(workspaceId, id); }} />} +``` + +(`workspaceId` is already a prop of `Node`. `e.stopPropagation()` prevents the card's `onMouseDown` focus from firing.) + +- [ ] **Step 5: App passes zoomed** + +In `app/src/App.tsx`, update the `LayoutEngine` render to pass `zoomed={active.zoomed}`: + +```tsx + ? +``` + +- [ ] **Step 6: Build + commit** + +Run: `cd app && npm run build` → clean. `cd` back. + +```bash +git add app/src-tauri/src/bridge.rs app/src-tauri/src/lib.rs app/src/layoutTypes.ts app/src/socketBridge.ts app/src/LayoutEngine.tsx app/src/App.tsx +git commit -m "feat(app): panel zoom — full-grid render + header toggle" +``` + +--- + +# SP3 — Scrollback search + +## Task 7: search addon + registry in TerminalView + +**Files:** Add dependency; Create `app/src/searchRegistry.ts`; Modify `app/src/TerminalView.tsx` + +- [ ] **Step 1: Install the addon** + +Run: `cd app && npm install @xterm/addon-search && cd ..` +Expected: adds `@xterm/addon-search` to `app/package.json` dependencies. + +- [ ] **Step 2: Create the registry** + +Create `app/src/searchRegistry.ts`: + +```ts +import type { SearchAddon } from "@xterm/addon-search"; + +/** Maps a surfaceId to its terminal's SearchAddon so the search bar can reach + * the focused panel without prop-drilling through the layout tree. */ +const registry = new Map(); + +export function registerSearch(surfaceId: string, addon: SearchAddon): void { + registry.set(surfaceId, addon); +} + +export function unregisterSearch(surfaceId: string): void { + registry.delete(surfaceId); +} + +export function getSearch(surfaceId: string): SearchAddon | undefined { + return registry.get(surfaceId); +} +``` + +- [ ] **Step 3: Wire it into TerminalView** + +In `app/src/TerminalView.tsx`: +- Add imports: + +```tsx +import { SearchAddon } from "@xterm/addon-search"; +import { registerSearch, unregisterSearch } from "./searchRegistry"; +``` + +- Construct the terminal with a large scrollback: + +```tsx + const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000 }); +``` + +- After `term.open(ref.current);` (and the WebGL block), load + register the search addon: + +```tsx + const search = new SearchAddon(); + term.loadAddon(search); + registerSearch(surfaceId, search); +``` + +- In the cleanup return, before `term.dispose();` add: + +```tsx + unregisterSearch(surfaceId); +``` + +- [ ] **Step 4: Build (no committed UI yet, just verify it compiles)** + +Run: `cd app && npm run build` → clean. `cd` back. + +- [ ] **Step 5: Commit** + +```bash +git add app/package.json app/package-lock.json app/src/searchRegistry.ts app/src/TerminalView.tsx +git commit -m "feat(app): load xterm search addon + surface→addon registry" +``` + +## Task 8: SearchBar + ⌘F wiring + +**Files:** Create `app/src/SearchBar.tsx`; Modify `app/src/CenterToolbar.tsx`, `app/src/App.tsx` + +- [ ] **Step 1: Create SearchBar** + +Create `app/src/SearchBar.tsx`: + +```tsx +import { useEffect, useRef, useState } from "react"; +import { ChevronUp, ChevronDown, X } from "lucide-react"; +import { COLORS, FONT } from "./theme"; +import { getSearch } from "./searchRegistry"; + +const DECORATIONS = { + matchBackground: "#5A4A1F", + matchOverviewRuler: "#F2B84B", + activeMatchBackground: "#F2B84B", + activeMatchColorOverviewRuler: "#F2B84B", +}; + +export function SearchBar({ surfaceId, onClose }: { surfaceId: string | null; onClose: () => void }) { + const [term, setTerm] = useState(""); + const [count, setCount] = useState({ index: -1, total: 0 }); + const inputRef = useRef(null); + + // Subscribe to result changes for the active surface's addon. + useEffect(() => { + inputRef.current?.focus(); + if (!surfaceId) return; + const addon = getSearch(surfaceId); + if (!addon) return; + const sub = addon.onDidChangeResults((r) => setCount({ index: r.resultIndex, total: r.resultCount })); + return () => { sub.dispose(); addon.clearDecorations(); }; + }, [surfaceId]); + + function run(forward: boolean) { + if (!surfaceId) return; + const addon = getSearch(surfaceId); + if (!addon || !term) { addon?.clearDecorations(); setCount({ index: -1, total: 0 }); return; } + const opts = { decorations: DECORATIONS }; + if (forward) addon.findNext(term, opts); else addon.findPrevious(term, opts); + } + + return ( +
+ { setTerm(e.target.value); }} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); run(!e.shiftKey); } + else if (e.key === "Escape") { e.preventDefault(); onClose(); } + }} + placeholder="Search scrollback" + style={{ width: 200, background: "transparent", border: "none", outline: "none", color: COLORS.textPrimary, fontFamily: FONT.ui, fontSize: 13 }} /> + + {count.total > 0 ? `${count.index + 1}/${count.total}` : "0/0"} + + run(false)} /> + run(true)} /> + +
+ ); +} +``` + +- [ ] **Step 2: CenterToolbar opens search** + +In `app/src/CenterToolbar.tsx`, add an `onOpenSearch: () => void` prop and wire it to the pill's `onClick`: + +```tsx +export function CenterToolbar({ selected, onSelect, onOpenSearch }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void }) { +``` + +On the scrollback pill `div`, add `onClick={onOpenSearch}` (it already has `cursor: "pointer"`). + +- [ ] **Step 3: App wires ⌘F + renders SearchBar** + +In `app/src/App.tsx`: +- Import `SearchBar`. +- Add state: `const [searchOpen, setSearchOpen] = useState(false);` +- Add a global keydown effect: + +```tsx + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { + if (activeRef.current) { e.preventDefault(); setSearchOpen(true); } + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); +``` + +- Pass `onOpenSearch={() => setSearchOpen(true)}` to `CenterToolbar`. +- Render the bar inside the grid column container (the `
` that holds `LayoutEngine`), making that container `position: "relative"` and adding: + +```tsx + {searchOpen && active && setSearchOpen(false)} />} +``` + +Concretely, change that wrapper to `
` and place the `SearchBar` line after the `LayoutEngine`/empty-state ternary. + +- [ ] **Step 4: Build + commit** + +Run: `cd app && npm run build` → clean. `cd` back. + +```bash +git add app/src/SearchBar.tsx app/src/CenterToolbar.tsx app/src/App.tsx +git commit -m "feat(app): scrollback search bar (⌘F) on the focused panel" +``` + +--- + +# Final + +## Task 9: manual scenarios + full verification + +**Files:** Modify `DOCS/RUNNING.md` + +- [ ] **Step 1: Full verification** + +Run: `cargo test --workspace` → all green (kill stale daemon + `rm -f ~/.spacesh/daemon.lock` if only the lock test fails). +Run: `cd app && npm run build` → clean. `cd` back. + +- [ ] **Step 2: Document scenarios in DOCS/RUNNING.md** + +Add a subsection after the SP2 scenario (Russian, matching the doc's style): + +```markdown +### SP1/SP3/SP4 — health, поиск, zoom +- **Health (SP1):** футер сайдбара показывает `spaceshd · live` с зелёной точкой и аптайм (`3d 4h`); версия — в tooltip. При падении демона точка сереет, текст `offline`. +- **Поиск (SP3):** `⌘F` (или клик по пилюле «Search scrollback») открывает строку поиска над активной панелью. Печатай → совпадения подсвечиваются, `Enter`/`Shift+Enter` — next/prev, счётчик `i/N`, `Esc` — закрыть. Поиск идёт по буферу xterm активной панели (scrollback до 10000 строк). +- **Zoom (SP4):** иконка `⤢` в шапке панели разворачивает её на весь грид; `⤡` возвращает. Состояние персистится — переживает рестарт демона; при закрытии развёрнутой панели zoom сбрасывается. +``` + +Update §9 "Известные ограничения": remove "зум ... не реализованы" and "поиск по скроллбэку ... не реализованы"; note scrollback search is xterm-buffer-scoped (active panel, up to 10000 lines), and daemon-side/CLI grid search remains future work. + +- [ ] **Step 3: Commit** + +```bash +git add DOCS/RUNNING.md +git commit -m "docs: SP1/SP3/SP4 manual scenarios and updated limitations" +``` + +- [ ] **Step 4: Final review handoff** + +Dispatch a final code reviewer across the SP1/SP3/SP4 commits before merging the branch. + +--- + +## Notes for the implementer + +- **Branch:** work on `spacesh-sp1-sp3-sp4` (already created). +- **Env lock test:** `lifecycle::tests::lock_is_exclusive_within_process` fails if a real daemon holds `~/.spacesh/daemon.lock`; that is environmental — `pkill -f "target/debug/spaceshd"; rm -f ~/.spacesh/daemon.lock` and re-run. +- **TDD:** Rust tasks write the test first. Frontend tasks verify via `npm run build` + the manual scenario. +- **Independence:** SP1, SP4, SP3 are independent; if one task group stalls, the others still merge cleanly. diff --git a/DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md b/DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md new file mode 100644 index 0000000..9f99272 --- /dev/null +++ b/DOCS/superpowers/specs/2026-06-10-spacesh-sp1-sp3-sp4-design.md @@ -0,0 +1,115 @@ +# spacesh SP1 + SP3 + SP4 — Design + +**Status:** Design (approved for plan authoring) +**Date:** 2026-06-10 +**Scope:** Three independent sub-projects from the "frontend mocks → backend" decomposition, batched into one design doc. Each section is independently implementable and gets its own task group in the plan. + +- **SP1 — Daemon observability:** real `spaceshd · live · ` sidebar footer (replaces the hardcoded mock). +- **SP3 — Scrollback search:** working `⌘F` find-in-terminal for the focused panel (replaces the mock pill). +- **SP4 — Panel zoom:** maximize one panel to fill the grid, persisted across restarts (replaces the mock zoom icon). + +## Architecture invariants honored +- **Daemon is the single source of truth.** SP1 health and SP4 zoom state live in `spaceshd`; the GUI mirrors them. SP3 search is a *display* concern (operates on the xterm.js buffer the user sees) and deliberately stays client-side. +- **One socket, one protocol.** SP1/SP4 add new `Cmd` variants in `spacesh-proto`; no new transport. +- **Display (xterm.js) vs analysis (alacritty grid) split.** SP3 uses xterm.js's own buffer + search addon — searching what is displayed is a display task. The daemon's authoritative grid is untouched; a daemon-side grid search can be added later as a separate command if headless/CLI/cross-panel search is ever needed. + +--- + +## SP1 — Daemon observability + +### Problem +The sidebar footer shows a hardcoded `spaceshd · live` / `3d 4h`. There is no command to learn the daemon's version or how long it has been running. + +### Proto +New command (in `spacesh-proto/src/message.rs`): +- `Cmd::Health` → response data `{ "version": String, "pid": u32, "started_at_ms": u64 }`. + +`version` is the daemon crate version; `pid` is the daemon process id; `started_at_ms` is the unix-epoch-millis timestamp captured when the daemon began serving. Uptime is derived GUI-side (`now - started_at_ms`) so it can tick without polling. + +### Daemon +- `serve()` captures `started_at_ms` once (`SystemTime::now()` → millis) and threads it into `router`, which holds it for the lifetime of the process. +- `handle_request` gains a `Cmd::Health` arm returning `{ version: env!("CARGO_PKG_VERSION").to_string(), pid: std::process::id(), started_at_ms }`. + +### GUI +- `socketBridge.ts`: `getHealth(): Promise<{ version: string; pid: number; started_at_ms: number }>`. +- `App.tsx`: fetch health on connect (and on reconnect); track a `connected` boolean (true after a successful `getStatusFull`/`getHealth`, false on `spacesh:disconnected`). Pass `health` + `connected` to `Sidebar`. +- `Sidebar.tsx` footer: the `live` dot is green when `connected`, grey otherwise; the label reads `spaceshd · live` when connected, `spaceshd · offline` otherwise; the right-hand value shows uptime formatted from `started_at_ms` (e.g. `3d 4h`, `5h 12m`, `47s`), recomputed on a ~30s interval; the daemon version is shown via the element's `title` tooltip. When `health` is null (not yet fetched / offline) the uptime slot is blank. + +### Edge cases +- Health requested before connect → the bridge call rejects; GUI keeps `connected=false` and shows offline. +- Uptime formatting: `<1m` → `Ns`; `<1h` → `Nm`; `<1d` → `Nh Mm`; else `Nd Mh`. + +### Tests +- proto: `Cmd::Health` serde round-trip. +- daemon: integration test — `Cmd::Health` returns a non-empty `version`, a plausible `pid`, and a `started_at_ms` ≤ now. + +--- + +## SP3 — Scrollback search (xterm addon-search) + +### Problem +The center toolbar's `Search scrollback ⌘F` pill is a mock. Users need to find text in a panel's output. + +### Approach +Use xterm.js's official `@xterm/addon-search` on the **focused** panel's terminal. Searching the displayed buffer (including its scrollback) is a display concern, so this is entirely client-side — no daemon changes. The xterm scrollback is raised so "full scrollback" is meaningful. + +### Components +- **Dependency:** `@xterm/addon-search`. +- **`TerminalView.tsx`:** construct the `Terminal` with `scrollback: 10000`; load a `SearchAddon`; register it in a module-level `Map` keyed by `surfaceId` on mount and delete the entry on unmount. This registry lets the search bar reach the focused panel's addon without prop-drilling through the layout tree. +- **`SearchBar.tsx` (new):** a small overlay input. Given the focused `surfaceId`, it looks up the addon in the registry and drives it: + - typing / Enter → `addon.findNext(term, opts)`; Shift+Enter → `addon.findPrevious(term, opts)`. + - match count + current index from `addon.onDidChangeResults(({ resultIndex, resultCount }) => …)` rendered as `i/N` (or `0/0`). + - `opts` sets decoration colors (match / active-match) from the theme (`COLORS.stWait` active, a dim variant for others). + - `Esc` closes the bar and calls `addon.clearDecorations()`. +- **`App.tsx`:** owns `searchOpen` state and the focused surface (`effectiveFocus`, already tracked). A global `keydown` handler opens the bar on `⌘F`/`Ctrl+F` (preventing the browser default) when a workspace is active; clicking the toolbar pill also opens it. The bar targets `effectiveFocus`; if focus changes while open, the bar re-targets and clears the previous panel's decorations. + +### Edge cases +- No focused panel / focused panel has no registered addon (e.g. stopped) → bar shows `0/0` and is a no-op. +- Empty query → clear decorations, `0/0`. +- Closing the bar always clears decorations on the targeted addon. + +### Tests +- Frontend type-check + build (`npm run build`). No headless smoke for xterm search (xterm needs a real DOM/canvas; covered by manual testing). Manual scenario added to `RUNNING.md`. + +--- + +## SP4 — Panel zoom (persisted) + +### Problem +The panel-header zoom icon (`maximize-2`) is a mock. Users want to maximize one panel to fill the grid, and have that survive restarts. + +### Proto +- `Workspace` (persisted) and `WorkspaceView` (status) gain `zoomed: Option` (at most one zoomed panel per workspace; `None` = normal grid). +- New `Cmd::SetZoom { workspace_id: WorkspaceId, surface_id: Option }` — `Some(sid)` zooms that panel, `None` clears zoom. + +### Daemon +- The registry stores `zoomed` on each workspace; it is included when building `WorkspaceView` and persisted in `state.json`. +- `Cmd::SetZoom` handler: if `Some(sid)`, validate the surface belongs to the workspace (else `NOT_FOUND`); set `zoomed`; if `None`, clear it. Persist (`mark_dirty`) and broadcast `Evt::WorkspaceChanged { workspace: view }` so all clients re-render. Respond `ok`. +- **Auto-clear:** when a surface is removed (`remove_surface`, used by Close/ApplyPreset/CloseWorkspace) or a workspace's structure changes such that the zoomed surface no longer exists, clear `zoomed` if it pointed at the removed surface. (Applying a new preset replaces surfaces, so it must also clear zoom for that workspace.) + +### GUI +- `WorkspaceView` type gains `zoomed: string | null`. +- `socketBridge.ts`: `setZoom(workspaceId: string, surfaceId: string | null)`. +- `LayoutEngine.tsx`: accept the workspace's `zoomed`. When `zoomed` is set and that surface is present & running, render ONLY that leaf at full size (the split tree and splitters are bypassed). Otherwise render the normal tree. +- Panel header zoom control: when not zoomed, `Maximize2` → `setZoom(workspaceId, sid)`; when this panel is the zoomed one, show `Minimize2` → `setZoom(workspaceId, null)`. The control stops propagation so it doesn't also trigger focus. +- `App.tsx` passes `active.zoomed` into `LayoutEngine`. + +### Edge cases +- Zoomed surface stopped (process exited but still in tree): still render it zoomed, showing the "Process exited / Restart" state full-screen (consistent with normal panes). Auto-clear only on actual removal from the tree. +- Zoom set on a workspace, then a different workspace selected: zoom is per-workspace, so switching away and back preserves it. + +### Tests +- proto: `Cmd::SetZoom` serde round-trip; `Workspace`/`WorkspaceView` round-trip with `zoomed` set. +- daemon: integration — SetZoom(Some) reflects in `status` (`zoomed == sid`) and broadcasts WorkspaceChanged; SetZoom(None) clears; closing the zoomed surface auto-clears `zoomed`. +- frontend: build clean; manual scenario in `RUNNING.md`. + +--- + +## Out of scope +- Daemon-side grid search / CLI search (SP3 stays client-side). +- Multiple simultaneous zoomed panels (one per workspace only). +- Health metrics beyond version/pid/uptime (no CPU/mem/session counts). +- The top-bar `search`/`settings`/account menu and Telegram/MAX channels remain mocked (other sub-projects / out of v1). + +## Build order within the plan +SP1 (smallest, proto+daemon+sidebar) → SP4 (proto+daemon+layout) → SP3 (frontend-only). Each is independent; this order front-loads the cheap daemon work and ends with the isolated frontend feature. diff --git a/app/package-lock.json b/app/package-lock.json index 3ed1d59..f6be5ad 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "@fontsource/inter": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", + "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "lucide-react": "^1.17.0", @@ -1462,6 +1463,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, "node_modules/@xterm/addon-webgl": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", diff --git a/app/package.json b/app/package.json index 62e4672..e751bc6 100644 --- a/app/package.json +++ b/app/package.json @@ -13,6 +13,7 @@ "@fontsource/inter": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", + "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "lucide-react": "^1.17.0", diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index 8f6e5b8..82c484d 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -299,6 +299,17 @@ pub async fn focus(state: BridgeState<'_>, surface_id: String) -> Result, workspace_id: String, surface_id: Option) -> Result { + let cmd = Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(workspace_id), + surface_id: surface_id.map(spacesh_proto::SurfaceId), + }; + data_of(state.request(cmd).await.map_err(|e| e.to_string())?) +} + // ---- M3 event log commands ---- #[tauri::command] @@ -311,3 +322,8 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result) -> Result { + data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b1db4f5..35a4bc9 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -49,8 +49,10 @@ pub fn run() { bridge::set_group, bridge::delete_group, bridge::focus, + bridge::set_zoom, bridge::event_log, bridge::mark_read, + bridge::health, ]) .run(tauri::generate_context!()) .expect("error while running spacesh"); diff --git a/app/src/App.tsx b/app/src/App.tsx index 5aaac1f..bbf58cc 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,12 +3,13 @@ import { LayoutEngine } from "./LayoutEngine"; import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { CenterToolbar } from "./CenterToolbar"; +import { SearchBar } from "./SearchBar"; import { Wizard } from "./Wizard"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; import { COLORS } from "./theme"; -import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead } from "./socketBridge"; -import type { EventRecord } from "./socketBridge"; +import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth } from "./socketBridge"; +import type { EventRecord, DaemonHealth } from "./socketBridge"; import { leafIds } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; @@ -21,7 +22,11 @@ export function App() { const [events, setEvents] = useState([]); const [wizard, setWizard] = useState(false); const [eventsOpen, setEventsOpen] = useState(true); + const [health, setHealth] = useState(null); + const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchNonce, setSearchNonce] = useState(0); const activeRef = useRef(null); const wsRef = useRef([]); activeRef.current = activeId; @@ -49,12 +54,18 @@ export function App() { if (!activeRef.current && st.workspaces.length) setActiveId(st.workspaces[0].id); }, []); + const loadHealth = useCallback(async () => { + try { setHealth(await getHealth()); setConnected(true); } + catch { setConnected(false); } + }, []); + const wsOf = (surfaceId: string): WorkspaceView | undefined => wsRef.current.find((w) => surfaceId in w.surfaces); useEffect(() => { void refresh(); void seedEvents(); + void loadHealth(); const unlisten = onDaemonEvent((evt) => { if (evt.evt === "event") { const rec = evt.data.record; @@ -74,9 +85,24 @@ export function App() { void refresh(); } }); - const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); void seedEvents(); }); + const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { + setConnected(false); + void refresh(); + void seedEvents(); + void loadHealth(); + }); return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); }; - }, [refresh, seedEvents]); + }, [refresh, seedEvents, loadHealth]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { + if (activeRef.current) { e.preventDefault(); setSearchOpen(true); setSearchNonce((n) => n + 1); } + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); const unread = useMemo(() => events.filter((e) => !e.read).length, [events]); const active = workspaces.find((w) => w.id === activeId) ?? null; @@ -93,15 +119,16 @@ export function App() {
setEventsOpen((v) => !v)} unread={unread} />
- setWizard(true)} /> + setWizard(true)} health={health} connected={connected} />
{active && ( - { if (active) void applyPreset(active.id, p, []); }} /> + { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} /> )} -
+
{active - ? + ? :
No workspace — create one to begin.
} + {searchOpen && active && setSearchOpen(false)} />}
{eventsOpen && ( diff --git a/app/src/CenterToolbar.tsx b/app/src/CenterToolbar.tsx index 699e5ca..f674cc6 100644 --- a/app/src/CenterToolbar.tsx +++ b/app/src/CenterToolbar.tsx @@ -3,13 +3,14 @@ import { COLORS, FONT } from "./theme"; import { PresetPicker } from "./PresetPicker"; /** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */ -export function CenterToolbar({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) { +export function CenterToolbar({ selected, onSelect, onOpenSearch }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void }) { return (
; focusedId: string | null; onFocus: (id: string) => void; + zoomed: string | null; } /** Collapse an absolute cwd into a ~/ style label for the panel header. */ @@ -23,21 +24,29 @@ function shortPath(cwd: string): string { return leaf ? `~/${leaf}` : cwd; } -export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus }: Props) { +export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: Props) { if (!layout) { return
Empty workspace — apply a preset to add panels.
; } + if (zoomed) { + return ( +
+ +
+ ); + } return (
- +
); } -function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus }: { +function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus, zoomed }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record; states: Record; surfaces: Record; focusedId: string | null; onFocus: (id: string) => void; + zoomed: string | null; }) { if ("leaf" in node) { const id = node.leaf.surface_id; @@ -60,10 +69,18 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o return card(
Process exited
- +
+ + {zoomed === id && ( + + )} +
); } @@ -81,7 +98,11 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o {state} - + {zoomed === id + ? { e.stopPropagation(); void setZoom(workspaceId, null); }} /> + : { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
@@ -102,7 +123,7 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac); void setRatios(workspaceId, path, next); }}> - + ))}
diff --git a/app/src/SearchBar.tsx b/app/src/SearchBar.tsx new file mode 100644 index 0000000..eac81fa --- /dev/null +++ b/app/src/SearchBar.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronUp, ChevronDown, X } from "lucide-react"; +import { COLORS, FONT } from "./theme"; +import { getSearch } from "./searchRegistry"; +import type { ISearchOptions } from "@xterm/addon-search"; + +const SEARCH_OPTS: ISearchOptions = { + decorations: { + matchBackground: COLORS.searchMatch, + matchOverviewRuler: COLORS.stWait, + activeMatchBackground: COLORS.stWait, + activeMatchColorOverviewRuler: COLORS.stWait, + }, +}; + +export function SearchBar({ + surfaceId, + reopenNonce, + onClose, +}: { + surfaceId: string | null; + reopenNonce: number; + onClose: () => void; +}) { + const [term, setTerm] = useState(""); + const [count, setCount] = useState({ index: -1, total: 0 }); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, [reopenNonce]); + + useEffect(() => { + inputRef.current?.focus(); + if (!surfaceId) return; + const addon = getSearch(surfaceId); + if (!addon) return; + const sub = addon.onDidChangeResults((r) => + setCount({ index: r.resultIndex, total: r.resultCount }) + ); + return () => { + sub.dispose(); + addon.clearDecorations(); + }; + }, [surfaceId]); + + function run(forward: boolean) { + if (!surfaceId) return; + const addon = getSearch(surfaceId); + if (!addon || !term) { + addon?.clearDecorations(); + setCount({ index: -1, total: 0 }); + return; + } + if (forward) addon.findNext(term, SEARCH_OPTS); + else addon.findPrevious(term, SEARCH_OPTS); + } + + return ( +
+ { + setTerm(e.target.value); + setCount({ index: -1, total: 0 }); + if (surfaceId) getSearch(surfaceId)?.clearDecorations(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + run(!e.shiftKey); + } else if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }} + placeholder="Search scrollback" + style={{ + width: 200, + background: "transparent", + border: "none", + outline: "none", + color: COLORS.textPrimary, + fontFamily: FONT.ui, + fontSize: 13, + }} + /> + + {count.total > 0 ? `${count.index + 1}/${count.total}` : "0/0"} + + run(false)} + /> + run(true)} + /> + +
+ ); +} diff --git a/app/src/Sidebar.tsx b/app/src/Sidebar.tsx index ce3954a..2d44737 100644 --- a/app/src/Sidebar.tsx +++ b/app/src/Sidebar.tsx @@ -1,7 +1,16 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Plus, ChevronDown, ChevronRight } from "lucide-react"; import { COLORS, FONT, STATE_COLOR } from "./theme"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; +import type { DaemonHealth } from "./socketBridge"; + +function fmtUptime(startedMs: number): string { + const s = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; +} function aggregate(w: WorkspaceView): SurfaceState | "stopped" { const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"]; @@ -14,15 +23,22 @@ function aggregate(w: WorkspaceView): SurfaceState | "stopped" { } export function Sidebar({ - groups, workspaces, activeId, onSelect, onNew, + groups, workspaces, activeId, onSelect, onNew, health, connected, }: { groups: Group[]; workspaces: WorkspaceView[]; activeId: string | null; onSelect: (id: string) => void; onNew: () => void; + health: DaemonHealth | null; + connected: boolean; }) { const [collapsed, setCollapsed] = useState>({}); + const [, setTick] = useState(0); + useEffect(() => { + const t = setInterval(() => setTick((n) => n + 1), 30000); + return () => clearInterval(t); + }, []); const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order); const ungrouped = byGroup(null); @@ -76,12 +92,12 @@ export function Sidebar({ {ungrouped.length > 0 &&
{ungrouped.map(row)}
}
- {/* Daemon status footer — uptime is mocked until the daemon reports it. */} -
- - spaceshd · live +
+ + {connected ? "spaceshd · live" : "spaceshd · offline"} - 3d 4h + {health ? fmtUptime(health.started_at_ms) : ""}
); diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index fd070de..52e0116 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { WebglAddon } from "@xterm/addon-webgl"; +import { SearchAddon } from "@xterm/addon-search"; import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge"; +import { registerSearch, unregisterSearch } from "./searchRegistry"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); @@ -11,7 +13,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { useEffect(() => { if (!ref.current) return; - const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false }); + const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000 }); try { term.loadAddon(new WebglAddon()); } catch { @@ -19,6 +21,10 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { } term.open(ref.current); + const search = new SearchAddon(); + term.loadAddon(search); + registerSearch(surfaceId, search); + // Input → daemon. const inputDisposable = term.onData((data) => { void sendInput(surfaceId, encoder.encode(data)); @@ -42,6 +48,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { disposed = true; inputDisposable.dispose(); void detachSurface(surfaceId); + unregisterSearch(surfaceId); term.dispose(); }; }, [surfaceId]); diff --git a/app/src/layoutTypes.ts b/app/src/layoutTypes.ts index f725993..2730e1a 100644 --- a/app/src/layoutTypes.ts +++ b/app/src/layoutTypes.ts @@ -35,6 +35,7 @@ export interface WorkspaceView { order: number; unread: boolean; layout: LayoutNode | null; + zoomed: string | null; surfaces: Record; } diff --git a/app/src/searchRegistry.ts b/app/src/searchRegistry.ts new file mode 100644 index 0000000..4152278 --- /dev/null +++ b/app/src/searchRegistry.ts @@ -0,0 +1,17 @@ +import type { SearchAddon } from "@xterm/addon-search"; + +/** Maps a surfaceId to its terminal's SearchAddon so the search bar can reach + * the focused panel without prop-drilling through the layout tree. */ +const registry = new Map(); + +export function registerSearch(surfaceId: string, addon: SearchAddon): void { + registry.set(surfaceId, addon); +} + +export function unregisterSearch(surfaceId: string): void { + registry.delete(surfaceId); +} + +export function getSearch(surfaceId: string): SearchAddon | undefined { + return registry.get(surfaceId); +} diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts index a6470ee..8d102c1 100644 --- a/app/src/socketBridge.ts +++ b/app/src/socketBridge.ts @@ -175,3 +175,13 @@ export async function deleteGroup(groupId: string): Promise { export async function closeSurfaceCmd(surfaceId: string): Promise { await invoke("close_surface", { surfaceId }); } + +export interface DaemonHealth { version: string; pid: number; started_at_ms: number } + +export async function getHealth(): Promise { + return await invoke("health"); +} + +export async function setZoom(workspaceId: string, surfaceId: string | null): Promise { + await invoke("set_zoom", { workspaceId, surfaceId }); +} diff --git a/app/src/theme.ts b/app/src/theme.ts index 700b335..e1421ce 100644 --- a/app/src/theme.ts +++ b/app/src/theme.ts @@ -18,6 +18,7 @@ export const COLORS = { stDone: "#3FB950", stError: "#F4544E", stIdle: "#5A6573", + searchMatch: "#5A4A1F", } as const; export const FONT = { diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index 3c682ba..ea38600 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -122,6 +122,12 @@ pub enum Cmd { limit: Option, }, MarkRead { target: MarkReadTarget }, + SetZoom { + workspace_id: WorkspaceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + surface_id: Option, + }, + Health, Status, Shutdown, } @@ -321,6 +327,27 @@ mod tests { assert_eq!(back, evt); } + #[test] + fn set_zoom_cmd_round_trips() { + let z = Envelope::Req { id: 1, cmd: Cmd::SetZoom { + workspace_id: WorkspaceId("w_1".into()), surface_id: Some(SurfaceId("s_1".into())) } }; + let j = serde_json::to_string(&z).unwrap(); + assert!(j.contains(r#""cmd":"set_zoom""#)); + assert_eq!(serde_json::from_str::(&j).unwrap(), z); + let unz = Envelope::Req { id: 2, cmd: Cmd::SetZoom { + workspace_id: WorkspaceId("w_1".into()), surface_id: None } }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&unz).unwrap()).unwrap(), unz); + } + + #[test] + fn health_cmd_round_trips() { + let env = Envelope::Req { id: 1, cmd: Cmd::Health }; + let j = serde_json::to_string(&env).unwrap(); + assert!(j.contains(r#""cmd":"health""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, env); + } + #[test] fn event_log_cmd_no_limit_round_trips() { let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } }; diff --git a/crates/spacesh-proto/src/workspace.rs b/crates/spacesh-proto/src/workspace.rs index 5a0c28a..6dfc0bd 100644 --- a/crates/spacesh-proto/src/workspace.rs +++ b/crates/spacesh-proto/src/workspace.rs @@ -42,6 +42,9 @@ pub struct Workspace { /// None = empty workspace (no panels yet). #[serde(default)] pub layout: Option, + /// The single maximized surface for this workspace, if any. + #[serde(default)] + pub zoomed: Option, pub surfaces: HashMap, } @@ -66,6 +69,8 @@ pub struct WorkspaceView { pub order: u32, pub unread: bool, pub layout: Option, + #[serde(default)] + pub zoomed: Option, pub surfaces: HashMap, } @@ -99,10 +104,22 @@ mod tests { order: 0, unread: false, layout: None, + zoomed: None, surfaces: HashMap::new(), }; let j = serde_json::to_string(&w).unwrap(); let back: Workspace = serde_json::from_str(&j).unwrap(); assert_eq!(back, w); } + + #[test] + fn workspace_round_trips_with_zoom() { + let w = Workspace { + id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), + group_id: None, order: 0, unread: false, layout: None, + zoomed: Some(SurfaceId("s_1".into())), surfaces: HashMap::new(), + }; + let back: Workspace = serde_json::from_str(&serde_json::to_string(&w).unwrap()).unwrap(); + assert_eq!(back, w); + } } diff --git a/crates/spaceshd/src/registry.rs b/crates/spaceshd/src/registry.rs index a7a31ff..ac9f679 100644 --- a/crates/spaceshd/src/registry.rs +++ b/crates/spaceshd/src/registry.rs @@ -51,7 +51,7 @@ impl Registry { let order = self.workspaces.len() as u32; self.workspaces.insert(id.clone(), Workspace { id: id.clone(), path: key.clone(), name, group_id: None, order, - unread: false, layout: None, surfaces: HashMap::new(), + unread: false, layout: None, zoomed: None, surfaces: HashMap::new(), }); self.by_path.insert(key, id.clone()); (id, true) @@ -95,6 +95,7 @@ impl Registry { if let Some(w) = self.workspaces.get_mut(&ws) { w.surfaces.remove(sid); w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, sid)); + if w.zoomed.as_ref() == Some(sid) { w.zoomed = None; } } } } @@ -168,7 +169,7 @@ impl Registry { WorkspaceView { id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), group_id: w.group_id.clone(), order: w.order, unread: w.unread, - layout: w.layout.clone(), surfaces, + layout: w.layout.clone(), zoomed: w.zoomed.clone(), surfaces, } } pub fn status(&self) -> (Vec, Vec) { @@ -189,6 +190,10 @@ impl Registry { self.live.clear(); self.states.clear(); for w in state.workspaces { + let mut w = w; + if let Some(z) = &w.zoomed { + if !w.surfaces.contains_key(z) { w.zoomed = None; } + } self.by_path.insert(w.path.clone(), w.id.clone()); self.workspaces.insert(w.id.clone(), w); } @@ -277,6 +282,17 @@ mod tests { assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work); } + #[test] + fn remove_surface_clears_zoom() { + let mut r = Registry::new(); + let (ws, _) = r.open_workspace(std::env::temp_dir()); + let s1 = r.new_surface_id(); + r.add_surface_spec(&ws, s1.clone(), spec()); + r.workspace_mut(&ws).unwrap().zoomed = Some(s1.clone()); + r.remove_surface(&s1); + assert!(r.workspace(&ws).unwrap().zoomed.is_none()); + } + #[test] fn drop_state_resets_to_idle() { let mut r = Registry::new(); diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index ac3c214..8cced31 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -62,9 +62,11 @@ pub async fn serve(socket: &Path, store: Arc, event_store: Arc { handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &state_tx, &persister, - &mut event_log, &event_persister).await; + &mut event_log, &event_persister, started_at_ms).await; } } } @@ -272,6 +275,7 @@ async fn handle_request( persister: &Persister, event_log: &mut EventLog, event_persister: &EventPersister, + started_at_ms: u64, ) { use spacesh_proto::message::SplitDir; use spacesh_proto::layout::{LayoutNode, Orient}; @@ -568,7 +572,12 @@ async fn handle_request( crate::hooks::cleanup(&surface_id); crate::hooks::cleanup_shell(&surface_id); broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() })); - if let Some(ws_id) = ws_id { emit_layout(reg, &ws_id, clients); } + if let Some(ws_id) = ws_id { + emit_layout(reg, &ws_id, clients); + if let Some(view) = reg.workspace_view(&ws_id) { + broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view })); + } + } persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { @@ -590,6 +599,31 @@ async fn handle_request( } } + Cmd::SetZoom { workspace_id, surface_id } => { + let Some(w) = reg.workspace(&workspace_id) else { + let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; + }; + if let Some(sid) = &surface_id { + if !w.surfaces.contains_key(sid) { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; + } + } + reg.workspace_mut(&workspace_id).expect("workspace validated above").zoomed = surface_id.clone(); + if let Some(view) = reg.workspace_view(&workspace_id) { + broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view })); + } + persister.mark_dirty(reg.persist_state()); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + + Cmd::Health => { + let _ = out.send(ok(id, serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "pid": std::process::id(), + "started_at_ms": started_at_ms, + }))).await; + } + Cmd::Status => { let (groups, workspaces) = reg.status(); let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; @@ -1300,4 +1334,125 @@ mod tests { let log = req(&mut s, 6, Cmd::EventLog { limit: None }).await; assert_eq!(res_data(&log)["unread"].as_u64().unwrap(), 0); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn health_reports_version_pid_started() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store = make_event_store(&dir); + let sock_for_task = sock.clone(); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; }); + wait_for_socket(&sock).await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; + let r = req(&mut s, 1, Cmd::Health).await; + let d = res_data(&r); + assert!(!d["version"].as_str().unwrap().is_empty()); + assert!(d["pid"].as_u64().unwrap() > 0); + let started = d["started_at_ms"].as_u64().unwrap(); + assert!(started > 0 && started >= now.saturating_sub(5000) && started <= now + 1000, "started_at_ms plausible: {started} vs now {now}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_zoom_sets_and_clears_and_autoclears() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store = make_event_store(&dir); + let sock_for_task = sock.clone(); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; }); + wait_for_socket(&sock).await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + + let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + let r = req(&mut s, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + + let _ = req(&mut s, 3, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + let st = req(&mut s, 4, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert_eq!(w0["zoomed"], sid); + + let _ = req(&mut s, 5, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), surface_id: None, + }).await; + let st = req(&mut s, 6, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert!(w0["zoomed"].is_null()); + + let _ = req(&mut s, 7, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + let _ = req(&mut s, 8, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await; + let st = req(&mut s, 9, Cmd::Status).await; + let w0 = res_data(&st)["workspaces"].as_array().unwrap().iter().find(|w| w["id"] == ws).unwrap().clone(); + assert!(w0["zoomed"].is_null(), "closing the zoomed surface clears zoom"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn close_zoomed_broadcasts_workspace_changed() { + let _serial = crate::test_support::serial(); + let dir = tempdir_path(); + let sock = dir.join("sock"); + let store: std::sync::Arc = + std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); + let event_store = make_event_store(&dir); + let sock_for_task = sock.clone(); + let store2 = store.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; }); + wait_for_socket(&sock).await; + + // Control connection: open, spawn, zoom. + let mut ctrl = UnixStream::connect(&sock).await.unwrap(); + let r = req(&mut ctrl, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + let r = req(&mut ctrl, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 5".into()], cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + let _ = req(&mut ctrl, 3, Cmd::SetZoom { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + surface_id: Some(spacesh_proto::SurfaceId(sid.clone())), + }).await; + + // Observer connection: must be attached BEFORE the Close so it catches the broadcast. + let mut observer = UnixStream::connect(&sock).await.unwrap(); + + // Close the zoomed surface on the control connection. + let _ = req(&mut ctrl, 4, Cmd::Close { surface_id: spacesh_proto::SurfaceId(sid.clone()) }).await; + + // Observer must receive a WorkspaceChanged for this workspace with zoomed == None. + let mut saw_cleared = false; + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + if let Ok(Ok(Some(env))) = + tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await { + if let Envelope::Evt(Evt::WorkspaceChanged { workspace }) = env { + if workspace.id.0 == ws { + assert!(workspace.zoomed.is_none(), "WorkspaceChanged must report cleared zoom"); + saw_cleared = true; + break; + } + } + } + } + assert!(saw_cleared, "expected a WorkspaceChanged broadcast with cleared zoom after closing the zoomed surface"); + } } diff --git a/crates/spaceshd/src/state_store.rs b/crates/spaceshd/src/state_store.rs index 31ca3c4..7a7c9b6 100644 --- a/crates/spaceshd/src/state_store.rs +++ b/crates/spaceshd/src/state_store.rs @@ -93,6 +93,7 @@ mod tests { order: 0, unread: false, layout: None, + zoomed: None, surfaces: std::collections::HashMap::new(), }], }