Merge SP1+SP3+SP4: daemon health, scrollback search, panel zoom

- SP1: Cmd::Health (version/pid/uptime) → real sidebar live/uptime footer
- SP3: ⌘F scrollback search on the focused panel via xterm addon-search
- SP4: persisted per-workspace panel zoom (full-grid render, auto-clear on removal)
- 119 workspace tests green; frontend build clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:56:09 +07:00
22 changed files with 1444 additions and 34 deletions
+7 -1
View File
@@ -163,6 +163,11 @@ S shutdown
### M4 — CLI ### M4 — CLI
- `spacesh status --json` против живого демона; `spacesh notify` без демона → молча `exit 0`; `spacesh completions zsh` печатает скрипт. - `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. Где что лежит / сброс ## 7. Где что лежит / сброс
@@ -203,7 +208,8 @@ rm -rf ~/.spacesh # сбрасывает сокет, лок, state.json,
- **Клик по нативному уведомлению** не фокусит конкретную панель (клик по записи в Event Center — фокусит). - **Клик по нативному уведомлению** не фокусит конкретную панель (клик по записи в Event Center — фокусит).
- **Event Center** — лента хранится в демоне и персистируется в `~/.spacesh/events.json` (переживает перезапуск GUI и холодный рестарт демона). Вкладки `Unread`/`Errors` и бейдж `bell` работают по реальным данным (флаги прочтения на уровне события). По-прежнему не реализованы: каналы Telegram/MAX в футере Event Center (SP5), а также `search`/`settings` и меню аккаунта в топ-баре. - **Event Center** — лента хранится в демоне и персистируется в `~/.spacesh/events.json` (переживает перезапуск GUI и холодный рестарт демона). Вкладки `Unread`/`Errors` и бейдж `bell` работают по реальным данным (флаги прочтения на уровне события). По-прежнему не реализованы: каналы Telegram/MAX в футере Event Center (SP5), а также `search`/`settings` и меню аккаунта в топ-баре.
- **Статус эфемерен** (work/wait/done/error/idle) — не персистится; после холодного рестарта демона панель `stopped`, статус `idle`. - **Статус эфемерен** (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`).
--- ---
@@ -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<SurfaceId>` 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<string, SearchAddon>` 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<dyn crate::state_store::StateStore> =
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<Value, String> {
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<DaemonHealth> {
return await invoke<DaemonHealth>("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<DaemonHealth | null>(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: `<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => 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
<div title={health ? `spaceshd v${health.version} · pid ${health.pid}` : "daemon offline"}
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 7px" }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>{connected ? "spaceshd · live" : "spaceshd · offline"}</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>{health ? fmtUptime(health.started_at_ms) : ""}</span>
</div>
```
- [ ] **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<SurfaceId>,
```
And to `WorkspaceView` (after its `layout` field):
```rust
#[serde(default)]
pub zoomed: Option<SurfaceId>,
```
- [ ] **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<SurfaceId>,
},
```
- [ ] **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::<Envelope>(&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::<Envelope>(&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<dyn crate::state_store::StateStore> =
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<String>) -> Result<Value, String> {
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<void> {
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 (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node workspaceId={workspaceId} node={{ leaf: { surface_id: zoomed } }} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
</div>
);
}
```
- 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
<Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom (mock)" />
```
with a real toggle:
```tsx
{zoomed === id
? <Minimize2 size={13} color={COLORS.textSecondary} style={{ cursor: "pointer" }} aria-label="Unzoom"
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
onMouseDown={(e) => { 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
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} />
```
- [ ] **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<string, SearchAddon>();
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<HTMLInputElement>(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 (
<div style={{
position: "absolute", top: 8, right: 16, zIndex: 50,
display: "flex", alignItems: "center", gap: 6, height: 32, padding: "0 8px",
background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}>
<input ref={inputRef} value={term}
onChange={(e) => { 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 }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, minWidth: 40, textAlign: "right" }}>
{count.total > 0 ? `${count.index + 1}/${count.total}` : "0/0"}
</span>
<ChevronUp size={15} color={COLORS.textSecondary} style={{ cursor: "pointer" }} onClick={() => run(false)} />
<ChevronDown size={15} color={COLORS.textSecondary} style={{ cursor: "pointer" }} onClick={() => run(true)} />
<X size={15} color={COLORS.textMuted} style={{ cursor: "pointer" }} onClick={onClose} />
</div>
);
}
```
- [ ] **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 `<div style={{ flex: 1, minHeight: 0 }}>` that holds `LayoutEngine`), making that container `position: "relative"` and adding:
```tsx
{searchOpen && active && <SearchBar surfaceId={effectiveFocus} onClose={() => setSearchOpen(false)} />}
```
Concretely, change that wrapper to `<div style={{ flex: 1, minHeight: 0, position: "relative" }}>` 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.
@@ -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 · <uptime>` 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<string, SearchAddon>` 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<SurfaceId>` (at most one zoomed panel per workspace; `None` = normal grid).
- New `Cmd::SetZoom { workspace_id: WorkspaceId, surface_id: Option<SurfaceId> }``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.
+7
View File
@@ -12,6 +12,7 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-notification": "^2",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
@@ -1462,6 +1463,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "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": { "node_modules/@xterm/addon-webgl": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-notification": "^2",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
+16
View File
@@ -299,6 +299,17 @@ pub async fn focus(state: BridgeState<'_>, surface_id: String) -> Result<Value,
data_of(state.request(Cmd::Focus { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) data_of(state.request(Cmd::Focus { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
} }
// ---- SP4 zoom command ----
#[tauri::command]
pub async fn set_zoom(state: BridgeState<'_>, workspace_id: String, surface_id: Option<String>) -> Result<Value, String> {
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 ---- // ---- M3 event log commands ----
#[tauri::command] #[tauri::command]
@@ -311,3 +322,8 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result<Value, S
let target: spacesh_proto::MarkReadTarget = serde_json::from_value(target).map_err(|e| format!("invalid mark_read target: {e}"))?; let target: spacesh_proto::MarkReadTarget = serde_json::from_value(target).map_err(|e| format!("invalid mark_read target: {e}"))?;
data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?) data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?)
} }
#[tauri::command]
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
}
+2
View File
@@ -49,8 +49,10 @@ pub fn run() {
bridge::set_group, bridge::set_group,
bridge::delete_group, bridge::delete_group,
bridge::focus, bridge::focus,
bridge::set_zoom,
bridge::event_log, bridge::event_log,
bridge::mark_read, bridge::mark_read,
bridge::health,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running spacesh"); .expect("error while running spacesh");
+35 -8
View File
@@ -3,12 +3,13 @@ import { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar"; import { CenterToolbar } from "./CenterToolbar";
import { SearchBar } from "./SearchBar";
import { Wizard } from "./Wizard"; import { Wizard } from "./Wizard";
import { EventCenter } from "./EventCenter"; import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify"; import { maybeNotify } from "./notify";
import { COLORS } from "./theme"; import { COLORS } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead } from "./socketBridge"; import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth } from "./socketBridge";
import type { EventRecord } from "./socketBridge"; import type { EventRecord, DaemonHealth } from "./socketBridge";
import { leafIds } from "./layoutTypes"; import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -21,7 +22,11 @@ export function App() {
const [events, setEvents] = useState<EventRecord[]>([]); const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false); const [wizard, setWizard] = useState(false);
const [eventsOpen, setEventsOpen] = useState(true); const [eventsOpen, setEventsOpen] = useState(true);
const [health, setHealth] = useState<DaemonHealth | null>(null);
const [connected, setConnected] = useState(false);
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchNonce, setSearchNonce] = useState(0);
const activeRef = useRef<string | null>(null); const activeRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]); const wsRef = useRef<WorkspaceView[]>([]);
activeRef.current = activeId; activeRef.current = activeId;
@@ -49,12 +54,18 @@ export function App() {
if (!activeRef.current && st.workspaces.length) setActiveId(st.workspaces[0].id); 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 => const wsOf = (surfaceId: string): WorkspaceView | undefined =>
wsRef.current.find((w) => surfaceId in w.surfaces); wsRef.current.find((w) => surfaceId in w.surfaces);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
void seedEvents(); void seedEvents();
void loadHealth();
const unlisten = onDaemonEvent((evt) => { const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "event") { if (evt.evt === "event") {
const rec = evt.data.record; const rec = evt.data.record;
@@ -74,9 +85,24 @@ export function App() {
void refresh(); 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()); }; 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 unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
const active = workspaces.find((w) => w.id === activeId) ?? null; const active = workspaces.find((w) => w.id === activeId) ?? null;
@@ -93,15 +119,16 @@ export function App() {
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}> <div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} /> <TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}> <div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} /> <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && ( {active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} /> <CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} />
)} )}
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active {active
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} /> ? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} />
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>} : <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
{searchOpen && active && <SearchBar surfaceId={effectiveFocus} reopenNonce={searchNonce} onClose={() => setSearchOpen(false)} />}
</div> </div>
</div> </div>
{eventsOpen && ( {eventsOpen && (
+3 -2
View File
@@ -3,13 +3,14 @@ import { COLORS, FONT } from "./theme";
import { PresetPicker } from "./PresetPicker"; import { PresetPicker } from "./PresetPicker";
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */ /** 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 ( return (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}> <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<PresetPicker selected={selected} onSelect={onSelect} /> <PresetPicker selected={selected} onSelect={onSelect} />
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<div <div
title="Search scrollback (mock)" title="Search scrollback"
onClick={onOpenSearch}
style={{ style={{
display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6, display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6,
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer", background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer",
+32 -11
View File
@@ -1,10 +1,10 @@
import { useRef } from "react"; import { useRef } from "react";
import { Maximize2, RotateCw } from "lucide-react"; import { Maximize2, Minimize2, RotateCw } from "lucide-react";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { StatusRing } from "./StatusRing"; import { StatusRing } from "./StatusRing";
import { COLORS, FONT, STATE_COLOR } from "./theme"; import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
import { setRatios, restartSurface } from "./socketBridge"; import { setRatios, restartSurface, setZoom } from "./socketBridge";
interface Props { interface Props {
workspaceId: string; workspaceId: string;
@@ -15,6 +15,7 @@ interface Props {
surfaces: Record<string, SurfaceView>; surfaces: Record<string, SurfaceView>;
focusedId: string | null; focusedId: string | null;
onFocus: (id: string) => void; onFocus: (id: string) => void;
zoomed: string | null;
} }
/** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */ /** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
@@ -23,21 +24,29 @@ function shortPath(cwd: string): string {
return leaf ? `~/${leaf}` : cwd; 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) { if (!layout) {
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>; return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
} }
if (zoomed) {
return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node workspaceId={workspaceId} node={{ leaf: { surface_id: zoomed } }} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
</div>
);
}
return ( return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}> <div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} /> <Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
</div> </div>
); );
} }
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[]; workspaceId: string; node: LayoutNode; path: number[];
running: Record<string, boolean>; states: Record<string, SurfaceState>; running: Record<string, boolean>; states: Record<string, SurfaceState>;
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void; surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
zoomed: string | null;
}) { }) {
if ("leaf" in node) { if ("leaf" in node) {
const id = node.leaf.surface_id; const id = node.leaf.surface_id;
@@ -60,10 +69,18 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o
return card( return card(
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div> <div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
<button onClick={() => void restartSurface(id)} <div style={{ display: "flex", gap: 8 }}>
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}> <button onClick={() => void restartSurface(id)}
<RotateCw size={13} /> Restart style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
</button> <RotateCw size={13} /> Restart
</button>
{zoomed === id && (
<button onClick={() => void setZoom(workspaceId, null)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<Minimize2 size={13} /> Exit zoom
</button>
)}
</div>
</div> </div>
); );
} }
@@ -81,7 +98,11 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}> <span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
{state} {state}
</span> </span>
<Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom (mock)" /> {zoomed === id
? <Minimize2 size={13} color={COLORS.textSecondary} style={{ cursor: "pointer" }} aria-label="Unzoom"
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
</div> </div>
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} /> <TerminalView key={id} surfaceId={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); next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
void setRatios(workspaceId, path, next); void setRatios(workspaceId, path, next);
}}> }}>
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} /> <Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
</Pane> </Pane>
))} ))}
</div> </div>
+137
View File
@@ -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<HTMLInputElement>(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 (
<div
style={{
position: "absolute",
top: 8,
right: 16,
zIndex: 50,
display: "flex",
alignItems: "center",
gap: 6,
height: 32,
padding: "0 8px",
background: COLORS.bgElevated,
border: `1px solid ${COLORS.borderStrong}`,
borderRadius: 8,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
>
<input
ref={inputRef}
value={term}
onChange={(e) => {
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,
}}
/>
<span
style={{
fontFamily: FONT.mono,
fontSize: 11,
color: COLORS.textMuted,
minWidth: 40,
textAlign: "right",
}}
>
{count.total > 0 ? `${count.index + 1}/${count.total}` : "0/0"}
</span>
<ChevronUp
size={15}
color={COLORS.textSecondary}
style={{ cursor: "pointer" }}
onClick={() => run(false)}
/>
<ChevronDown
size={15}
color={COLORS.textSecondary}
style={{ cursor: "pointer" }}
onClick={() => run(true)}
/>
<X
size={15}
color={COLORS.textMuted}
style={{ cursor: "pointer" }}
onClick={onClose}
/>
</div>
);
}
+23 -7
View File
@@ -1,7 +1,16 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight } from "lucide-react"; import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { COLORS, FONT, STATE_COLOR } from "./theme"; import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; 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" { function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"]; const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
@@ -14,15 +23,22 @@ function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
} }
export function Sidebar({ export function Sidebar({
groups, workspaces, activeId, onSelect, onNew, groups, workspaces, activeId, onSelect, onNew, health, connected,
}: { }: {
groups: Group[]; groups: Group[];
workspaces: WorkspaceView[]; workspaces: WorkspaceView[];
activeId: string | null; activeId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onNew: () => void; onNew: () => void;
health: DaemonHealth | null;
connected: boolean;
}) { }) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
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 byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order);
const ungrouped = byGroup(null); const ungrouped = byGroup(null);
@@ -76,12 +92,12 @@ export function Sidebar({
{ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>} {ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>}
</div> </div>
{/* Daemon status footer — uptime is mocked until the daemon reports it. */} <div title={health ? `spaceshd v${health.version} · pid ${health.pid}` : "daemon offline"}
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}> style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.stDone, flex: "0 0 7px" }} /> <span style={{ width: 7, height: 7, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 7px" }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>spaceshd · live</span> <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>{connected ? "spaceshd · live" : "spaceshd · offline"}</span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>3d 4h</span> <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>{health ? fmtUptime(health.started_at_ms) : ""}</span>
</div> </div>
</div> </div>
); );
+8 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { WebglAddon } from "@xterm/addon-webgl"; import { WebglAddon } from "@xterm/addon-webgl";
import { SearchAddon } from "@xterm/addon-search";
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge"; import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
import { registerSearch, unregisterSearch } from "./searchRegistry";
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@@ -11,7 +13,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
useEffect(() => { useEffect(() => {
if (!ref.current) return; 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 { try {
term.loadAddon(new WebglAddon()); term.loadAddon(new WebglAddon());
} catch { } catch {
@@ -19,6 +21,10 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
} }
term.open(ref.current); term.open(ref.current);
const search = new SearchAddon();
term.loadAddon(search);
registerSearch(surfaceId, search);
// Input → daemon. // Input → daemon.
const inputDisposable = term.onData((data) => { const inputDisposable = term.onData((data) => {
void sendInput(surfaceId, encoder.encode(data)); void sendInput(surfaceId, encoder.encode(data));
@@ -42,6 +48,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
disposed = true; disposed = true;
inputDisposable.dispose(); inputDisposable.dispose();
void detachSurface(surfaceId); void detachSurface(surfaceId);
unregisterSearch(surfaceId);
term.dispose(); term.dispose();
}; };
}, [surfaceId]); }, [surfaceId]);
+1
View File
@@ -35,6 +35,7 @@ export interface WorkspaceView {
order: number; order: number;
unread: boolean; unread: boolean;
layout: LayoutNode | null; layout: LayoutNode | null;
zoomed: string | null;
surfaces: Record<string, SurfaceView>; surfaces: Record<string, SurfaceView>;
} }
+17
View File
@@ -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<string, SearchAddon>();
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);
}
+10
View File
@@ -175,3 +175,13 @@ export async function deleteGroup(groupId: string): Promise<void> {
export async function closeSurfaceCmd(surfaceId: string): Promise<void> { export async function closeSurfaceCmd(surfaceId: string): Promise<void> {
await invoke("close_surface", { surfaceId }); await invoke("close_surface", { surfaceId });
} }
export interface DaemonHealth { version: string; pid: number; started_at_ms: number }
export async function getHealth(): Promise<DaemonHealth> {
return await invoke<DaemonHealth>("health");
}
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
await invoke("set_zoom", { workspaceId, surfaceId });
}
+1
View File
@@ -18,6 +18,7 @@ export const COLORS = {
stDone: "#3FB950", stDone: "#3FB950",
stError: "#F4544E", stError: "#F4544E",
stIdle: "#5A6573", stIdle: "#5A6573",
searchMatch: "#5A4A1F",
} as const; } as const;
export const FONT = { export const FONT = {
+27
View File
@@ -122,6 +122,12 @@ pub enum Cmd {
limit: Option<u32>, limit: Option<u32>,
}, },
MarkRead { target: MarkReadTarget }, MarkRead { target: MarkReadTarget },
SetZoom {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
surface_id: Option<SurfaceId>,
},
Health,
Status, Status,
Shutdown, Shutdown,
} }
@@ -321,6 +327,27 @@ mod tests {
assert_eq!(back, evt); 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::<Envelope>(&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::<Envelope>(&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] #[test]
fn event_log_cmd_no_limit_round_trips() { fn event_log_cmd_no_limit_round_trips() {
let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } }; let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } };
+17
View File
@@ -42,6 +42,9 @@ pub struct Workspace {
/// None = empty workspace (no panels yet). /// None = empty workspace (no panels yet).
#[serde(default)] #[serde(default)]
pub layout: Option<LayoutNode>, pub layout: Option<LayoutNode>,
/// The single maximized surface for this workspace, if any.
#[serde(default)]
pub zoomed: Option<SurfaceId>,
pub surfaces: HashMap<SurfaceId, SurfaceSpec>, pub surfaces: HashMap<SurfaceId, SurfaceSpec>,
} }
@@ -66,6 +69,8 @@ pub struct WorkspaceView {
pub order: u32, pub order: u32,
pub unread: bool, pub unread: bool,
pub layout: Option<LayoutNode>, pub layout: Option<LayoutNode>,
#[serde(default)]
pub zoomed: Option<SurfaceId>,
pub surfaces: HashMap<SurfaceId, SurfaceView>, pub surfaces: HashMap<SurfaceId, SurfaceView>,
} }
@@ -99,10 +104,22 @@ mod tests {
order: 0, order: 0,
unread: false, unread: false,
layout: None, layout: None,
zoomed: None,
surfaces: HashMap::new(), surfaces: HashMap::new(),
}; };
let j = serde_json::to_string(&w).unwrap(); let j = serde_json::to_string(&w).unwrap();
let back: Workspace = serde_json::from_str(&j).unwrap(); let back: Workspace = serde_json::from_str(&j).unwrap();
assert_eq!(back, w); 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);
}
} }
+18 -2
View File
@@ -51,7 +51,7 @@ impl Registry {
let order = self.workspaces.len() as u32; let order = self.workspaces.len() as u32;
self.workspaces.insert(id.clone(), Workspace { self.workspaces.insert(id.clone(), Workspace {
id: id.clone(), path: key.clone(), name, group_id: None, order, 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()); self.by_path.insert(key, id.clone());
(id, true) (id, true)
@@ -95,6 +95,7 @@ impl Registry {
if let Some(w) = self.workspaces.get_mut(&ws) { if let Some(w) = self.workspaces.get_mut(&ws) {
w.surfaces.remove(sid); w.surfaces.remove(sid);
w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, 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 { WorkspaceView {
id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
group_id: w.group_id.clone(), order: w.order, unread: w.unread, 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<Group>, Vec<WorkspaceView>) { pub fn status(&self) -> (Vec<Group>, Vec<WorkspaceView>) {
@@ -189,6 +190,10 @@ impl Registry {
self.live.clear(); self.live.clear();
self.states.clear(); self.states.clear();
for w in state.workspaces { 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.by_path.insert(w.path.clone(), w.id.clone());
self.workspaces.insert(w.id.clone(), w); 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); 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] #[test]
fn drop_state_resets_to_idle() { fn drop_state_resets_to_idle() {
let mut r = Registry::new(); let mut r = Registry::new();
+157 -2
View File
@@ -62,9 +62,11 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<d
let initial = store.load().unwrap_or_default(); let initial = store.load().unwrap_or_default();
let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500)); let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500));
let event_initial = event_store.load().unwrap_or_default(); let event_initial = event_store.load().unwrap_or_default();
let started_at_ms = now_millis();
let shutdown = tokio::spawn(router( let shutdown = tokio::spawn(router(
router_rx, router_tx.clone(), exit_tx, state_tx, router_rx, router_tx.clone(), exit_tx, state_tx,
persister, initial, event_persister, event_initial, persister, initial, event_persister, event_initial,
started_at_ms,
)); ));
let mut next_client: ClientId = 0; let mut next_client: ClientId = 0;
@@ -125,6 +127,7 @@ async fn router(
initial: crate::state_store::PersistState, initial: crate::state_store::PersistState,
event_persister: EventPersister, event_persister: EventPersister,
event_initial: crate::event_log::EventLogState, event_initial: crate::event_log::EventLogState,
started_at_ms: u64,
) { ) {
let mut reg = Registry::new(); let mut reg = Registry::new();
reg.restore(initial); reg.restore(initial);
@@ -174,7 +177,7 @@ async fn router(
ServerMsg::Request { id, cmd, client, out } => { ServerMsg::Request { id, cmd, client, out } => {
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients,
&router_tx, &exit_tx, &state_tx, &persister, &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, persister: &Persister,
event_log: &mut EventLog, event_log: &mut EventLog,
event_persister: &EventPersister, event_persister: &EventPersister,
started_at_ms: u64,
) { ) {
use spacesh_proto::message::SplitDir; use spacesh_proto::message::SplitDir;
use spacesh_proto::layout::{LayoutNode, Orient}; use spacesh_proto::layout::{LayoutNode, Orient};
@@ -568,7 +572,12 @@ async fn handle_request(
crate::hooks::cleanup(&surface_id); crate::hooks::cleanup(&surface_id);
crate::hooks::cleanup_shell(&surface_id); crate::hooks::cleanup_shell(&surface_id);
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() })); 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()); persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await; let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else { } 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 => { Cmd::Status => {
let (groups, workspaces) = reg.status(); let (groups, workspaces) = reg.status();
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; 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; let log = req(&mut s, 6, Cmd::EventLog { limit: None }).await;
assert_eq!(res_data(&log)["unread"].as_u64().unwrap(), 0); 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<dyn crate::state_store::StateStore> =
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<dyn crate::state_store::StateStore> =
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<dyn crate::state_store::StateStore> =
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");
}
} }
+1
View File
@@ -93,6 +93,7 @@ mod tests {
order: 0, order: 0,
unread: false, unread: false,
layout: None, layout: None,
zoomed: None,
surfaces: std::collections::HashMap::new(), surfaces: std::collections::HashMap::new(),
}], }],
} }