f18d929c10
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
810 lines
32 KiB
Markdown
810 lines
32 KiB
Markdown
# 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.
|