Merge spacesh M2: layouts, split tree, persistence, sidebar, presets
n-ary split layouts + LayoutEngine resize, multi-workspace structure with groups/unread/order, 10 presets + wizard, disk persistence (state.json) with cold-start restore (panels stopped). 56 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -719,6 +719,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"serde",
|
||||
"spacesh-proto",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2636,5 +2636,5 @@ git commit -m "feat(app): sidebar, preset picker, wizard, App rewired around wor
|
||||
- **Out of slice:** status rings colored by agent state (M3), auto-unread from events (M3), zoom/search/diff/notifications (M5), remote (M6), auth (separate). The status ring in Sidebar is a static placeholder.
|
||||
- **Two documented partials within M2** (model + protocol fully support both; only the wiring is deferred to keep the slice focused — pick them up if time allows):
|
||||
1. **Cold-start autostart honoring.** `SurfaceSpec.autostart` is persisted and restored, but the router does not auto-`restart_surface` autostart panels on cold start (default is off and no M2 UI sets it true, so the path is untestable end-to-end this slice). To finish: after `reg.restore(initial)` in `router`, iterate restored surfaces with `spec.autostart == true` and spawn each via `spawn_from_spec` + `spawn_output_bridge` + `set_live`, emitting `surface_restarted`.
|
||||
2. **Sidebar drag-reorder UI.** `set_workspace_meta { order, group_id }` and `set_group { order }` exist and are wired in the bridge; the `Sidebar` component renders groups/order but does not yet emit HTML5 drag handlers. To finish: add `draggable` rows + `onDrop` calling `setWorkspaceMeta`/`setGroup` with the new order. Reordering is fully functional via command today; only the drag affordance is missing.
|
||||
2. **Sidebar drag-reorder UI.** The full command surface is wired in the bridge — `set_workspace_meta { order, group_id }`, `set_group { name, color, order }`, `delete_group`, `create_group` — so workspace and group reordering/editing are functional via command today. The deferred piece is only the GUI affordance: the `Sidebar` component renders groups/order/unread/count but does not yet emit HTML5 drag handlers. To finish: add `draggable` rows + `onDrop` calling `setWorkspaceMeta`/`setGroup` with the new order.
|
||||
```
|
||||
|
||||
@@ -7,6 +7,8 @@ use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
use spacesh_proto::codec::{read_frame, write_frame};
|
||||
use spacesh_proto::message::{SplitDir, Edge, PresetSlot};
|
||||
use spacesh_proto::ids::{GroupId, WorkspaceId};
|
||||
use spacesh_proto::{Cmd, Envelope, Evt, SurfaceId};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
@@ -193,3 +195,63 @@ pub async fn status(state: BridgeState<'_>) -> Result<Value, String> {
|
||||
pub async fn close_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
// ---- M2 commands ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn split_surface(state: BridgeState<'_>, surface_id: String, dir: String, command: Option<String>, args: Vec<String>) -> Result<Value, String> {
|
||||
let dir = if dir == "down" { SplitDir::Down } else { SplitDir::Right };
|
||||
data_of(state.request(Cmd::SplitSurface { surface_id: SurfaceId(surface_id), dir, command, args }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_ratios(state: BridgeState<'_>, workspace_id: String, node_path: Vec<u32>, ratios: Vec<f32>) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::SetRatios { workspace_id: WorkspaceId(workspace_id), node_path, ratios }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn move_surface(state: BridgeState<'_>, surface_id: String, target_surface_id: String, edge: String) -> Result<Value, String> {
|
||||
let edge = match edge.as_str() { "left" => Edge::Left, "top" => Edge::Top, "bottom" => Edge::Bottom, _ => Edge::Right };
|
||||
data_of(state.request(Cmd::MoveSurface { surface_id: SurfaceId(surface_id), target_surface_id: SurfaceId(target_surface_id), edge }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_id: String, slots: Vec<PresetSlot>) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::ApplyPreset { workspace_id: WorkspaceId(workspace_id), preset_id, slots }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_workspace(state: BridgeState<'_>, workspace_id: String) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option<String>, group_id: Option<String>, unread: Option<bool>, order: Option<u32>) -> Result<Value, String> {
|
||||
// group_id: None from JS means "no change"; an explicit null is sent as Some("") to mean "ungroup".
|
||||
let gid = match group_id {
|
||||
None => None,
|
||||
Some(s) if s.is_empty() => Some(None),
|
||||
Some(s) => Some(Some(GroupId(s))),
|
||||
};
|
||||
data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_group(state: BridgeState<'_>, name: String, color: String) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::CreateGroup { name, color }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_group(state: BridgeState<'_>, group_id: String, name: Option<String>, color: Option<String>, order: Option<u32>) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::SetGroup { group_id: GroupId(group_id), name, color, order }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result<Value, String> {
|
||||
data_of(state.request(Cmd::DeleteGroup { group_id: GroupId(group_id) }).await.map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ pub fn run() {
|
||||
bridge::detach,
|
||||
bridge::status,
|
||||
bridge::close_surface,
|
||||
bridge::split_surface,
|
||||
bridge::set_ratios,
|
||||
bridge::move_surface,
|
||||
bridge::apply_preset,
|
||||
bridge::restart_surface,
|
||||
bridge::close_workspace,
|
||||
bridge::set_workspace_meta,
|
||||
bridge::create_group,
|
||||
bridge::set_group,
|
||||
bridge::delete_group,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running spacesh");
|
||||
|
||||
+41
-56
@@ -1,68 +1,53 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { SurfaceList } from "./SurfaceList";
|
||||
import { openWorkspace, newSurface, getStatus, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { LayoutEngine } from "./LayoutEngine";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { PresetPicker } from "./PresetPicker";
|
||||
import { Wizard } from "./Wizard";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
|
||||
import type { Group, WorkspaceView } from "./layoutTypes";
|
||||
|
||||
export function App() {
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState<Record<string, boolean>>({});
|
||||
const [wizard, setWizard] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const st = await getStatusFull();
|
||||
setGroups(st.groups);
|
||||
setWorkspaces(st.workspaces);
|
||||
const run: Record<string, boolean> = {};
|
||||
st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; }));
|
||||
setRunning(run);
|
||||
if (!activeId && st.workspaces.length) setActiveId(st.workspaces[0].id);
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const ws = await getStatus();
|
||||
const flat = ws.flatMap((w) => w.surfaces);
|
||||
setSurfaces(flat);
|
||||
if (flat.length) setActive(flat[0]);
|
||||
})();
|
||||
void refresh();
|
||||
const unlisten = onDaemonEvent(() => { void refresh(); });
|
||||
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); });
|
||||
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
|
||||
}, [refresh]);
|
||||
|
||||
const unlisten = onDaemonEvent((evt) => {
|
||||
if (evt.evt === "surface_created") {
|
||||
setSurfaces((s) => [...s, evt.data.surface_id]);
|
||||
} else if (evt.evt === "surface_closed" || evt.evt === "exit") {
|
||||
// exit leaves the surface visible; surface_closed removes it.
|
||||
if (evt.evt === "surface_closed") {
|
||||
setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => {
|
||||
// Force a remount of the active TerminalView by toggling the key.
|
||||
setActive((cur) => cur);
|
||||
void getStatus().then((ws) => {
|
||||
const flat = ws.flatMap((w) => w.surfaces);
|
||||
setSurfaces(flat);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlisten.then((f) => f());
|
||||
void reconnect.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleNewSurface() {
|
||||
let ws = workspaceId;
|
||||
if (!ws) {
|
||||
ws = await openWorkspace(".");
|
||||
setWorkspaceId(ws);
|
||||
}
|
||||
const id = await newSurface(ws, 80, 24);
|
||||
setActive(id);
|
||||
}
|
||||
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", background: "#000" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 160 }}>
|
||||
<button onClick={handleNewSurface} style={{ margin: 8 }}>
|
||||
+ surface
|
||||
</button>
|
||||
<SurfaceList surfaces={surfaces} active={active} onSelect={setActive} />
|
||||
<div style={{ display: "flex", height: "100vh", background: "#0E1116" }}>
|
||||
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={setActiveId} onNew={() => setWizard(true)} />
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
{active && (
|
||||
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
|
||||
<PresetPicker selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{active ? <TerminalView key={active} surfaceId={active} /> : <div style={{ color: "#666", padding: 16 }}>no surface</div>}
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{active
|
||||
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} />
|
||||
: <div style={{ color: "#666", padding: 24 }}>No workspace — create one to begin.</div>}
|
||||
</div>
|
||||
</div>
|
||||
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useRef } from "react";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import type { LayoutNode } from "./layoutTypes";
|
||||
import { setRatios, restartSurface } from "./socketBridge";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
layout: LayoutNode | null;
|
||||
/** surface_id -> running flag, from the latest status/events. */
|
||||
running: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function LayoutEngine({ workspaceId, layout, running }: Props) {
|
||||
if (!layout) {
|
||||
return <div style={{ color: "#666", padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
||||
}
|
||||
return <Node workspaceId={workspaceId} node={layout} path={[]} running={running} />;
|
||||
}
|
||||
|
||||
function Node({ workspaceId, node, path, running }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record<string, boolean> }) {
|
||||
if ("leaf" in node) {
|
||||
const id = node.leaf.surface_id;
|
||||
if (running[id] === false) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", background: "#0A0D12", color: "#8B97A6", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ fontFamily: "monospace", fontSize: 13 }}>Process exited</div>
|
||||
<button onClick={() => void restartSurface(id)} style={{ padding: "6px 14px" }}>⏎ Restart</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TerminalView key={id} surfaceId={id} />;
|
||||
}
|
||||
|
||||
const { orient, ratios, children } = node.split;
|
||||
const dir = orient === "h" ? "row" : "column";
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
|
||||
{children.map((child, i) => (
|
||||
<Pane key={i} grow={ratios[i] ?? 1} isLast={i === children.length - 1} orient={orient}
|
||||
onResize={(deltaFrac) => {
|
||||
const next = [...ratios];
|
||||
next[i] = Math.max(0.05, next[i] + deltaFrac);
|
||||
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
|
||||
void setRatios(workspaceId, path, next);
|
||||
}}>
|
||||
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} />
|
||||
</Pane>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const startDrag = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const parent = ref.current?.parentElement;
|
||||
if (!parent) return;
|
||||
const total = orient === "h" ? parent.clientWidth : parent.clientHeight;
|
||||
const start = orient === "h" ? e.clientX : e.clientY;
|
||||
let last = start;
|
||||
const move = (ev: MouseEvent) => {
|
||||
const cur = orient === "h" ? ev.clientX : ev.clientY;
|
||||
const delta = (cur - last) / total;
|
||||
last = cur;
|
||||
onResize(delta);
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} style={{ flexGrow: grow, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "hidden", position: "relative" }}>
|
||||
{children}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div onMouseDown={startDrag}
|
||||
style={{
|
||||
flex: "0 0 4px",
|
||||
cursor: orient === "h" ? "col-resize" : "row-resize",
|
||||
background: "#232A33",
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const PRESETS: { id: string; label: string; slots: number }[] = [
|
||||
{ id: "1", label: "1", slots: 1 },
|
||||
{ id: "2lr", label: "2↔", slots: 2 },
|
||||
{ id: "2tb", label: "2↕", slots: 2 },
|
||||
{ id: "2+1", label: "2+1", slots: 3 },
|
||||
{ id: "1+2", label: "1+2", slots: 3 },
|
||||
{ id: "3", label: "3", slots: 3 },
|
||||
{ id: "2x2", label: "2×2", slots: 4 },
|
||||
{ id: "4", label: "4", slots: 4 },
|
||||
{ id: "2x3", label: "2×3", slots: 6 },
|
||||
{ id: "2x4", label: "2×4", slots: 8 },
|
||||
];
|
||||
|
||||
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{PRESETS.map((p) => (
|
||||
<button key={p.id} onClick={() => onSelect(p.id)}
|
||||
style={{
|
||||
padding: "6px 10px", borderRadius: 6, fontFamily: "monospace", fontSize: 12,
|
||||
background: p.id === selected ? "#1A2029" : "transparent",
|
||||
border: p.id === selected ? "1px solid #4C8DFF" : "1px solid #232A33",
|
||||
color: p.id === selected ? "#E6EDF3" : "#8B97A6", cursor: "pointer",
|
||||
}}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Group, WorkspaceView } from "./layoutTypes";
|
||||
|
||||
export function Sidebar({
|
||||
groups, workspaces, activeId, onSelect, onNew,
|
||||
}: {
|
||||
groups: Group[];
|
||||
workspaces: WorkspaceView[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
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 row = (w: WorkspaceView) => (
|
||||
<div key={w.id} onClick={() => onSelect(w.id)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 9, padding: "6px 8px", borderRadius: 6, cursor: "pointer",
|
||||
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13,
|
||||
color: w.id === activeId ? "#E6EDF3" : "#8B97A6",
|
||||
}}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", border: "2px solid #5A6573" }} />
|
||||
<span style={{ flex: 1 }}>{w.name}</span>
|
||||
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4C8DFF" }} />}
|
||||
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#5A6573" }}>{Object.keys(w.surfaces).length}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ width: 248, background: "#13171F", height: "100%", padding: 14, boxSizing: "border-box", overflowY: "auto" }}>
|
||||
<button onClick={onNew} style={{ width: "100%", padding: 8, marginBottom: 16, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 7 }}>+ New workspace</button>
|
||||
{groups.sort((a, b) => a.order - b.order).map((g) => (
|
||||
<div key={g.id} style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "0 4px", marginBottom: 4 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
|
||||
<span style={{ fontFamily: "Inter", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: "#8B97A6" }}>{g.name.toUpperCase()}</span>
|
||||
</div>
|
||||
{byGroup(g.id).map(row)}
|
||||
</div>
|
||||
))}
|
||||
{ungrouped.length > 0 && <div style={{ marginTop: 8 }}>{ungrouped.map(row)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
import { PresetPicker, PRESETS } from "./PresetPicker";
|
||||
import { openWorkspace, applyPreset } from "./socketBridge";
|
||||
|
||||
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
|
||||
const [path, setPath] = useState(".");
|
||||
const [preset, setPreset] = useState("2x2");
|
||||
const [agents, setAgents] = useState<string[]>([]);
|
||||
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
|
||||
const agentChoices = ["shell", "claude", "codex", "gemini"];
|
||||
|
||||
async function create() {
|
||||
const ws = await openWorkspace(path);
|
||||
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
||||
const a = agents[i] ?? "shell";
|
||||
return a === "shell" ? {} : { command: a };
|
||||
});
|
||||
await applyPreset(ws, preset, slotSpecs);
|
||||
onDone(ws);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", inset: 0, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 480, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
|
||||
<input value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
|
||||
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
|
||||
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
|
||||
{Array.from({ length: slots }, (_, i) => (
|
||||
<select key={i} value={agents[i] ?? "shell"} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
|
||||
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
|
||||
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
|
||||
<button onClick={() => void create()} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>Create workspace</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type Orient = "h" | "v";
|
||||
|
||||
export type LayoutNode =
|
||||
| { leaf: { surface_id: string } }
|
||||
| { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } };
|
||||
|
||||
export interface SurfaceView {
|
||||
spec: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
agent_label: string | null;
|
||||
cols: number;
|
||||
rows: number;
|
||||
autostart: boolean;
|
||||
};
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceView {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
group_id: string | null;
|
||||
order: number;
|
||||
unread: boolean;
|
||||
layout: LayoutNode | null;
|
||||
surfaces: Record<string, SurfaceView>;
|
||||
}
|
||||
|
||||
export function leafIds(node: LayoutNode | null): string[] {
|
||||
if (!node) return [];
|
||||
if ("leaf" in node) return [node.leaf.surface_id];
|
||||
return node.split.children.flatMap(leafIds);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { Group, WorkspaceView } from "./layoutTypes";
|
||||
|
||||
export interface WorkspaceStatus {
|
||||
workspace_id: string;
|
||||
@@ -73,3 +74,70 @@ export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() =>
|
||||
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
|
||||
return listen(name, () => handler());
|
||||
}
|
||||
|
||||
// ---- M2 additions ----
|
||||
|
||||
export interface StatusResult {
|
||||
groups: Group[];
|
||||
workspaces: WorkspaceView[];
|
||||
}
|
||||
|
||||
export async function getStatusFull(): Promise<StatusResult> {
|
||||
return await invoke<StatusResult>("status");
|
||||
}
|
||||
|
||||
export async function splitSurface(surfaceId: string, dir: "right" | "down", command?: string, args: string[] = []): Promise<string> {
|
||||
const data = await invoke<{ surface_id: string }>("split_surface", { surfaceId, dir, command: command ?? null, args });
|
||||
return data.surface_id;
|
||||
}
|
||||
|
||||
export async function setRatios(workspaceId: string, nodePath: number[], ratios: number[]): Promise<void> {
|
||||
await invoke("set_ratios", { workspaceId, nodePath, ratios });
|
||||
}
|
||||
|
||||
export async function moveSurface(surfaceId: string, targetSurfaceId: string, edge: "left" | "right" | "top" | "bottom"): Promise<void> {
|
||||
await invoke("move_surface", { surfaceId, targetSurfaceId, edge });
|
||||
}
|
||||
|
||||
export async function applyPreset(workspaceId: string, presetId: string, slots: { command?: string; args?: string[] }[]): Promise<string[]> {
|
||||
const data = await invoke<{ surface_ids: string[] }>("apply_preset", {
|
||||
workspaceId, presetId,
|
||||
slots: slots.map((s) => ({ command: s.command ?? null, args: s.args ?? [] })),
|
||||
});
|
||||
return data.surface_ids;
|
||||
}
|
||||
|
||||
export async function restartSurface(surfaceId: string): Promise<void> {
|
||||
await invoke("restart_surface", { surfaceId });
|
||||
}
|
||||
|
||||
export async function closeWorkspaceCmd(workspaceId: string): Promise<void> {
|
||||
await invoke("close_workspace", { workspaceId });
|
||||
}
|
||||
|
||||
export async function setWorkspaceMeta(workspaceId: string, meta: { name?: string; groupId?: string | null; unread?: boolean; order?: number }): Promise<void> {
|
||||
await invoke("set_workspace_meta", {
|
||||
workspaceId,
|
||||
name: meta.name ?? null,
|
||||
groupId: meta.groupId === undefined ? null : meta.groupId,
|
||||
unread: meta.unread ?? null,
|
||||
order: meta.order ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createGroup(name: string, color: string): Promise<string> {
|
||||
const data = await invoke<{ group_id: string }>("create_group", { name, color });
|
||||
return data.group_id;
|
||||
}
|
||||
|
||||
export async function setGroup(groupId: string, meta: { name?: string; color?: string; order?: number }): Promise<void> {
|
||||
await invoke("set_group", { groupId, name: meta.name ?? null, color: meta.color ?? null, order: meta.order ?? null });
|
||||
}
|
||||
|
||||
export async function deleteGroup(groupId: string): Promise<void> {
|
||||
await invoke("delete_group", { groupId });
|
||||
}
|
||||
|
||||
export async function closeSurfaceCmd(surfaceId: string): Promise<void> {
|
||||
await invoke("close_surface", { surfaceId });
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ version.workspace = true
|
||||
[dependencies]
|
||||
alacritty_terminal.workspace = true
|
||||
serde.workspace = true
|
||||
spacesh-proto = { path = "../spacesh-proto" }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod grid;
|
||||
pub mod ops;
|
||||
pub mod presets;
|
||||
pub mod snapshot;
|
||||
|
||||
pub use grid::GridSurface;
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Pure algorithms over `spacesh_proto::LayoutNode`. No I/O.
|
||||
use spacesh_proto::layout::{LayoutNode, Orient};
|
||||
use spacesh_proto::ids::SurfaceId;
|
||||
|
||||
/// Minimum ratio a panel may shrink to (5%).
|
||||
const MIN_RATIO: f32 = 0.05;
|
||||
|
||||
/// Collect all surface ids in the tree, left-to-right.
|
||||
pub fn leaves(node: &LayoutNode) -> Vec<SurfaceId> {
|
||||
let mut out = Vec::new();
|
||||
collect(node, &mut out);
|
||||
out
|
||||
}
|
||||
fn collect(node: &LayoutNode, out: &mut Vec<SurfaceId>) {
|
||||
match node {
|
||||
LayoutNode::Leaf { surface_id } => out.push(surface_id.clone()),
|
||||
LayoutNode::Split { children, .. } => children.iter().for_each(|c| collect(c, out)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the leaf `target` by inserting `new_id` as a sibling on `dir`.
|
||||
/// Returns true if the target was found and split.
|
||||
pub fn split_leaf(root: &mut LayoutNode, target: &SurfaceId, dir: Orient, after: bool, new_id: SurfaceId) -> bool {
|
||||
// If root itself is the target leaf, replace it with a split.
|
||||
if let LayoutNode::Leaf { surface_id } = root {
|
||||
if surface_id == target {
|
||||
let existing = root.clone();
|
||||
let new_leaf = LayoutNode::leaf(new_id);
|
||||
let children = if after { vec![existing, new_leaf] } else { vec![new_leaf, existing] };
|
||||
*root = LayoutNode::Split { orient: dir, ratios: even(children.len()), children };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if let LayoutNode::Split { orient, ratios, children } = root {
|
||||
// If a direct child is the target leaf AND this split matches `dir`, insert as sibling.
|
||||
if *orient == dir {
|
||||
if let Some(i) = children.iter().position(|c| is_leaf(c, target)) {
|
||||
children.insert(i + if after { 1 } else { 0 }, LayoutNode::leaf(new_id));
|
||||
*ratios = even(children.len());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Otherwise recurse.
|
||||
for c in children.iter_mut() {
|
||||
if split_leaf(c, target, dir, after, new_id.clone()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Remove the leaf `target`. Collapses empty/now-single-child splits and promotes
|
||||
/// single children. Returns the new root (None if the tree became empty).
|
||||
pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option<LayoutNode> {
|
||||
match root {
|
||||
LayoutNode::Leaf { surface_id } => {
|
||||
if &surface_id == target { None } else { Some(LayoutNode::Leaf { surface_id }) }
|
||||
}
|
||||
LayoutNode::Split { orient, children, .. } => {
|
||||
let kept: Vec<LayoutNode> = children
|
||||
.into_iter()
|
||||
.filter_map(|c| remove_leaf(c, target))
|
||||
.collect();
|
||||
match kept.len() {
|
||||
0 => None,
|
||||
1 => Some(kept.into_iter().next().unwrap()), // promote single child
|
||||
n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set ratios on the split node addressed by `path` (child indices from root).
|
||||
/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the
|
||||
/// path is invalid or the length does not match the node's child count.
|
||||
pub fn set_ratios(root: &mut LayoutNode, path: &[u32], ratios: &[f32]) -> bool {
|
||||
let Some(node) = node_at_mut(root, path) else { return false };
|
||||
if let LayoutNode::Split { ratios: r, children, .. } = node {
|
||||
if ratios.len() != children.len() {
|
||||
return false;
|
||||
}
|
||||
*r = normalize_clamp(ratios);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move leaf `src` to sit on `edge` of leaf `target`. Returns the new root.
|
||||
/// No-op (returns the original) if src == target or either is missing.
|
||||
pub fn move_leaf(root: LayoutNode, src: &SurfaceId, target: &SurfaceId, edge: spacesh_proto::message::Edge) -> LayoutNode {
|
||||
use spacesh_proto::message::Edge;
|
||||
if src == target || !contains(&root, src) || !contains(&root, target) {
|
||||
return root;
|
||||
}
|
||||
let Some(removed) = remove_leaf(root, src) else { return LayoutNode::leaf(src.clone()) };
|
||||
let (orient, after) = match edge {
|
||||
Edge::Left => (Orient::H, false),
|
||||
Edge::Right => (Orient::H, true),
|
||||
Edge::Top => (Orient::V, false),
|
||||
Edge::Bottom => (Orient::V, true),
|
||||
};
|
||||
let mut root = removed;
|
||||
split_leaf(&mut root, target, orient, after, src.clone());
|
||||
root
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
fn is_leaf(node: &LayoutNode, id: &SurfaceId) -> bool {
|
||||
matches!(node, LayoutNode::Leaf { surface_id } if surface_id == id)
|
||||
}
|
||||
fn contains(node: &LayoutNode, id: &SurfaceId) -> bool {
|
||||
leaves(node).iter().any(|s| s == id)
|
||||
}
|
||||
fn even(n: usize) -> Vec<f32> {
|
||||
vec![1.0 / n as f32; n]
|
||||
}
|
||||
fn normalize_clamp(ratios: &[f32]) -> Vec<f32> {
|
||||
// Two-pass: clamp all to MIN_RATIO, then normalize. If normalization would
|
||||
// bring any value back below MIN_RATIO, pin those and redistribute the rest.
|
||||
let n = ratios.len();
|
||||
if n == 0 { return vec![]; }
|
||||
let mut r: Vec<f32> = ratios.iter().map(|v| v.max(MIN_RATIO)).collect();
|
||||
// Iteratively pin items that would end up below MIN_RATIO after normalization.
|
||||
let mut pinned = vec![false; n];
|
||||
for _ in 0..n {
|
||||
let pin_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| **p).map(|(v, _)| *v).sum();
|
||||
let free_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| !**p).map(|(v, _)| *v).sum();
|
||||
let remaining = 1.0 - pin_sum;
|
||||
let mut changed = false;
|
||||
for i in 0..n {
|
||||
if pinned[i] { continue; }
|
||||
let normalized = if free_sum > 0.0 { r[i] / free_sum * remaining } else { remaining / n as f32 };
|
||||
if normalized < MIN_RATIO {
|
||||
r[i] = MIN_RATIO;
|
||||
pinned[i] = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed { break; }
|
||||
}
|
||||
// Final normalization of unpinned values.
|
||||
let pin_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| **p).map(|(v, _)| *v).sum();
|
||||
let free_sum: f32 = r.iter().zip(&pinned).filter(|(_, p)| !**p).map(|(v, _)| *v).sum();
|
||||
let remaining = (1.0 - pin_sum).max(0.0);
|
||||
let mut result = vec![0.0f32; n];
|
||||
for i in 0..n {
|
||||
if pinned[i] {
|
||||
result[i] = r[i];
|
||||
} else {
|
||||
result[i] = if free_sum > 0.0 { r[i] / free_sum * remaining } else { remaining / n as f32 };
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[u32]) -> Option<&'a mut LayoutNode> {
|
||||
let mut cur = root;
|
||||
for &idx in path {
|
||||
match cur {
|
||||
LayoutNode::Split { children, .. } => {
|
||||
cur = children.get_mut(idx as usize)?;
|
||||
}
|
||||
LayoutNode::Leaf { .. } => return None,
|
||||
}
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sid(s: &str) -> SurfaceId { SurfaceId(s.into()) }
|
||||
|
||||
#[test]
|
||||
fn split_root_leaf_creates_split() {
|
||||
let mut root = LayoutNode::leaf(sid("s_1"));
|
||||
assert!(split_leaf(&mut root, &sid("s_1"), Orient::H, true, sid("s_2")));
|
||||
assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_same_orient_appends_as_sibling() {
|
||||
let mut root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![0.5, 0.5],
|
||||
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
|
||||
};
|
||||
split_leaf(&mut root, &sid("s_2"), Orient::H, true, sid("s_3"));
|
||||
// 3 children in one row, even ratios.
|
||||
match &root {
|
||||
LayoutNode::Split { children, ratios, .. } => {
|
||||
assert_eq!(children.len(), 3);
|
||||
assert!((ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5);
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2"), sid("s_3")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_promotes_single_child() {
|
||||
let root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![0.5, 0.5],
|
||||
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
|
||||
};
|
||||
let after = remove_leaf(root, &sid("s_2")).unwrap();
|
||||
assert_eq!(after, LayoutNode::leaf(sid("s_1"))); // split collapsed to the surviving leaf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_leaf_returns_none() {
|
||||
let root = LayoutNode::leaf(sid("s_1"));
|
||||
assert!(remove_leaf(root, &sid("s_1")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_ratios_normalizes_and_clamps() {
|
||||
let mut root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![0.5, 0.5],
|
||||
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
|
||||
};
|
||||
assert!(set_ratios(&mut root, &[], &[0.0, 1.0]));
|
||||
if let LayoutNode::Split { ratios, .. } = &root {
|
||||
assert!(ratios[0] >= MIN_RATIO);
|
||||
assert!((ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_ratios_wrong_len_rejected() {
|
||||
let mut root = LayoutNode::Split {
|
||||
orient: Orient::H, ratios: vec![0.5, 0.5],
|
||||
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
|
||||
};
|
||||
assert!(!set_ratios(&mut root, &[], &[1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_leaf_to_right_of_target() {
|
||||
let root = LayoutNode::Split {
|
||||
orient: Orient::V, ratios: vec![0.5, 0.5],
|
||||
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
|
||||
};
|
||||
let after = move_leaf(root, &sid("s_1"), &sid("s_2"), spacesh_proto::message::Edge::Right);
|
||||
assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_onto_self_is_noop() {
|
||||
let root = LayoutNode::leaf(sid("s_1"));
|
||||
let after = move_leaf(root.clone(), &sid("s_1"), &sid("s_1"), spacesh_proto::message::Edge::Right);
|
||||
assert_eq!(after, root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The 10 layout presets (DOCS/MAIN.md §8.2). A preset maps a list of surface
|
||||
//! ids (one per slot, in order) to a LayoutNode. `slot_count` says how many
|
||||
//! panels the preset needs.
|
||||
use spacesh_proto::ids::SurfaceId;
|
||||
use spacesh_proto::layout::{LayoutNode, Orient};
|
||||
|
||||
/// Known preset ids and their panel counts.
|
||||
pub fn slot_count(preset_id: &str) -> Option<usize> {
|
||||
Some(match preset_id {
|
||||
"1" => 1,
|
||||
"2lr" => 2, // 2↔
|
||||
"2tb" => 2, // 2↕
|
||||
"2+1" => 3,
|
||||
"1+2" => 3,
|
||||
"3" => 3,
|
||||
"2x2" => 4,
|
||||
"4" => 4, // single row of 4
|
||||
"2x3" => 6,
|
||||
"2x4" => 8,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn leaf(id: &SurfaceId) -> LayoutNode { LayoutNode::leaf(id.clone()) }
|
||||
fn even(n: usize) -> Vec<f32> { vec![1.0 / n as f32; n] }
|
||||
|
||||
fn row(ids: &[SurfaceId]) -> LayoutNode {
|
||||
LayoutNode::Split { orient: Orient::H, ratios: even(ids.len()), children: ids.iter().map(leaf).collect() }
|
||||
}
|
||||
fn col(children: Vec<LayoutNode>) -> LayoutNode {
|
||||
LayoutNode::Split { orient: Orient::V, ratios: even(children.len()), children }
|
||||
}
|
||||
fn rown(children: Vec<LayoutNode>) -> LayoutNode {
|
||||
LayoutNode::Split { orient: Orient::H, ratios: even(children.len()), children }
|
||||
}
|
||||
|
||||
/// Build the preset tree from exactly `slot_count(preset_id)` ids.
|
||||
/// Returns None for an unknown id or wrong id count.
|
||||
pub fn build(preset_id: &str, ids: &[SurfaceId]) -> Option<LayoutNode> {
|
||||
if slot_count(preset_id)? != ids.len() {
|
||||
return None;
|
||||
}
|
||||
Some(match preset_id {
|
||||
"1" => leaf(&ids[0]),
|
||||
"2lr" => row(&ids),
|
||||
"2tb" => col(vec![leaf(&ids[0]), leaf(&ids[1])]),
|
||||
// left big column over... 2 stacked on the right.
|
||||
"2+1" => rown(vec![leaf(&ids[0]), col(vec![leaf(&ids[1]), leaf(&ids[2])])]),
|
||||
// one big on the left, 2 stacked on the right (mirror naming kept simple).
|
||||
"1+2" => rown(vec![col(vec![leaf(&ids[0]), leaf(&ids[1])]), leaf(&ids[2])]),
|
||||
"3" => row(&ids),
|
||||
"2x2" => col(vec![row(&ids[0..2]), row(&ids[2..4])]),
|
||||
"4" => row(&ids),
|
||||
"2x3" => col(vec![row(&ids[0..3]), row(&ids[3..6])]),
|
||||
"2x4" => col(vec![row(&ids[0..4]), row(&ids[4..8])]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ops::leaves;
|
||||
fn ids(n: usize) -> Vec<SurfaceId> { (0..n).map(|i| SurfaceId(format!("s_{i}"))).collect() }
|
||||
|
||||
#[test]
|
||||
fn all_presets_have_counts() {
|
||||
for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] {
|
||||
assert!(slot_count(p).is_some(), "missing count for {p}");
|
||||
}
|
||||
assert!(slot_count("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_uses_all_ids_in_order() {
|
||||
for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] {
|
||||
let n = slot_count(p).unwrap();
|
||||
let tree = build(p, &ids(n)).unwrap();
|
||||
assert_eq!(leaves(&tree), ids(n), "preset {p} must place all ids in order");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rejects_wrong_id_count() {
|
||||
assert!(build("2x2", &ids(3)).is_none());
|
||||
assert!(build("bogus", &ids(1)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_2x2_is_two_rows() {
|
||||
let tree = build("2x2", &ids(4)).unwrap();
|
||||
match tree {
|
||||
LayoutNode::Split { orient: Orient::V, children, .. } => {
|
||||
assert_eq!(children.len(), 2);
|
||||
for r in &children {
|
||||
matches!(r, LayoutNode::Split { orient: Orient::H, .. });
|
||||
}
|
||||
}
|
||||
_ => panic!("2x2 should be a vertical split of two horizontal rows"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,12 @@ impl std::fmt::Display for WorkspaceId {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct GroupId(pub String);
|
||||
|
||||
impl std::fmt::Display for GroupId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ids::SurfaceId;
|
||||
|
||||
/// Split orientation. `H` lays children left-to-right; `V` top-to-bottom.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Orient {
|
||||
H,
|
||||
V,
|
||||
}
|
||||
|
||||
/// Recursive n-ary layout tree. Externally tagged so JSON reads
|
||||
/// `{ "leaf": { "surface_id": "s_1" } }` / `{ "split": { ... } }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LayoutNode {
|
||||
Leaf { surface_id: SurfaceId },
|
||||
Split {
|
||||
orient: Orient,
|
||||
ratios: Vec<f32>,
|
||||
children: Vec<LayoutNode>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutNode {
|
||||
pub fn leaf(id: SurfaceId) -> Self {
|
||||
LayoutNode::Leaf { surface_id: id }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leaf_serializes_externally_tagged() {
|
||||
let n = LayoutNode::leaf(SurfaceId("s_1".into()));
|
||||
let j = serde_json::to_string(&n).unwrap();
|
||||
assert_eq!(j, r#"{"leaf":{"surface_id":"s_1"}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_round_trips() {
|
||||
let n = LayoutNode::Split {
|
||||
orient: Orient::V,
|
||||
ratios: vec![0.5, 0.5],
|
||||
children: vec![
|
||||
LayoutNode::leaf(SurfaceId("s_1".into())),
|
||||
LayoutNode::leaf(SurfaceId("s_2".into())),
|
||||
],
|
||||
};
|
||||
let j = serde_json::to_string(&n).unwrap();
|
||||
assert!(j.contains(r#""split""#));
|
||||
assert!(j.contains(r#""orient":"v""#));
|
||||
let back: LayoutNode = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, n);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
pub mod codec;
|
||||
pub mod ids;
|
||||
pub mod layout;
|
||||
pub mod message;
|
||||
pub mod workspace;
|
||||
|
||||
pub use ids::{SurfaceId, WorkspaceId};
|
||||
pub use ids::{GroupId, SurfaceId, WorkspaceId};
|
||||
pub use layout::{LayoutNode, Orient};
|
||||
pub use message::{Cmd, Envelope, ErrorBody, Evt};
|
||||
pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ids::{SurfaceId, WorkspaceId};
|
||||
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
|
||||
use crate::layout::LayoutNode;
|
||||
use crate::workspace::{Group, WorkspaceView};
|
||||
|
||||
/// Wire envelope. `kind` is the serde tag.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -26,6 +28,33 @@ pub struct ErrorBody {
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
/// Direction a split grows the new neighbor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SplitDir {
|
||||
Right,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Edge of a target leaf to drop a moved panel against.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Edge {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// One panel slot when applying a preset.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PresetSlot {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Client → daemon commands. The active subset for M0+M1.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "cmd", content = "args", rename_all = "snake_case")]
|
||||
@@ -50,6 +79,41 @@ pub enum Cmd {
|
||||
Detach { surface_id: SurfaceId },
|
||||
Focus { surface_id: SurfaceId },
|
||||
Close { surface_id: SurfaceId },
|
||||
SplitSurface {
|
||||
surface_id: SurfaceId,
|
||||
dir: SplitDir,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
command: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
args: Vec<String>,
|
||||
},
|
||||
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
|
||||
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
|
||||
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
|
||||
RestartSurface { surface_id: SurfaceId },
|
||||
CloseWorkspace { workspace_id: WorkspaceId },
|
||||
SetWorkspaceMeta {
|
||||
workspace_id: WorkspaceId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
group_id: Option<Option<GroupId>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
unread: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
order: Option<u32>,
|
||||
},
|
||||
CreateGroup { name: String, color: String },
|
||||
SetGroup {
|
||||
group_id: GroupId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
color: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
order: Option<u32>,
|
||||
},
|
||||
DeleteGroup { group_id: GroupId },
|
||||
Status,
|
||||
Shutdown,
|
||||
}
|
||||
@@ -62,6 +126,11 @@ pub enum Evt {
|
||||
Exit { surface_id: SurfaceId, code: i32 },
|
||||
SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
|
||||
SurfaceClosed { surface_id: SurfaceId },
|
||||
LayoutChanged { workspace_id: WorkspaceId, layout: Option<LayoutNode> },
|
||||
WorkspaceChanged { workspace: WorkspaceView },
|
||||
WorkspaceClosed { workspace_id: WorkspaceId },
|
||||
GroupsChanged { groups: Vec<Group> },
|
||||
SurfaceRestarted { surface_id: SurfaceId },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -112,4 +181,63 @@ mod tests {
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_surface_serializes() {
|
||||
let env = Envelope::Req {
|
||||
id: 1,
|
||||
cmd: Cmd::SplitSurface {
|
||||
surface_id: SurfaceId("s_1".into()),
|
||||
dir: SplitDir::Right,
|
||||
command: None,
|
||||
args: vec![],
|
||||
},
|
||||
};
|
||||
let j = serde_json::to_string(&env).unwrap();
|
||||
assert!(j.contains("split_surface"));
|
||||
assert!(j.contains(r#""dir":"right""#));
|
||||
let back: Envelope = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_preset_round_trips() {
|
||||
let env = Envelope::Req {
|
||||
id: 2,
|
||||
cmd: Cmd::ApplyPreset {
|
||||
workspace_id: WorkspaceId("w_1".into()),
|
||||
preset_id: "2x2".into(),
|
||||
slots: vec![
|
||||
PresetSlot { command: Some("claude".into()), args: vec![] },
|
||||
PresetSlot { command: None, args: vec![] },
|
||||
],
|
||||
},
|
||||
};
|
||||
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
|
||||
assert_eq!(back, env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_ratios_round_trips() {
|
||||
let env = Envelope::Req {
|
||||
id: 3,
|
||||
cmd: Cmd::SetRatios {
|
||||
workspace_id: WorkspaceId("w_1".into()),
|
||||
node_path: vec![0, 1],
|
||||
ratios: vec![0.3, 0.7],
|
||||
},
|
||||
};
|
||||
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
|
||||
assert_eq!(back, env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_changed_event_round_trips() {
|
||||
let evt = Envelope::Evt(Evt::LayoutChanged {
|
||||
workspace_id: WorkspaceId("w_1".into()),
|
||||
layout: Some(crate::layout::LayoutNode::leaf(SurfaceId("s_1".into()))),
|
||||
});
|
||||
let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap();
|
||||
assert_eq!(back, evt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
|
||||
use crate::layout::LayoutNode;
|
||||
|
||||
/// Everything needed to (re)create a panel's process.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SurfaceSpec {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
#[serde(default)]
|
||||
pub agent_label: Option<String>,
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
#[serde(default)]
|
||||
pub autostart: bool,
|
||||
}
|
||||
|
||||
/// A colored, ordered collection of workspaces.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub order: u32,
|
||||
}
|
||||
|
||||
/// Persisted workspace: structure only (no live process state).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Workspace {
|
||||
pub id: WorkspaceId,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub group_id: Option<GroupId>,
|
||||
pub order: u32,
|
||||
#[serde(default)]
|
||||
pub unread: bool,
|
||||
/// None = empty workspace (no panels yet).
|
||||
#[serde(default)]
|
||||
pub layout: Option<LayoutNode>,
|
||||
pub surfaces: HashMap<SurfaceId, SurfaceSpec>,
|
||||
}
|
||||
|
||||
/// Per-surface view in `status` — spec plus live lifecycle flag.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SurfaceView {
|
||||
pub spec: SurfaceSpec,
|
||||
/// true = has a live actor/PTY; false = stopped (in tree, no process).
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Workspace view in `status` / `workspace_changed`: structure + per-surface state.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceView {
|
||||
pub id: WorkspaceId,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub group_id: Option<GroupId>,
|
||||
pub order: u32,
|
||||
pub unread: bool,
|
||||
pub layout: Option<LayoutNode>,
|
||||
pub surfaces: HashMap<SurfaceId, SurfaceView>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn surface_spec_round_trips() {
|
||||
let s = SurfaceSpec {
|
||||
command: "claude".into(),
|
||||
args: vec![],
|
||||
cwd: "/tmp".into(),
|
||||
agent_label: Some("claude".into()),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
autostart: false,
|
||||
};
|
||||
let j = serde_json::to_string(&s).unwrap();
|
||||
let back: SurfaceSpec = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_round_trips_with_empty_layout() {
|
||||
let w = Workspace {
|
||||
id: WorkspaceId("w_1".into()),
|
||||
path: "/tmp/p".into(),
|
||||
name: "p".into(),
|
||||
group_id: None,
|
||||
order: 0,
|
||||
unread: false,
|
||||
layout: None,
|
||||
surfaces: HashMap::new(),
|
||||
};
|
||||
let j = serde_json::to_string(&w).unwrap();
|
||||
let back: Workspace = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, w);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod launchd;
|
||||
mod lifecycle;
|
||||
mod persist;
|
||||
mod registry;
|
||||
mod server;
|
||||
mod state_store;
|
||||
mod surface;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -48,6 +50,9 @@ async fn run_daemon() -> Result<()> {
|
||||
};
|
||||
lifecycle::clear_stale_socket()?;
|
||||
let sock = lifecycle::socket_path()?;
|
||||
let state_path = lifecycle::spacesh_dir()?.join("state.json");
|
||||
let store: std::sync::Arc<dyn state_store::StateStore> =
|
||||
std::sync::Arc::new(state_store::JsonStateStore::new(state_path));
|
||||
eprintln!("spaceshd listening on {}", sock.display());
|
||||
server::serve(&sock).await
|
||||
server::serve(&sock, store).await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{Duration, Instant};
|
||||
use crate::state_store::{PersistState, StateStore};
|
||||
|
||||
/// A handle the registry uses to request a persist. `mark_dirty(state)` records
|
||||
/// the latest snapshot and (re)arms the debounce timer.
|
||||
#[derive(Clone)]
|
||||
pub struct Persister {
|
||||
tx: mpsc::Sender<PersistState>,
|
||||
}
|
||||
|
||||
impl Persister {
|
||||
pub fn mark_dirty(&self, state: PersistState) {
|
||||
// Best-effort; dropping a snapshot is fine because a newer one will arrive.
|
||||
let _ = self.tx.try_send(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the debounce task. Returns the `Persister` handle.
|
||||
/// `debounce` is configurable so tests can use a short window.
|
||||
pub fn spawn(store: Arc<dyn StateStore>, debounce: Duration) -> Persister {
|
||||
let (tx, mut rx) = mpsc::channel::<PersistState>(64);
|
||||
tokio::spawn(async move {
|
||||
let mut latest: Option<PersistState> = None;
|
||||
let mut deadline: Option<Instant> = None;
|
||||
loop {
|
||||
let timer = async {
|
||||
match deadline {
|
||||
Some(d) => tokio::time::sleep_until(d).await,
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
msg = rx.recv() => {
|
||||
match msg {
|
||||
Some(state) => {
|
||||
latest = Some(state);
|
||||
deadline = Some(Instant::now() + debounce);
|
||||
}
|
||||
None => {
|
||||
// channel closed: final flush then exit
|
||||
if let Some(s) = latest.take() { let _ = store.save(&s); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = timer => {
|
||||
if let Some(s) = latest.take() { let _ = store.save(&s); }
|
||||
deadline = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Persister { tx }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct CountingStore {
|
||||
saves: AtomicUsize,
|
||||
last: Mutex<Option<PersistState>>,
|
||||
}
|
||||
impl StateStore for CountingStore {
|
||||
fn load(&self) -> anyhow::Result<PersistState> { Ok(PersistState::default()) }
|
||||
fn save(&self, state: &PersistState) -> anyhow::Result<()> {
|
||||
self.saves.fetch_add(1, Ordering::SeqCst);
|
||||
*self.last.lock().unwrap() = Some(state.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn burst_coalesces_to_one_save() {
|
||||
let store = Arc::new(CountingStore { saves: AtomicUsize::new(0), last: Mutex::new(None) });
|
||||
let p = spawn(store.clone(), Duration::from_millis(80));
|
||||
// Fire several dirty signals rapidly.
|
||||
for v in 1..=5u32 {
|
||||
let mut s = PersistState::default();
|
||||
s.version = v;
|
||||
p.mark_dirty(s);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
// Wait past the debounce window.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(store.saves.load(Ordering::SeqCst), 1, "burst should coalesce to one save");
|
||||
assert_eq!(store.last.lock().unwrap().as_ref().unwrap().version, 5, "save uses the latest snapshot");
|
||||
}
|
||||
}
|
||||
+204
-63
@@ -1,24 +1,23 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use spacesh_proto::{SurfaceId, WorkspaceId};
|
||||
|
||||
use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId};
|
||||
use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
|
||||
|
||||
use crate::state_store::PersistState;
|
||||
use crate::surface::SurfaceHandle;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceMeta {
|
||||
pub id: WorkspaceId,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Single-threaded owner of all live surfaces and workspaces.
|
||||
/// Lives inside the server task; not shared across threads.
|
||||
/// Single-threaded owner of structure (workspaces/groups/trees + per-surface
|
||||
/// specs) and the live actor map. Lives in the server router task.
|
||||
#[derive(Default)]
|
||||
pub struct Registry {
|
||||
counter: AtomicU64,
|
||||
workspaces: HashMap<WorkspaceId, WorkspaceMeta>,
|
||||
/// path → workspace, so `open` is idempotent.
|
||||
by_path: HashMap<PathBuf, WorkspaceId>,
|
||||
surfaces: HashMap<SurfaceId, SurfaceHandle>,
|
||||
groups: HashMap<GroupId, Group>,
|
||||
workspaces: HashMap<WorkspaceId, Workspace>,
|
||||
by_path: HashMap<String, WorkspaceId>,
|
||||
/// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped.
|
||||
live: HashMap<SurfaceId, SurfaceHandle>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
@@ -31,75 +30,217 @@ impl Registry {
|
||||
format!("{prefix}_{n:x}")
|
||||
}
|
||||
|
||||
/// Idempotent: opening the same canonicalized path returns the existing workspace.
|
||||
pub fn open_workspace(&mut self, path: PathBuf) -> WorkspaceMeta {
|
||||
let canonical = path.canonicalize().unwrap_or(path);
|
||||
if let Some(id) = self.by_path.get(&canonical) {
|
||||
return self.workspaces[id].clone();
|
||||
}
|
||||
let id = WorkspaceId(self.next_id("w"));
|
||||
let meta = WorkspaceMeta { id: id.clone(), path: canonical.clone() };
|
||||
self.workspaces.insert(id.clone(), meta.clone());
|
||||
self.by_path.insert(canonical, id);
|
||||
meta
|
||||
}
|
||||
|
||||
pub fn workspace(&self, id: &WorkspaceId) -> Option<&WorkspaceMeta> {
|
||||
self.workspaces.get(id)
|
||||
}
|
||||
|
||||
pub fn new_surface_id(&self) -> SurfaceId {
|
||||
SurfaceId(self.next_id("s"))
|
||||
}
|
||||
|
||||
pub fn insert_surface(&mut self, handle: SurfaceHandle) {
|
||||
self.surfaces.insert(handle.id.clone(), handle);
|
||||
// ---- workspaces ----
|
||||
|
||||
/// Idempotent by canonicalized path. Returns (workspace_id, created?).
|
||||
pub fn open_workspace(&mut self, path: PathBuf) -> (WorkspaceId, bool) {
|
||||
let canonical = path.canonicalize().unwrap_or(path);
|
||||
let key = canonical.to_string_lossy().to_string();
|
||||
if let Some(id) = self.by_path.get(&key) {
|
||||
return (id.clone(), false);
|
||||
}
|
||||
let id = WorkspaceId(self.next_id("w"));
|
||||
let name = canonical.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| key.clone());
|
||||
let order = self.workspaces.len() as u32;
|
||||
self.workspaces.insert(id.clone(), Workspace {
|
||||
id: id.clone(), path: key.clone(), name, group_id: None, order,
|
||||
unread: false, layout: None, surfaces: HashMap::new(),
|
||||
});
|
||||
self.by_path.insert(key, id.clone());
|
||||
(id, true)
|
||||
}
|
||||
|
||||
pub fn surface(&self, id: &SurfaceId) -> Option<&SurfaceHandle> {
|
||||
self.surfaces.get(id)
|
||||
pub fn workspace(&self, id: &WorkspaceId) -> Option<&Workspace> {
|
||||
self.workspaces.get(id)
|
||||
}
|
||||
pub fn workspace_mut(&mut self, id: &WorkspaceId) -> Option<&mut Workspace> {
|
||||
self.workspaces.get_mut(id)
|
||||
}
|
||||
pub fn close_workspace(&mut self, id: &WorkspaceId) -> Vec<SurfaceId> {
|
||||
let Some(ws) = self.workspaces.remove(id) else { return vec![] };
|
||||
self.by_path.retain(|_, v| v != id);
|
||||
let ids: Vec<SurfaceId> = ws.surfaces.keys().cloned().collect();
|
||||
for sid in &ids {
|
||||
self.live.remove(sid);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn remove_surface(&mut self, id: &SurfaceId) -> Option<SurfaceHandle> {
|
||||
self.surfaces.remove(id)
|
||||
/// The workspace that owns a surface id, if any.
|
||||
pub fn workspace_of(&self, sid: &SurfaceId) -> Option<WorkspaceId> {
|
||||
self.workspaces.values().find(|w| w.surfaces.contains_key(sid)).map(|w| w.id.clone())
|
||||
}
|
||||
|
||||
/// Snapshot for the `status` command: (workspace, its surface ids).
|
||||
pub fn status(&self) -> Vec<(WorkspaceMeta, Vec<SurfaceId>)> {
|
||||
self.workspaces
|
||||
.values()
|
||||
.map(|w| {
|
||||
let sids = self
|
||||
.surfaces
|
||||
.values()
|
||||
.filter(|s| s.workspace_id == w.id)
|
||||
.map(|s| s.id.clone())
|
||||
.collect();
|
||||
(w.clone(), sids)
|
||||
})
|
||||
.collect()
|
||||
// ---- surfaces (structure) ----
|
||||
|
||||
pub fn add_surface_spec(&mut self, ws: &WorkspaceId, sid: SurfaceId, spec: SurfaceSpec) {
|
||||
if let Some(w) = self.workspaces.get_mut(ws) {
|
||||
w.surfaces.insert(sid, spec);
|
||||
}
|
||||
}
|
||||
pub fn surface_spec(&self, sid: &SurfaceId) -> Option<SurfaceSpec> {
|
||||
self.workspaces.values().find_map(|w| w.surfaces.get(sid).cloned())
|
||||
}
|
||||
/// Remove a surface from its workspace's spec map and the tree.
|
||||
pub fn remove_surface(&mut self, sid: &SurfaceId) {
|
||||
self.live.remove(sid);
|
||||
if let Some(ws) = self.workspace_of(sid) {
|
||||
if let Some(w) = self.workspaces.get_mut(&ws) {
|
||||
w.surfaces.remove(sid);
|
||||
w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, sid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- live actors ----
|
||||
|
||||
pub fn set_live(&mut self, handle: SurfaceHandle) {
|
||||
self.live.insert(handle.id.clone(), handle);
|
||||
}
|
||||
pub fn live(&self, sid: &SurfaceId) -> Option<&SurfaceHandle> {
|
||||
self.live.get(sid)
|
||||
}
|
||||
pub fn mark_stopped(&mut self, sid: &SurfaceId) {
|
||||
self.live.remove(sid);
|
||||
}
|
||||
pub fn is_running(&self, sid: &SurfaceId) -> bool {
|
||||
self.live.contains_key(sid)
|
||||
}
|
||||
|
||||
// ---- groups ----
|
||||
|
||||
pub fn create_group(&mut self, name: String, color: String) -> GroupId {
|
||||
let id = GroupId(self.next_id("g"));
|
||||
let order = self.groups.len() as u32;
|
||||
self.groups.insert(id.clone(), Group { id: id.clone(), name, color, order });
|
||||
id
|
||||
}
|
||||
pub fn group_mut(&mut self, id: &GroupId) -> Option<&mut Group> {
|
||||
self.groups.get_mut(id)
|
||||
}
|
||||
pub fn delete_group(&mut self, id: &GroupId) {
|
||||
self.groups.remove(id);
|
||||
for w in self.workspaces.values_mut() {
|
||||
if w.group_id.as_ref() == Some(id) {
|
||||
w.group_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn groups(&self) -> Vec<Group> {
|
||||
let mut g: Vec<Group> = self.groups.values().cloned().collect();
|
||||
g.sort_by_key(|x| x.order);
|
||||
g
|
||||
}
|
||||
|
||||
// ---- views & persistence ----
|
||||
|
||||
pub fn workspace_view(&self, id: &WorkspaceId) -> Option<WorkspaceView> {
|
||||
let w = self.workspaces.get(id)?;
|
||||
Some(self.to_view(w))
|
||||
}
|
||||
fn to_view(&self, w: &Workspace) -> WorkspaceView {
|
||||
let surfaces = w.surfaces.iter().map(|(sid, spec)| {
|
||||
(sid.clone(), SurfaceView { spec: spec.clone(), running: self.live.contains_key(sid) })
|
||||
}).collect();
|
||||
WorkspaceView {
|
||||
id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
|
||||
group_id: w.group_id.clone(), order: w.order, unread: w.unread,
|
||||
layout: w.layout.clone(), surfaces,
|
||||
}
|
||||
}
|
||||
pub fn status(&self) -> (Vec<Group>, Vec<WorkspaceView>) {
|
||||
let mut ws: Vec<WorkspaceView> = self.workspaces.values().map(|w| self.to_view(w)).collect();
|
||||
ws.sort_by_key(|w| w.order);
|
||||
(self.groups(), ws)
|
||||
}
|
||||
pub fn persist_state(&self) -> PersistState {
|
||||
let mut workspaces: Vec<Workspace> = self.workspaces.values().cloned().collect();
|
||||
workspaces.sort_by_key(|w| w.order);
|
||||
PersistState { version: 1, groups: self.groups(), workspaces }
|
||||
}
|
||||
/// Replace all structure from a loaded snapshot (cold start). Clears live map.
|
||||
pub fn restore(&mut self, state: PersistState) {
|
||||
self.groups = state.groups.into_iter().map(|g| (g.id.clone(), g)).collect();
|
||||
self.workspaces.clear();
|
||||
self.by_path.clear();
|
||||
self.live.clear();
|
||||
for w in state.workspaces {
|
||||
self.by_path.insert(w.path.clone(), w.id.clone());
|
||||
self.workspaces.insert(w.id.clone(), w);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use spacesh_proto::layout::{LayoutNode as LN, Orient};
|
||||
|
||||
#[test]
|
||||
fn open_is_idempotent_per_path() {
|
||||
let mut reg = Registry::new();
|
||||
let dir = std::env::temp_dir();
|
||||
let a = reg.open_workspace(dir.clone());
|
||||
let b = reg.open_workspace(dir.clone());
|
||||
assert_eq!(a.id, b.id);
|
||||
fn spec() -> SurfaceSpec {
|
||||
SurfaceSpec { command: "/bin/sh".into(), args: vec![], cwd: "/tmp".into(),
|
||||
agent_label: None, cols: 80, rows: 24, autostart: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_and_prefixed() {
|
||||
let reg = Registry::new();
|
||||
let s1 = reg.new_surface_id();
|
||||
let s2 = reg.new_surface_id();
|
||||
assert!(s1.0.starts_with("s_"));
|
||||
assert_ne!(s1, s2);
|
||||
fn open_is_idempotent() {
|
||||
let mut r = Registry::new();
|
||||
let (a, c1) = r.open_workspace(std::env::temp_dir());
|
||||
let (b, c2) = r.open_workspace(std::env::temp_dir());
|
||||
assert_eq!(a, b);
|
||||
assert!(c1 && !c2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_running_then_stopped() {
|
||||
let mut r = Registry::new();
|
||||
let (ws, _) = r.open_workspace(std::env::temp_dir());
|
||||
let sid = r.new_surface_id();
|
||||
r.add_surface_spec(&ws, sid.clone(), spec());
|
||||
assert!(!r.is_running(&sid)); // spec present, no live actor = stopped
|
||||
let v = r.workspace_view(&ws).unwrap();
|
||||
assert_eq!(v.surfaces.get(&sid).unwrap().running, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_surface_updates_tree() {
|
||||
let mut r = Registry::new();
|
||||
let (ws, _) = r.open_workspace(std::env::temp_dir());
|
||||
let s1 = r.new_surface_id();
|
||||
let s2 = r.new_surface_id();
|
||||
r.add_surface_spec(&ws, s1.clone(), spec());
|
||||
r.add_surface_spec(&ws, s2.clone(), spec());
|
||||
r.workspace_mut(&ws).unwrap().layout = Some(LN::Split {
|
||||
orient: Orient::H, ratios: vec![0.5, 0.5],
|
||||
children: vec![LN::leaf(s1.clone()), LN::leaf(s2.clone())],
|
||||
});
|
||||
r.remove_surface(&s2);
|
||||
let w = r.workspace(&ws).unwrap();
|
||||
assert!(!w.surfaces.contains_key(&s2));
|
||||
assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_round_trips_through_persist_state() {
|
||||
let mut r = Registry::new();
|
||||
let (ws, _) = r.open_workspace(std::env::temp_dir());
|
||||
r.add_surface_spec(&ws, r.new_surface_id(), spec());
|
||||
let state = r.persist_state();
|
||||
let mut r2 = Registry::new();
|
||||
r2.restore(state.clone());
|
||||
assert_eq!(r2.persist_state().workspaces.len(), state.workspaces.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_group_ungroups_members() {
|
||||
let mut r = Registry::new();
|
||||
let (ws, _) = r.open_workspace(std::env::temp_dir());
|
||||
let g = r.create_group("prod".into(), "#fff".into());
|
||||
r.workspace_mut(&ws).unwrap().group_id = Some(g.clone());
|
||||
r.delete_group(&g);
|
||||
assert!(r.workspace(&ws).unwrap().group_id.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+363
-83
@@ -1,14 +1,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use spacesh_proto::codec::{read_frame, write_frame};
|
||||
use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId};
|
||||
use spacesh_pty::{PtyHandle, SpawnSpec};
|
||||
use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId, WorkspaceId};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use crate::persist::{self, Persister};
|
||||
use crate::registry::Registry;
|
||||
use crate::surface::{spawn_surface, SurfaceMsg};
|
||||
use crate::state_store::StateStore;
|
||||
use crate::surface::{SurfaceMsg};
|
||||
|
||||
/// Per-client outbound channel: the router pushes envelopes the client task writes out.
|
||||
type ClientTx = mpsc::Sender<Envelope>;
|
||||
@@ -29,7 +32,7 @@ enum ServerMsg {
|
||||
|
||||
type ClientId = u64;
|
||||
|
||||
pub async fn serve(socket: &Path) -> Result<()> {
|
||||
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>) -> Result<()> {
|
||||
let listener = UnixListener::bind(socket)?;
|
||||
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
|
||||
|
||||
@@ -42,7 +45,9 @@ pub async fn serve(socket: &Path) -> Result<()> {
|
||||
}
|
||||
});
|
||||
|
||||
let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx));
|
||||
let persister = persist::spawn(store.clone(), Duration::from_millis(500));
|
||||
let initial = store.load().unwrap_or_default();
|
||||
let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx, persister, initial));
|
||||
|
||||
let mut next_client: ClientId = 0;
|
||||
loop {
|
||||
@@ -97,8 +102,11 @@ async fn router(
|
||||
mut rx: mpsc::Receiver<ServerMsg>,
|
||||
router_tx: mpsc::Sender<ServerMsg>,
|
||||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||
persister: Persister,
|
||||
initial: crate::state_store::PersistState,
|
||||
) {
|
||||
let mut reg = Registry::new();
|
||||
reg.restore(initial);
|
||||
let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
|
||||
// surface_id → set of client ids subscribed (attached).
|
||||
let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new();
|
||||
@@ -125,11 +133,13 @@ async fn router(
|
||||
}
|
||||
}
|
||||
ServerMsg::Exit { surface_id, code } => {
|
||||
// Transition running -> stopped; keep panel + tree.
|
||||
reg.mark_stopped(&surface_id);
|
||||
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
|
||||
broadcast_evt(&clients, &evt);
|
||||
}
|
||||
ServerMsg::Request { id, cmd, client, out } => {
|
||||
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx).await;
|
||||
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &persister).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +159,15 @@ fn err(id: u64, code: &str, msg: &str) -> Envelope {
|
||||
error: Some(ErrorBody { code: code.into(), msg: msg.into() }) }
|
||||
}
|
||||
|
||||
/// Emit a `layout_changed` event for a workspace's current tree.
|
||||
fn emit_layout(reg: &Registry, ws_id: &WorkspaceId, clients: &HashMap<ClientId, ClientTx>) {
|
||||
if let Some(w) = reg.workspace(ws_id) {
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::LayoutChanged {
|
||||
workspace_id: ws_id.clone(), layout: w.layout.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_request(
|
||||
id: u64,
|
||||
@@ -160,116 +179,299 @@ async fn handle_request(
|
||||
clients: &HashMap<ClientId, ClientTx>,
|
||||
router_tx: &mpsc::Sender<ServerMsg>,
|
||||
exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||
persister: &Persister,
|
||||
) {
|
||||
use spacesh_proto::message::SplitDir;
|
||||
use spacesh_proto::layout::{LayoutNode, Orient};
|
||||
use spacesh_proto::workspace::SurfaceSpec;
|
||||
|
||||
match cmd {
|
||||
Cmd::Open { path } => {
|
||||
let meta = reg.open_workspace(path.into());
|
||||
let _ = out.send(ok(id, serde_json::json!({ "workspace_id": meta.id.0 }))).await;
|
||||
let (ws_id, created) = reg.open_workspace(path.into());
|
||||
if created {
|
||||
if let Some(view) = reg.workspace_view(&ws_id) {
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view }));
|
||||
}
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
}
|
||||
let _ = out.send(ok(id, serde_json::json!({ "workspace_id": ws_id.0 }))).await;
|
||||
}
|
||||
|
||||
Cmd::NewSurface { workspace_id, command, args, cols, rows } => {
|
||||
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await;
|
||||
return;
|
||||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
|
||||
};
|
||||
let sid = reg.new_surface_id();
|
||||
let shell = command.unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||||
let spec = SpawnSpec {
|
||||
command: shell,
|
||||
args,
|
||||
cwd: ws.path.clone(),
|
||||
cols,
|
||||
rows,
|
||||
env: vec![("SPACESH_SURFACE_ID".into(), sid.0.clone())],
|
||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||||
let spec = SurfaceSpec {
|
||||
command: shell, args: args.clone(), cwd: ws.path.clone(),
|
||||
agent_label: command, cols, rows, autostart: false,
|
||||
};
|
||||
match PtyHandle::spawn(spec) {
|
||||
Ok(pty) => {
|
||||
let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, cols, rows, exit_tx.clone());
|
||||
// Bridge the surface's broadcast into the router as Output messages.
|
||||
match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
|
||||
reg.insert_surface(handle);
|
||||
let created = Envelope::Evt(Evt::SurfaceCreated {
|
||||
reg.set_live(handle);
|
||||
reg.add_surface_spec(&workspace_id, sid.clone(), spec);
|
||||
// First panel of an empty workspace becomes the root leaf.
|
||||
if let Some(w) = reg.workspace_mut(&workspace_id) {
|
||||
if w.layout.is_none() {
|
||||
w.layout = Some(LayoutNode::leaf(sid.clone()));
|
||||
}
|
||||
}
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated {
|
||||
surface_id: sid.clone(), workspace_id: workspace_id.clone(),
|
||||
});
|
||||
broadcast_evt(clients, &created);
|
||||
}));
|
||||
emit_layout(reg, &workspace_id, clients);
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await;
|
||||
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; }
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::SplitSurface { surface_id, dir, command, args } => {
|
||||
let Some(ws_id) = reg.workspace_of(&surface_id) else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
|
||||
};
|
||||
let ws = reg.workspace(&ws_id).cloned().unwrap();
|
||||
let new_sid = reg.new_surface_id();
|
||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||||
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
|
||||
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, exit_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
||||
reg.set_live(handle);
|
||||
reg.add_surface_spec(&ws_id, new_sid.clone(), spec);
|
||||
let orient = match dir { SplitDir::Right => Orient::H, SplitDir::Down => Orient::V };
|
||||
if let Some(w) = reg.workspace_mut(&ws_id) {
|
||||
let mut root = w.layout.take().unwrap_or_else(|| LayoutNode::leaf(surface_id.clone()));
|
||||
spacesh_core::ops::split_leaf(&mut root, &surface_id, orient, true, new_sid.clone());
|
||||
w.layout = Some(root);
|
||||
}
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: new_sid.clone(), workspace_id: ws_id.clone() }));
|
||||
emit_layout(reg, &ws_id, clients);
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::json!({ "surface_id": new_sid.0 }))).await;
|
||||
}
|
||||
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; }
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::SetRatios { workspace_id, node_path, ratios } => {
|
||||
let ok_set = reg.workspace_mut(&workspace_id).map(|w| {
|
||||
if let Some(l) = w.layout.as_mut() {
|
||||
spacesh_core::ops::set_ratios(l, &node_path, &ratios)
|
||||
} else { false }
|
||||
}).unwrap_or(false);
|
||||
if ok_set {
|
||||
emit_layout(reg, &workspace_id, clients);
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
} else {
|
||||
let _ = out.send(err(id, "BAD_REQUEST", "invalid node_path or ratios")).await;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::MoveSurface { surface_id, target_surface_id, edge } => {
|
||||
let Some(ws_id) = reg.workspace_of(&surface_id) else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
|
||||
};
|
||||
if let Some(w) = reg.workspace_mut(&ws_id) {
|
||||
if let Some(root) = w.layout.take() {
|
||||
w.layout = Some(spacesh_core::ops::move_leaf(root, &surface_id, &target_surface_id, edge));
|
||||
}
|
||||
}
|
||||
emit_layout(reg, &ws_id, clients);
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
}
|
||||
|
||||
Cmd::ApplyPreset { workspace_id, preset_id, slots } => {
|
||||
let Some(count) = spacesh_core::presets::slot_count(&preset_id) else {
|
||||
let _ = out.send(err(id, "BAD_REQUEST", "unknown preset")).await; return;
|
||||
};
|
||||
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
|
||||
};
|
||||
// Kill current panels of this workspace.
|
||||
let existing: Vec<SurfaceId> = ws.surfaces.keys().cloned().collect();
|
||||
for sid in &existing {
|
||||
if let Some(h) = reg.live(sid) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; }
|
||||
reg.remove_surface(sid);
|
||||
subs.remove(sid);
|
||||
}
|
||||
// Spawn `count` panels (slots padded/truncated to count).
|
||||
let mut new_ids = Vec::new();
|
||||
for i in 0..count {
|
||||
let slot = slots.get(i);
|
||||
let new_sid = reg.new_surface_id();
|
||||
let command = slot.and_then(|s| s.command.clone());
|
||||
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||||
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
|
||||
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
|
||||
match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
||||
reg.set_live(handle);
|
||||
reg.add_surface_spec(&workspace_id, new_sid.clone(), spec);
|
||||
new_ids.push(new_sid);
|
||||
}
|
||||
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; return; }
|
||||
}
|
||||
}
|
||||
if let Some(tree) = spacesh_core::presets::build(&preset_id, &new_ids) {
|
||||
if let Some(w) = reg.workspace_mut(&workspace_id) { w.layout = Some(tree); }
|
||||
}
|
||||
for sid in &new_ids {
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone() }));
|
||||
}
|
||||
emit_layout(reg, &workspace_id, clients);
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::json!({ "surface_ids": new_ids.iter().map(|s| s.0.clone()).collect::<Vec<_>>() }))).await;
|
||||
}
|
||||
|
||||
Cmd::RestartSurface { surface_id } => {
|
||||
if reg.is_running(&surface_id) {
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await; return; // already running
|
||||
}
|
||||
let Some(spec) = reg.surface_spec(&surface_id) else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
|
||||
};
|
||||
let ws_id = reg.workspace_of(&surface_id).unwrap();
|
||||
match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, exit_tx.clone()) {
|
||||
Ok(handle) => {
|
||||
spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone());
|
||||
reg.set_live(handle);
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() }));
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
}
|
||||
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; }
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::CloseWorkspace { workspace_id } => {
|
||||
let ids = reg.close_workspace(&workspace_id);
|
||||
for sid in &ids { subs.remove(sid); }
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceClosed { workspace_id: workspace_id.clone() }));
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
}
|
||||
|
||||
Cmd::SetWorkspaceMeta { workspace_id, name, group_id, unread, order } => {
|
||||
let found = reg.workspace_mut(&workspace_id).map(|w| {
|
||||
if let Some(n) = name { w.name = n; }
|
||||
if let Some(g) = group_id { w.group_id = g; }
|
||||
if let Some(u) = unread { w.unread = u; }
|
||||
if let Some(o) = order { w.order = o; }
|
||||
}).is_some();
|
||||
if found {
|
||||
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;
|
||||
} else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::CreateGroup { name, color } => {
|
||||
let gid = reg.create_group(name, color);
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() }));
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::json!({ "group_id": gid.0 }))).await;
|
||||
}
|
||||
|
||||
Cmd::SetGroup { group_id, name, color, order } => {
|
||||
let found = reg.group_mut(&group_id).map(|g| {
|
||||
if let Some(n) = name { g.name = n; }
|
||||
if let Some(c) = color { g.color = c; }
|
||||
if let Some(o) = order { g.order = o; }
|
||||
}).is_some();
|
||||
if found {
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() }));
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
} else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "group")).await;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::DeleteGroup { group_id } => {
|
||||
reg.delete_group(&group_id);
|
||||
broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() }));
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
}
|
||||
|
||||
Cmd::Input { surface_id, bytes } => {
|
||||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else {
|
||||
let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await;
|
||||
return;
|
||||
let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await; return;
|
||||
};
|
||||
if let Some(s) = reg.surface(&surface_id) {
|
||||
let _ = s.tx.send(SurfaceMsg::Input(decoded)).await;
|
||||
if let Some(s) = reg.live(&surface_id) {
|
||||
let _ = s.tx.send(crate::surface::SurfaceMsg::Input(decoded)).await;
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
} else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Resize { surface_id, cols, rows } => {
|
||||
if let Some(s) = reg.surface(&surface_id) {
|
||||
let _ = s.tx.send(SurfaceMsg::Resize { cols, rows }).await;
|
||||
if let Some(s) = reg.live(&surface_id) {
|
||||
let _ = s.tx.send(crate::surface::SurfaceMsg::Resize { cols, rows }).await;
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
} else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Attach { surface_id } => {
|
||||
if let Some(s) = reg.surface(&surface_id) {
|
||||
if let Some(s) = reg.live(&surface_id) {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
if s.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() {
|
||||
if let Ok((snap, _sub)) = reply_rx.await {
|
||||
subs.entry(surface_id.clone()).or_default().push(client);
|
||||
let _ = out.send(ok(id, serde_json::json!({
|
||||
"snapshot": snap.ansi,
|
||||
"cols": snap.cols,
|
||||
"rows": snap.rows,
|
||||
"cursor_row": snap.cursor_row,
|
||||
"cursor_col": snap.cursor_col,
|
||||
"snapshot": snap.ansi, "cols": snap.cols, "rows": snap.rows,
|
||||
"cursor_row": snap.cursor_row, "cursor_col": snap.cursor_col,
|
||||
}))).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = out.send(err(id, "INTERNAL", "attach failed")).await;
|
||||
} else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||||
// stopped panel: no live stream, return an empty snapshot so the GUI shows the restart overlay.
|
||||
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Detach { surface_id } => {
|
||||
if let Some(list) = subs.get_mut(&surface_id) {
|
||||
list.retain(|c| *c != client);
|
||||
}
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
}
|
||||
Cmd::Focus { surface_id: _ } => {
|
||||
// Focus is a no-op in this slice (window raise is GUI-side; CLI parity later).
|
||||
if let Some(list) = subs.get_mut(&surface_id) { list.retain(|c| *c != client); }
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
}
|
||||
|
||||
Cmd::Focus { surface_id: _ } => { let _ = out.send(ok(id, serde_json::Value::Null)).await; }
|
||||
|
||||
Cmd::Close { surface_id } => {
|
||||
if let Some(handle) = reg.remove_surface(&surface_id) {
|
||||
let _ = handle.tx.send(SurfaceMsg::Close).await;
|
||||
if reg.surface_spec(&surface_id).is_some() {
|
||||
if let Some(h) = reg.live(&surface_id) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; }
|
||||
let ws_id = reg.workspace_of(&surface_id);
|
||||
reg.remove_surface(&surface_id);
|
||||
subs.remove(&surface_id);
|
||||
let closed = Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() });
|
||||
broadcast_evt(clients, &closed);
|
||||
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); }
|
||||
persister.mark_dirty(reg.persist_state());
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
} else {
|
||||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Status => {
|
||||
let workspaces: Vec<_> = reg.status().into_iter().map(|(w, sids)| {
|
||||
serde_json::json!({
|
||||
"workspace_id": w.id.0,
|
||||
"path": w.path.to_string_lossy(),
|
||||
"surfaces": sids.iter().map(|s| s.0.clone()).collect::<Vec<_>>(),
|
||||
})
|
||||
}).collect();
|
||||
let _ = out.send(ok(id, serde_json::json!({ "workspaces": workspaces }))).await;
|
||||
let (groups, workspaces) = reg.status();
|
||||
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
|
||||
}
|
||||
|
||||
Cmd::Shutdown => {
|
||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||
std::process::exit(0);
|
||||
@@ -321,13 +523,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn res_data(env: &Envelope) -> &serde_json::Value {
|
||||
match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") }
|
||||
}
|
||||
|
||||
fn tempdir_path() -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("spaceshd-test-{n}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
async fn wait_for_socket(sock: &Path) {
|
||||
for _ in 0..300 {
|
||||
if UnixStream::connect(sock).await.is_ok() { return; }
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
panic!("socket never came up");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn open_new_surface_attach_streams_output() {
|
||||
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 sock_for_task = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
@@ -364,8 +589,11 @@ mod tests {
|
||||
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 sock_for_task = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||||
let r = req(&mut s, 1, Cmd::Input {
|
||||
@@ -381,33 +609,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn res_data(env: &Envelope) -> &serde_json::Value {
|
||||
match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") }
|
||||
}
|
||||
|
||||
fn tempdir_path() -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("spaceshd-test-{n}"));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
async fn wait_for_socket(sock: &Path) {
|
||||
for _ in 0..300 {
|
||||
if UnixStream::connect(sock).await.is_ok() { return; }
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
panic!("socket never came up");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reattach_returns_snapshot_with_prior_output() {
|
||||
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 sock_for_task = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
|
||||
let store2 = store.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; });
|
||||
wait_for_socket(&sock).await;
|
||||
|
||||
// First client: open, new surface that prints a marker, attach, then disconnect.
|
||||
@@ -436,4 +647,73 @@ mod tests {
|
||||
let snap = res_data(&r)["snapshot"].as_str().unwrap();
|
||||
assert!(snap.contains("REPAINT_ME"), "snapshot was: {snap:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_preset_builds_tree_and_status_reports_it() {
|
||||
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 sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, 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::ApplyPreset {
|
||||
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
|
||||
preset_id: "2x2".into(),
|
||||
slots: vec![],
|
||||
}).await;
|
||||
let ids = res_data(&r)["surface_ids"].as_array().unwrap();
|
||||
assert_eq!(ids.len(), 4);
|
||||
|
||||
let r = req(&mut s, 3, Cmd::Status).await;
|
||||
let wss = res_data(&r)["workspaces"].as_array().unwrap();
|
||||
let w0 = wss.iter().find(|w| w["id"] == ws).unwrap();
|
||||
assert!(w0["layout"].is_object(), "layout tree present");
|
||||
assert!(w0["layout"].to_string().contains("split"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cold_restart_restores_structure_stopped() {
|
||||
let _serial = crate::test_support::serial();
|
||||
let dir = tempdir_path();
|
||||
let state_path = dir.join("state.json");
|
||||
let sock = dir.join("sock");
|
||||
let ws;
|
||||
{
|
||||
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
|
||||
let sock2 = sock.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock2, 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;
|
||||
ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
|
||||
req(&mut s, 2, Cmd::ApplyPreset {
|
||||
workspace_id: spacesh_proto::WorkspaceId(ws.clone()), preset_id: "2tb".into(), slots: vec![],
|
||||
}).await;
|
||||
// allow debounce (500ms) to flush state.json
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(900)).await;
|
||||
}
|
||||
// "cold start": new store on the same state file, new socket.
|
||||
let sock_b = dir.join("sock2");
|
||||
let store_b: std::sync::Arc<dyn crate::state_store::StateStore> =
|
||||
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
|
||||
let sb2 = sock_b.clone();
|
||||
tokio::spawn(async move { let _ = serve(&sock_b, store_b).await; });
|
||||
wait_for_socket(&sb2).await;
|
||||
let mut s2 = UnixStream::connect(&sb2).await.unwrap();
|
||||
let r = req(&mut s2, 1, Cmd::Status).await;
|
||||
let wss = res_data(&r)["workspaces"].as_array().unwrap();
|
||||
let w0 = wss.iter().find(|w| w["id"] == ws).expect("workspace restored");
|
||||
let surfaces = w0["surfaces"].as_object().unwrap();
|
||||
assert_eq!(surfaces.len(), 2, "2tb panels restored");
|
||||
for (_id, sv) in surfaces {
|
||||
assert_eq!(sv["running"], false, "restored panels are stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spacesh_proto::workspace::{Group, Workspace};
|
||||
|
||||
/// The full persisted snapshot of structure (no live processes).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PersistState {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<Group>,
|
||||
#[serde(default)]
|
||||
pub workspaces: Vec<Workspace>,
|
||||
}
|
||||
|
||||
pub trait StateStore: Send + Sync {
|
||||
fn load(&self) -> Result<PersistState>;
|
||||
fn save(&self, state: &PersistState) -> Result<()>;
|
||||
}
|
||||
|
||||
/// JSON file store with atomic write (temp + rename) and corrupt-file backup.
|
||||
pub struct JsonStateStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl JsonStateStore {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn backup_corrupt(&self, ts: u128) {
|
||||
let bak = self.path.with_extension(format!("corrupt-{ts}"));
|
||||
let _ = std::fs::rename(&self.path, bak);
|
||||
}
|
||||
}
|
||||
|
||||
impl StateStore for JsonStateStore {
|
||||
fn load(&self) -> Result<PersistState> {
|
||||
if !self.path.exists() {
|
||||
return Ok(PersistState { version: 1, ..Default::default() });
|
||||
}
|
||||
let bytes = std::fs::read(&self.path)?;
|
||||
match serde_json::from_slice::<PersistState>(&bytes) {
|
||||
Ok(state) => Ok(state),
|
||||
Err(_) => {
|
||||
// Corrupt file: back it up and start fresh.
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
self.backup_corrupt(ts);
|
||||
Ok(PersistState { version: 1, ..Default::default() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self, state: &PersistState) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let bytes = serde_json::to_vec_pretty(state)?;
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
// fsync the temp file before rename for durability.
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use spacesh_proto::ids::WorkspaceId;
|
||||
|
||||
fn tmp_file(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("spacesh-state-{name}-{n}.json"));
|
||||
p
|
||||
}
|
||||
|
||||
fn sample() -> PersistState {
|
||||
PersistState {
|
||||
version: 1,
|
||||
groups: vec![],
|
||||
workspaces: vec![Workspace {
|
||||
id: WorkspaceId("w_1".into()),
|
||||
path: "/tmp/p".into(),
|
||||
name: "p".into(),
|
||||
group_id: None,
|
||||
order: 0,
|
||||
unread: false,
|
||||
layout: None,
|
||||
surfaces: std::collections::HashMap::new(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips() {
|
||||
let path = tmp_file("roundtrip");
|
||||
let store = JsonStateStore::new(path.clone());
|
||||
store.save(&sample()).unwrap();
|
||||
let back = store.load().unwrap();
|
||||
assert_eq!(back, sample());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_loads_default() {
|
||||
let store = JsonStateStore::new(tmp_file("missing"));
|
||||
let s = store.load().unwrap();
|
||||
assert_eq!(s.version, 1);
|
||||
assert!(s.workspaces.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_backed_up_and_load_returns_default() {
|
||||
let path = tmp_file("corrupt");
|
||||
std::fs::write(&path, b"{ this is not valid json").unwrap();
|
||||
let store = JsonStateStore::new(path.clone());
|
||||
let s = store.load().unwrap();
|
||||
assert!(s.workspaces.is_empty());
|
||||
// original path no longer holds the corrupt bytes (renamed away)
|
||||
assert!(!path.exists());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,31 @@
|
||||
use spacesh_core::{snapshot::snapshot_ansi, GridSurface};
|
||||
use spacesh_core::snapshot::Snapshot;
|
||||
use spacesh_proto::{SurfaceId, WorkspaceId};
|
||||
use spacesh_pty::PtyHandle;
|
||||
use spacesh_proto::workspace::SurfaceSpec;
|
||||
use spacesh_pty::{PtyHandle, SpawnSpec};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
/// Spawn (or restart) a surface actor from a persisted spec. Injects
|
||||
/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`.
|
||||
pub fn spawn_from_spec(
|
||||
id: SurfaceId,
|
||||
workspace_id: WorkspaceId,
|
||||
spec: &SurfaceSpec,
|
||||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||
) -> std::io::Result<SurfaceHandle> {
|
||||
let pty = PtyHandle::spawn(SpawnSpec {
|
||||
command: spec.command.clone(),
|
||||
args: spec.args.clone(),
|
||||
cwd: std::path::PathBuf::from(&spec.cwd),
|
||||
cols: spec.cols,
|
||||
rows: spec.rows,
|
||||
env: vec![("SPACESH_SURFACE_ID".into(), id.0.clone())],
|
||||
})
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
||||
Ok(spawn_surface(id, workspace_id, pty, spec.cols, spec.rows, exit_tx))
|
||||
}
|
||||
|
||||
const BROADCAST_CAP: usize = 1024;
|
||||
const FLUSH_INTERVAL: Duration = Duration::from_millis(6);
|
||||
const FLUSH_BYTES: usize = 16 * 1024;
|
||||
@@ -20,6 +41,7 @@ pub enum SurfaceMsg {
|
||||
|
||||
pub struct SurfaceHandle {
|
||||
pub id: SurfaceId,
|
||||
#[allow(dead_code)]
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub tx: mpsc::Sender<SurfaceMsg>,
|
||||
}
|
||||
@@ -181,4 +203,29 @@ mod tests {
|
||||
let (snap, _sub) = reply_rx.await.unwrap();
|
||||
assert!(snap.ansi.contains("SNAPME"), "snapshot: {:?}", snap.ansi);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_from_spec_runs_the_command() {
|
||||
let _serial = crate::test_support::serial();
|
||||
let spec = SurfaceSpec {
|
||||
command: "/bin/sh".into(),
|
||||
args: vec!["-c".into(), "printf RESPAWN; sleep 0.3".into()],
|
||||
cwd: std::env::temp_dir().to_string_lossy().into(),
|
||||
agent_label: None, cols: 80, rows: 24, autostart: false,
|
||||
};
|
||||
let (exit_tx, _rx) = mpsc::unbounded_channel();
|
||||
let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, exit_tx).unwrap();
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
|
||||
let mut sub = reply_rx.await.unwrap();
|
||||
let mut got = String::new();
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Ok(Ok(b)) = tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await {
|
||||
got.push_str(&String::from_utf8_lossy(&b));
|
||||
if got.contains("RESPAWN") { break; }
|
||||
}
|
||||
}
|
||||
assert!(got.contains("RESPAWN"), "got: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user