Merge spacesh M4: CLI + set_state/state status primitive
spacesh-cli (lib+bin) one-shot client at full bus parity minus interactive panels; --json + completions; lazy-start (notify best-effort). In-memory ephemeral SurfaceState (set_state/state). SPACESH_SOCK override. 73 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+147
@@ -36,6 +36,56 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -93,6 +143,61 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -277,6 +382,12 @@ dependencies = [
|
|||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -301,6 +412,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -398,6 +515,12 @@ dependencies = [
|
|||||||
"pin-utils",
|
"pin-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -713,6 +836,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spacesh-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
|
"serde_json",
|
||||||
|
"spacesh-proto",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spacesh-core"
|
name = "spacesh-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -764,6 +899,12 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -857,6 +998,12 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vte"
|
name = "vte"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ members = [
|
|||||||
"crates/spacesh-pty",
|
"crates/spacesh-pty",
|
||||||
"crates/spacesh-core",
|
"crates/spacesh-core",
|
||||||
"crates/spaceshd",
|
"crates/spaceshd",
|
||||||
|
"crates/spacesh-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -25,3 +26,5 @@ portable-pty = "0.8"
|
|||||||
alacritty_terminal = "0.25"
|
alacritty_terminal = "0.25"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap_complete = "4"
|
||||||
|
|||||||
@@ -275,18 +275,18 @@ pub fn socket_path() -> Result<PathBuf> {
|
|||||||
Ok(spacesh_dir()?.join("sock"))
|
Ok(spacesh_dir()?.join("sock"))
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Add a test in `lifecycle.rs` tests module:
|
Add a test in `lifecycle.rs` tests module. It mutates the process-global `SPACESH_SOCK`, so it MUST hold `crate::test_support::serial()` for its duration; ALSO add the same guard to the existing `paths_live_under_spacesh_dir` test (the only other test that calls `socket_path()`), or it will intermittently observe the override var and fail:
|
||||||
```rust
|
```rust
|
||||||
#[test]
|
#[test]
|
||||||
fn socket_path_honors_env_override() {
|
fn socket_path_honors_env_override() {
|
||||||
// Note: set/remove around the assertion; tests in this module run serially enough,
|
let _serial = crate::test_support::serial();
|
||||||
// but guard by restoring afterwards.
|
|
||||||
std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock");
|
std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock");
|
||||||
let p = socket_path().unwrap();
|
let p = socket_path().unwrap();
|
||||||
std::env::remove_var("SPACESH_SOCK");
|
std::env::remove_var("SPACESH_SOCK");
|
||||||
assert_eq!(p, std::path::PathBuf::from("/tmp/spacesh-test-override.sock"));
|
assert_eq!(p, std::path::PathBuf::from("/tmp/spacesh-test-override.sock"));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
And prepend `let _serial = crate::test_support::serial();` as the first line of the existing `paths_live_under_spacesh_dir` test.
|
||||||
|
|
||||||
- [ ] **Step 4: Initialize idle on every spawn; drop on exit; dispatch SetState**
|
- [ ] **Step 4: Initialize idle on every spawn; drop on exit; dispatch SetState**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "spacesh-cli"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "spacesh_cli"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "spacesh"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
spacesh-proto = { path = "../spacesh-proto" }
|
||||||
|
clap.workspace = true
|
||||||
|
clap_complete.workspace = true
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde_json.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "spacesh", about = "spacesh CLI — thin client to the spacesh daemon")]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Print raw JSON instead of human output.
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub json: bool,
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub cmd: Sub,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum StateArg { Work, Wait, Done, Error, Idle }
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum DirArg { Right, Down }
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum EdgeArg { Left, Right, Top, Bottom }
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Sub {
|
||||||
|
Open { path: String },
|
||||||
|
Status,
|
||||||
|
NewSurface {
|
||||||
|
workspace_id: String,
|
||||||
|
#[arg(long)] cmd: Option<String>,
|
||||||
|
#[arg(long = "arg")] args: Vec<String>,
|
||||||
|
#[arg(long, default_value_t = 80)] cols: u16,
|
||||||
|
#[arg(long, default_value_t = 24)] rows: u16,
|
||||||
|
},
|
||||||
|
Split {
|
||||||
|
surface_id: String,
|
||||||
|
#[arg(long, value_enum, default_value_t = DirArg::Right)] dir: DirArg,
|
||||||
|
#[arg(long)] cmd: Option<String>,
|
||||||
|
#[arg(long = "arg")] args: Vec<String>,
|
||||||
|
},
|
||||||
|
Close { surface_id: String },
|
||||||
|
Focus { surface_id: String },
|
||||||
|
Restart { surface_id: String },
|
||||||
|
Notify {
|
||||||
|
#[arg(long)] surface: String,
|
||||||
|
#[arg(long, value_enum)] state: StateArg,
|
||||||
|
},
|
||||||
|
ApplyPreset {
|
||||||
|
workspace_id: String,
|
||||||
|
#[arg(long)] preset: String,
|
||||||
|
#[arg(long = "agent")] agents: Vec<String>,
|
||||||
|
},
|
||||||
|
SetRatios {
|
||||||
|
workspace_id: String,
|
||||||
|
#[arg(long, value_delimiter = ',')] path: Vec<u32>,
|
||||||
|
#[arg(long, value_delimiter = ',')] ratios: Vec<f32>,
|
||||||
|
},
|
||||||
|
Move {
|
||||||
|
surface_id: String,
|
||||||
|
#[arg(long)] target: String,
|
||||||
|
#[arg(long, value_enum)] edge: EdgeArg,
|
||||||
|
},
|
||||||
|
CloseWorkspace { workspace_id: String },
|
||||||
|
Group {
|
||||||
|
#[command(subcommand)] action: GroupAction,
|
||||||
|
},
|
||||||
|
SetMeta {
|
||||||
|
workspace_id: String,
|
||||||
|
#[arg(long)] name: Option<String>,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long)] unread: Option<bool>,
|
||||||
|
#[arg(long)] order: Option<u32>,
|
||||||
|
},
|
||||||
|
Shutdown,
|
||||||
|
Completions { shell: clap_complete::Shell },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum GroupAction {
|
||||||
|
Create { #[arg(long)] name: String, #[arg(long)] color: String },
|
||||||
|
Set { group_id: String, #[arg(long)] name: Option<String>, #[arg(long)] color: Option<String>, #[arg(long)] order: Option<u32> },
|
||||||
|
Delete { group_id: String },
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde_json::Value;
|
||||||
|
use spacesh_proto::codec::{read_frame, write_frame};
|
||||||
|
use spacesh_proto::{Cmd, Envelope};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
pub fn socket_path() -> PathBuf {
|
||||||
|
if let Ok(p) = std::env::var("SPACESH_SOCK") {
|
||||||
|
if !p.is_empty() {
|
||||||
|
return PathBuf::from(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||||
|
PathBuf::from(home).join(".spacesh").join("sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect, lazy-starting the daemon if the socket is absent.
|
||||||
|
async fn connect_or_start() -> Result<UnixStream> {
|
||||||
|
let sock = socket_path();
|
||||||
|
if let Ok(s) = UnixStream::connect(&sock).await {
|
||||||
|
return Ok(s);
|
||||||
|
}
|
||||||
|
// Locate the daemon next to this binary and spawn it.
|
||||||
|
let exe = std::env::current_exe().context("current_exe")?;
|
||||||
|
let daemon = exe.with_file_name("spaceshd");
|
||||||
|
let _ = std::process::Command::new(daemon).spawn();
|
||||||
|
for _ in 0..100 {
|
||||||
|
if let Ok(s) = UnixStream::connect(&sock).await {
|
||||||
|
return Ok(s);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||||
|
}
|
||||||
|
Err(anyhow!("daemon unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot request/response. Skips any interleaved events; returns `data` on ok.
|
||||||
|
pub async fn request(cmd: Cmd) -> Result<Value> {
|
||||||
|
let mut stream = connect_or_start().await?;
|
||||||
|
send_and_read(&mut stream, cmd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort status notify: connect only (no spawn); silently succeed if absent.
|
||||||
|
pub async fn notify(cmd: Cmd) -> Result<()> {
|
||||||
|
let sock = socket_path();
|
||||||
|
let Ok(mut stream) = UnixStream::connect(&sock).await else {
|
||||||
|
return Ok(()); // no daemon — best-effort no-op
|
||||||
|
};
|
||||||
|
let _ = send_and_read(&mut stream, cmd).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_and_read(stream: &mut UnixStream, cmd: Cmd) -> Result<Value> {
|
||||||
|
write_frame(stream, &Envelope::Req { id: 1, cmd }).await.map_err(|e| anyhow!(e.to_string()))?;
|
||||||
|
loop {
|
||||||
|
match read_frame(stream).await.map_err(|e| anyhow!(e.to_string()))? {
|
||||||
|
Some(Envelope::Res { id: 1, ok, data, error }) => {
|
||||||
|
if ok {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
let (code, msg) = error.map(|e| (e.code, e.msg)).unwrap_or_else(|| ("ERROR".into(), "error".into()));
|
||||||
|
return Err(anyhow!("{code}: {msg}"));
|
||||||
|
}
|
||||||
|
Some(_) => continue, // events / non-matching res
|
||||||
|
None => return Err(anyhow!("connection closed")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod cli;
|
||||||
|
pub mod client;
|
||||||
|
pub mod mapping;
|
||||||
|
pub mod output;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use spacesh_cli::cli::Cli;
|
||||||
|
use spacesh_cli::output;
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn main() {
|
||||||
|
let parsed = Cli::parse();
|
||||||
|
std::process::exit(output::run(parsed).await);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId};
|
||||||
|
use spacesh_proto::message::{Cmd, Edge, PresetSlot, SplitDir};
|
||||||
|
use spacesh_proto::status::SurfaceState;
|
||||||
|
use crate::cli::{DirArg, EdgeArg, GroupAction, StateArg, Sub};
|
||||||
|
|
||||||
|
pub fn state_of(a: StateArg) -> SurfaceState {
|
||||||
|
match a {
|
||||||
|
StateArg::Work => SurfaceState::Work,
|
||||||
|
StateArg::Wait => SurfaceState::Wait,
|
||||||
|
StateArg::Done => SurfaceState::Done,
|
||||||
|
StateArg::Error => SurfaceState::Error,
|
||||||
|
StateArg::Idle => SurfaceState::Idle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a parsed subcommand to a bus command. `Completions` has no Cmd (handled
|
||||||
|
/// before dispatch), so callers must not pass it here.
|
||||||
|
pub fn to_cmd(sub: Sub) -> Cmd {
|
||||||
|
match sub {
|
||||||
|
Sub::Open { path } => Cmd::Open { path },
|
||||||
|
Sub::Status => Cmd::Status,
|
||||||
|
Sub::NewSurface { workspace_id, cmd, args, cols, rows } => Cmd::NewSurface {
|
||||||
|
workspace_id: WorkspaceId(workspace_id), command: cmd, args, cols, rows,
|
||||||
|
},
|
||||||
|
Sub::Split { surface_id, dir, cmd, args } => Cmd::SplitSurface {
|
||||||
|
surface_id: SurfaceId(surface_id),
|
||||||
|
dir: match dir { DirArg::Right => SplitDir::Right, DirArg::Down => SplitDir::Down },
|
||||||
|
command: cmd, args,
|
||||||
|
},
|
||||||
|
Sub::Close { surface_id } => Cmd::Close { surface_id: SurfaceId(surface_id) },
|
||||||
|
Sub::Focus { surface_id } => Cmd::Focus { surface_id: SurfaceId(surface_id) },
|
||||||
|
Sub::Restart { surface_id } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id) },
|
||||||
|
Sub::Notify { surface, state } => Cmd::SetState { surface_id: SurfaceId(surface), state: state_of(state) },
|
||||||
|
Sub::ApplyPreset { workspace_id, preset, agents } => Cmd::ApplyPreset {
|
||||||
|
workspace_id: WorkspaceId(workspace_id),
|
||||||
|
preset_id: preset,
|
||||||
|
slots: agents.into_iter().map(|a| if a == "shell" {
|
||||||
|
PresetSlot { command: None, args: vec![] }
|
||||||
|
} else {
|
||||||
|
PresetSlot { command: Some(a), args: vec![] }
|
||||||
|
}).collect(),
|
||||||
|
},
|
||||||
|
Sub::SetRatios { workspace_id, path, ratios } => Cmd::SetRatios {
|
||||||
|
workspace_id: WorkspaceId(workspace_id), node_path: path, ratios,
|
||||||
|
},
|
||||||
|
Sub::Move { surface_id, target, edge } => Cmd::MoveSurface {
|
||||||
|
surface_id: SurfaceId(surface_id),
|
||||||
|
target_surface_id: SurfaceId(target),
|
||||||
|
edge: match edge { EdgeArg::Left => Edge::Left, EdgeArg::Right => Edge::Right, EdgeArg::Top => Edge::Top, EdgeArg::Bottom => Edge::Bottom },
|
||||||
|
},
|
||||||
|
Sub::CloseWorkspace { workspace_id } => Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) },
|
||||||
|
Sub::Group { action } => match action {
|
||||||
|
GroupAction::Create { name, color } => Cmd::CreateGroup { name, color },
|
||||||
|
GroupAction::Set { group_id, name, color, order } => Cmd::SetGroup { group_id: GroupId(group_id), name, color, order },
|
||||||
|
GroupAction::Delete { group_id } => Cmd::DeleteGroup { group_id: GroupId(group_id) },
|
||||||
|
},
|
||||||
|
Sub::SetMeta { workspace_id, name, group, unread, order } => Cmd::SetWorkspaceMeta {
|
||||||
|
workspace_id: WorkspaceId(workspace_id),
|
||||||
|
name,
|
||||||
|
// None = no change; Some("") = ungroup; Some(g) = set group.
|
||||||
|
group_id: group.map(|g| if g.is_empty() { None } else { Some(GroupId(g)) }),
|
||||||
|
unread,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
Sub::Shutdown => Cmd::Shutdown,
|
||||||
|
Sub::Completions { .. } => unreachable!("completions handled before dispatch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
fn parse(argv: &[&str]) -> Sub {
|
||||||
|
Cli::try_parse_from(argv).unwrap().cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_maps_to_set_state() {
|
||||||
|
let cmd = to_cmd(parse(&["spacesh", "notify", "--surface", "s_1", "--state", "done"]));
|
||||||
|
assert!(matches!(cmd, Cmd::SetState { state: SurfaceState::Done, .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_default_dir_is_right() {
|
||||||
|
let cmd = to_cmd(parse(&["spacesh", "split", "s_1"]));
|
||||||
|
match cmd { Cmd::SplitSurface { dir, .. } => assert_eq!(dir, SplitDir::Right), _ => panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_ratios_parses_csv() {
|
||||||
|
let cmd = to_cmd(parse(&["spacesh", "set-ratios", "w_1", "--path", "0,1", "--ratios", "0.3,0.7"]));
|
||||||
|
match cmd { Cmd::SetRatios { node_path, ratios, .. } => { assert_eq!(node_path, vec![0,1]); assert_eq!(ratios, vec![0.3,0.7]); }, _ => panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_preset_shell_agent_is_empty_slot() {
|
||||||
|
let cmd = to_cmd(parse(&["spacesh", "apply-preset", "w_1", "--preset", "2lr", "--agent", "shell", "--agent", "claude"]));
|
||||||
|
match cmd {
|
||||||
|
Cmd::ApplyPreset { slots, .. } => {
|
||||||
|
assert!(slots[0].command.is_none());
|
||||||
|
assert_eq!(slots[1].command.as_deref(), Some("claude"));
|
||||||
|
}
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_meta_group_empty_means_ungroup() {
|
||||||
|
let cmd = to_cmd(parse(&["spacesh", "set-meta", "w_1", "--group", ""]));
|
||||||
|
match cmd { Cmd::SetWorkspaceMeta { group_id, .. } => assert_eq!(group_id, Some(None)), _ => panic!() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
use clap::CommandFactory;
|
||||||
|
use serde_json::Value;
|
||||||
|
use crate::cli::{Cli, Sub};
|
||||||
|
use crate::{client, mapping};
|
||||||
|
|
||||||
|
/// Entry point: returns the process exit code.
|
||||||
|
pub async fn run(cli: Cli) -> i32 {
|
||||||
|
// Completions are local — no daemon.
|
||||||
|
if let Sub::Completions { shell } = cli.cmd {
|
||||||
|
let mut cmd = Cli::command();
|
||||||
|
clap_complete::generate(shell, &mut cmd, "spacesh", &mut std::io::stdout());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify is best-effort: never fails the caller.
|
||||||
|
if let Sub::Notify { .. } = &cli.cmd {
|
||||||
|
let _ = client::notify(mapping::to_cmd(cli.cmd)).await;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_status = matches!(cli.cmd, Sub::Status);
|
||||||
|
let cmd = mapping::to_cmd(cli.cmd);
|
||||||
|
match client::request(cmd).await {
|
||||||
|
Ok(data) => {
|
||||||
|
if cli.json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&data).unwrap_or_else(|_| "null".into()));
|
||||||
|
} else if is_status {
|
||||||
|
print_status(&data);
|
||||||
|
} else {
|
||||||
|
print_human(&data);
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if cli.json {
|
||||||
|
println!("{}", serde_json::json!({ "ok": false, "error": e.to_string() }));
|
||||||
|
} else {
|
||||||
|
eprintln!("{e}");
|
||||||
|
}
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human render for non-status commands: surface the salient id, else "ok".
|
||||||
|
fn print_human(data: &Value) {
|
||||||
|
if let Some(id) = data.get("workspace_id").and_then(|v| v.as_str()) {
|
||||||
|
println!("{id}");
|
||||||
|
} else if let Some(id) = data.get("surface_id").and_then(|v| v.as_str()) {
|
||||||
|
println!("{id}");
|
||||||
|
} else if let Some(id) = data.get("group_id").and_then(|v| v.as_str()) {
|
||||||
|
println!("{id}");
|
||||||
|
} else if let Some(ids) = data.get("surface_ids").and_then(|v| v.as_array()) {
|
||||||
|
for id in ids {
|
||||||
|
if let Some(s) = id.as_str() { println!("{s}"); }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact table for `status`.
|
||||||
|
fn print_status(data: &Value) {
|
||||||
|
let empty = vec![];
|
||||||
|
let workspaces = data.get("workspaces").and_then(|v| v.as_array()).unwrap_or(&empty);
|
||||||
|
if workspaces.is_empty() {
|
||||||
|
println!("(no workspaces)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for w in workspaces {
|
||||||
|
let name = w.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let id = w.get("id").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let unread = w.get("unread").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
println!("{} ({}){}", name, id, if unread { " *" } else { "" });
|
||||||
|
if let Some(surfaces) = w.get("surfaces").and_then(|v| v.as_object()) {
|
||||||
|
for (sid, sv) in surfaces {
|
||||||
|
let running = sv.get("running").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let state = sv.get("state").and_then(|v| v.as_str()).unwrap_or("idle");
|
||||||
|
let agent = sv.get("spec").and_then(|s| s.get("agent_label")).and_then(|v| v.as_str()).unwrap_or("shell");
|
||||||
|
let life = if running { "running" } else { "stopped" };
|
||||||
|
println!(" {sid} {agent:<8} {life:<8} {state}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use spacesh_proto::codec::{read_frame, write_frame};
|
||||||
|
use spacesh_proto::{Cmd, Envelope, SurfaceId};
|
||||||
|
use spacesh_proto::status::SurfaceState;
|
||||||
|
use spacesh_cli::client;
|
||||||
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
|
// These tests mutate the process-global SPACESH_SOCK env var, so they must not
|
||||||
|
// run concurrently. Serialize them on a process-wide lock (poison-tolerant).
|
||||||
|
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp_sock(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-cli-{name}-{n}.sock"));
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot mock daemon: accept one connection, read one request, send `reply`.
|
||||||
|
fn mock_daemon(sock: PathBuf, reply: Envelope) {
|
||||||
|
let listener = UnixListener::bind(&sock).unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok((mut stream, _)) = listener.accept().await {
|
||||||
|
if let Ok(Some(_req)) = read_frame(&mut stream).await {
|
||||||
|
let _ = write_frame(&mut stream, &reply).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn request_returns_data_from_daemon() {
|
||||||
|
let _g = serial();
|
||||||
|
let sock = tmp_sock("req");
|
||||||
|
std::env::set_var("SPACESH_SOCK", &sock);
|
||||||
|
mock_daemon(sock.clone(), Envelope::Res {
|
||||||
|
id: 1, ok: true, data: serde_json::json!({ "workspace_id": "w_1" }), error: None,
|
||||||
|
});
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await; // let the listener bind
|
||||||
|
|
||||||
|
let data = client::request(Cmd::Open { path: "/tmp".into() }).await.unwrap();
|
||||||
|
std::env::remove_var("SPACESH_SOCK");
|
||||||
|
let _ = std::fs::remove_file(&sock);
|
||||||
|
assert_eq!(data["workspace_id"], "w_1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn request_surfaces_daemon_error() {
|
||||||
|
let _g = serial();
|
||||||
|
let sock = tmp_sock("err");
|
||||||
|
std::env::set_var("SPACESH_SOCK", &sock);
|
||||||
|
mock_daemon(sock.clone(), Envelope::Res {
|
||||||
|
id: 1, ok: false, data: serde_json::Value::Null,
|
||||||
|
error: Some(spacesh_proto::ErrorBody { code: "NOT_FOUND".into(), msg: "surface".into() }),
|
||||||
|
});
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
let res = client::request(Cmd::Close { surface_id: SurfaceId("s_x".into()) }).await;
|
||||||
|
std::env::remove_var("SPACESH_SOCK");
|
||||||
|
let _ = std::fs::remove_file(&sock);
|
||||||
|
assert!(res.is_err());
|
||||||
|
assert!(res.unwrap_err().to_string().contains("NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn notify_with_no_daemon_is_silent_success() {
|
||||||
|
let _g = serial();
|
||||||
|
let sock = tmp_sock("nodaemon"); // never bound
|
||||||
|
std::env::set_var("SPACESH_SOCK", &sock);
|
||||||
|
let r = client::notify(Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: SurfaceState::Done }).await;
|
||||||
|
std::env::remove_var("SPACESH_SOCK");
|
||||||
|
assert!(r.is_ok(), "notify must be a silent success when no daemon is listening");
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ pub mod codec;
|
|||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
|
pub mod status;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
pub use ids::{GroupId, SurfaceId, WorkspaceId};
|
pub use ids::{GroupId, SurfaceId, WorkspaceId};
|
||||||
pub use layout::{LayoutNode, Orient};
|
pub use layout::{LayoutNode, Orient};
|
||||||
pub use message::{Cmd, Envelope, ErrorBody, Evt};
|
pub use message::{Cmd, Envelope, ErrorBody, Evt};
|
||||||
|
pub use status::SurfaceState;
|
||||||
pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
|
pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
|
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
|
||||||
use crate::layout::LayoutNode;
|
use crate::layout::LayoutNode;
|
||||||
|
use crate::status::SurfaceState;
|
||||||
use crate::workspace::{Group, WorkspaceView};
|
use crate::workspace::{Group, WorkspaceView};
|
||||||
|
|
||||||
/// Wire envelope. `kind` is the serde tag.
|
/// Wire envelope. `kind` is the serde tag.
|
||||||
@@ -114,6 +115,7 @@ pub enum Cmd {
|
|||||||
order: Option<u32>,
|
order: Option<u32>,
|
||||||
},
|
},
|
||||||
DeleteGroup { group_id: GroupId },
|
DeleteGroup { group_id: GroupId },
|
||||||
|
SetState { surface_id: SurfaceId, state: SurfaceState },
|
||||||
Status,
|
Status,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
@@ -131,6 +133,7 @@ pub enum Evt {
|
|||||||
WorkspaceClosed { workspace_id: WorkspaceId },
|
WorkspaceClosed { workspace_id: WorkspaceId },
|
||||||
GroupsChanged { groups: Vec<Group> },
|
GroupsChanged { groups: Vec<Group> },
|
||||||
SurfaceRestarted { surface_id: SurfaceId },
|
SurfaceRestarted { surface_id: SurfaceId },
|
||||||
|
State { surface_id: SurfaceId, state: SurfaceState },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -240,4 +243,23 @@ mod tests {
|
|||||||
let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap();
|
let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap();
|
||||||
assert_eq!(back, evt);
|
assert_eq!(back, evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_state_round_trips() {
|
||||||
|
let env = Envelope::Req {
|
||||||
|
id: 1,
|
||||||
|
cmd: Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Done },
|
||||||
|
};
|
||||||
|
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
|
||||||
|
assert_eq!(back, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_event_round_trips() {
|
||||||
|
let evt = Envelope::Evt(Evt::State { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Wait });
|
||||||
|
let j = serde_json::to_string(&evt).unwrap();
|
||||||
|
assert!(j.contains(r#""evt":"state""#));
|
||||||
|
let back: Envelope = serde_json::from_str(&j).unwrap();
|
||||||
|
assert_eq!(back, evt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Ephemeral agent-activity status of a running surface (orthogonal to the
|
||||||
|
/// running/stopped process lifecycle). Defaults to `Idle`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SurfaceState {
|
||||||
|
Work,
|
||||||
|
Wait,
|
||||||
|
Done,
|
||||||
|
Error,
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serializes_snake_case() {
|
||||||
|
assert_eq!(serde_json::to_string(&SurfaceState::Work).unwrap(), r#""work""#);
|
||||||
|
assert_eq!(serde_json::to_string(&SurfaceState::Idle).unwrap(), r#""idle""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_is_idle() {
|
||||||
|
assert_eq!(SurfaceState::default(), SurfaceState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips() {
|
||||||
|
for s in [SurfaceState::Work, SurfaceState::Wait, SurfaceState::Done, SurfaceState::Error, SurfaceState::Idle] {
|
||||||
|
let j = serde_json::to_string(&s).unwrap();
|
||||||
|
let back: SurfaceState = serde_json::from_str(&j).unwrap();
|
||||||
|
assert_eq!(back, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
|
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
|
||||||
use crate::layout::LayoutNode;
|
use crate::layout::LayoutNode;
|
||||||
|
use crate::status::SurfaceState;
|
||||||
|
|
||||||
/// Everything needed to (re)create a panel's process.
|
/// Everything needed to (re)create a panel's process.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -50,6 +51,9 @@ pub struct SurfaceView {
|
|||||||
pub spec: SurfaceSpec,
|
pub spec: SurfaceSpec,
|
||||||
/// true = has a live actor/PTY; false = stopped (in tree, no process).
|
/// true = has a live actor/PTY; false = stopped (in tree, no process).
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
|
/// Ephemeral agent-activity status (meaningful while running).
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: SurfaceState,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Workspace view in `status` / `workspace_changed`: structure + per-surface state.
|
/// Workspace view in `status` / `workspace_changed`: structure + per-surface state.
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ pub fn spacesh_dir() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn socket_path() -> Result<PathBuf> {
|
pub fn socket_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(p) = std::env::var("SPACESH_SOCK") {
|
||||||
|
if !p.is_empty() {
|
||||||
|
return Ok(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(spacesh_dir()?.join("sock"))
|
Ok(spacesh_dir()?.join("sock"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +56,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn paths_live_under_spacesh_dir() {
|
fn paths_live_under_spacesh_dir() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
let dir = spacesh_dir().unwrap();
|
let dir = spacesh_dir().unwrap();
|
||||||
assert!(socket_path().unwrap().starts_with(&dir));
|
assert!(socket_path().unwrap().starts_with(&dir));
|
||||||
assert!(lock_path().unwrap().starts_with(&dir));
|
assert!(lock_path().unwrap().starts_with(&dir));
|
||||||
@@ -67,4 +73,15 @@ mod tests {
|
|||||||
assert!(second.is_none(), "second acquire should be blocked");
|
assert!(second.is_none(), "second acquire should be blocked");
|
||||||
drop(first);
|
drop(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_path_honors_env_override() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
|
// Note: set/remove around the assertion; serialized against the other
|
||||||
|
// env-sensitive lifecycle test via the crate's serial() lock.
|
||||||
|
std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock");
|
||||||
|
let p = socket_path().unwrap();
|
||||||
|
std::env::remove_var("SPACESH_SOCK");
|
||||||
|
assert_eq!(p, std::path::PathBuf::from("/tmp/spacesh-test-override.sock"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId};
|
use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId};
|
||||||
|
use spacesh_proto::status::SurfaceState;
|
||||||
use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
|
use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
|
||||||
|
|
||||||
use crate::state_store::PersistState;
|
use crate::state_store::PersistState;
|
||||||
@@ -18,6 +19,8 @@ pub struct Registry {
|
|||||||
by_path: HashMap<String, WorkspaceId>,
|
by_path: HashMap<String, WorkspaceId>,
|
||||||
/// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped.
|
/// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped.
|
||||||
live: HashMap<SurfaceId, SurfaceHandle>,
|
live: HashMap<SurfaceId, SurfaceHandle>,
|
||||||
|
/// Ephemeral per-surface status. In-memory only (never persisted).
|
||||||
|
states: HashMap<SurfaceId, SurfaceState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Registry {
|
impl Registry {
|
||||||
@@ -111,6 +114,18 @@ impl Registry {
|
|||||||
self.live.contains_key(sid)
|
self.live.contains_key(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- surface state ----
|
||||||
|
|
||||||
|
pub fn set_state(&mut self, sid: &SurfaceId, state: SurfaceState) {
|
||||||
|
self.states.insert(sid.clone(), state);
|
||||||
|
}
|
||||||
|
pub fn state(&self, sid: &SurfaceId) -> SurfaceState {
|
||||||
|
self.states.get(sid).copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
pub fn drop_state(&mut self, sid: &SurfaceId) {
|
||||||
|
self.states.remove(sid);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- groups ----
|
// ---- groups ----
|
||||||
|
|
||||||
pub fn create_group(&mut self, name: String, color: String) -> GroupId {
|
pub fn create_group(&mut self, name: String, color: String) -> GroupId {
|
||||||
@@ -144,7 +159,11 @@ impl Registry {
|
|||||||
}
|
}
|
||||||
fn to_view(&self, w: &Workspace) -> WorkspaceView {
|
fn to_view(&self, w: &Workspace) -> WorkspaceView {
|
||||||
let surfaces = w.surfaces.iter().map(|(sid, spec)| {
|
let surfaces = w.surfaces.iter().map(|(sid, spec)| {
|
||||||
(sid.clone(), SurfaceView { spec: spec.clone(), running: self.live.contains_key(sid) })
|
(sid.clone(), SurfaceView {
|
||||||
|
spec: spec.clone(),
|
||||||
|
running: self.live.contains_key(sid),
|
||||||
|
state: self.state(sid),
|
||||||
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
WorkspaceView {
|
WorkspaceView {
|
||||||
id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
|
id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
|
||||||
@@ -168,6 +187,7 @@ impl Registry {
|
|||||||
self.workspaces.clear();
|
self.workspaces.clear();
|
||||||
self.by_path.clear();
|
self.by_path.clear();
|
||||||
self.live.clear();
|
self.live.clear();
|
||||||
|
self.states.clear();
|
||||||
for w in state.workspaces {
|
for w in state.workspaces {
|
||||||
self.by_path.insert(w.path.clone(), w.id.clone());
|
self.by_path.insert(w.path.clone(), w.id.clone());
|
||||||
self.workspaces.insert(w.id.clone(), w);
|
self.workspaces.insert(w.id.clone(), w);
|
||||||
@@ -243,4 +263,28 @@ mod tests {
|
|||||||
r.delete_group(&g);
|
r.delete_group(&g);
|
||||||
assert!(r.workspace(&ws).unwrap().group_id.is_none());
|
assert!(r.workspace(&ws).unwrap().group_id.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_defaults_idle_and_can_be_set() {
|
||||||
|
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_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle);
|
||||||
|
r.set_state(&sid, spacesh_proto::status::SurfaceState::Work);
|
||||||
|
assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Work);
|
||||||
|
let v = r.workspace_view(&ws).unwrap();
|
||||||
|
assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_state_resets_to_idle() {
|
||||||
|
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());
|
||||||
|
r.set_state(&sid, spacesh_proto::status::SurfaceState::Error);
|
||||||
|
r.drop_state(&sid);
|
||||||
|
assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ async fn router(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ServerMsg::Exit { surface_id, code } => {
|
ServerMsg::Exit { surface_id, code } => {
|
||||||
// Transition running -> stopped; keep panel + tree.
|
|
||||||
reg.mark_stopped(&surface_id);
|
reg.mark_stopped(&surface_id);
|
||||||
|
reg.drop_state(&surface_id);
|
||||||
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
|
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
|
||||||
broadcast_evt(&clients, &evt);
|
broadcast_evt(&clients, &evt);
|
||||||
}
|
}
|
||||||
@@ -211,6 +211,7 @@ async fn handle_request(
|
|||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
|
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
|
||||||
reg.set_live(handle);
|
reg.set_live(handle);
|
||||||
|
reg.set_state(&sid, spacesh_proto::SurfaceState::Idle);
|
||||||
reg.add_surface_spec(&workspace_id, sid.clone(), spec);
|
reg.add_surface_spec(&workspace_id, sid.clone(), spec);
|
||||||
// First panel of an empty workspace becomes the root leaf.
|
// First panel of an empty workspace becomes the root leaf.
|
||||||
if let Some(w) = reg.workspace_mut(&workspace_id) {
|
if let Some(w) = reg.workspace_mut(&workspace_id) {
|
||||||
@@ -241,6 +242,7 @@ async fn handle_request(
|
|||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
||||||
reg.set_live(handle);
|
reg.set_live(handle);
|
||||||
|
reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle);
|
||||||
reg.add_surface_spec(&ws_id, new_sid.clone(), spec);
|
reg.add_surface_spec(&ws_id, new_sid.clone(), spec);
|
||||||
let orient = match dir { SplitDir::Right => Orient::H, SplitDir::Down => Orient::V };
|
let orient = match dir { SplitDir::Right => Orient::H, SplitDir::Down => Orient::V };
|
||||||
if let Some(w) = reg.workspace_mut(&ws_id) {
|
if let Some(w) = reg.workspace_mut(&ws_id) {
|
||||||
@@ -313,6 +315,7 @@ async fn handle_request(
|
|||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
|
||||||
reg.set_live(handle);
|
reg.set_live(handle);
|
||||||
|
reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle);
|
||||||
reg.add_surface_spec(&workspace_id, new_sid.clone(), spec);
|
reg.add_surface_spec(&workspace_id, new_sid.clone(), spec);
|
||||||
new_ids.push(new_sid);
|
new_ids.push(new_sid);
|
||||||
}
|
}
|
||||||
@@ -342,6 +345,7 @@ async fn handle_request(
|
|||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone());
|
spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone());
|
||||||
reg.set_live(handle);
|
reg.set_live(handle);
|
||||||
|
reg.set_state(&surface_id, spacesh_proto::SurfaceState::Idle);
|
||||||
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() }));
|
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() }));
|
||||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||||
}
|
}
|
||||||
@@ -467,6 +471,17 @@ async fn handle_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cmd::SetState { surface_id, state } => {
|
||||||
|
if reg.is_running(&surface_id) {
|
||||||
|
reg.set_state(&surface_id, state);
|
||||||
|
broadcast_evt(clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
|
||||||
|
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||||
|
} else {
|
||||||
|
// unknown or stopped surface — status is only meaningful while running.
|
||||||
|
let _ = out.send(err(id, "NOT_FOUND", "surface not running")).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Cmd::Status => {
|
Cmd::Status => {
|
||||||
let (groups, workspaces) = reg.status();
|
let (groups, workspaces) = reg.status();
|
||||||
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
|
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
|
||||||
@@ -677,6 +692,44 @@ mod tests {
|
|||||||
assert!(w0["layout"].to_string().contains("split"));
|
assert!(w0["layout"].to_string().contains("split"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn set_state_updates_status_and_emits_event() {
|
||||||
|
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::NewSurface {
|
||||||
|
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
|
||||||
|
command: Some("/bin/sh".into()),
|
||||||
|
args: vec!["-c".into(), "sleep 1".into()],
|
||||||
|
cols: 80, rows: 24,
|
||||||
|
}).await;
|
||||||
|
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
||||||
|
let surface_id = spacesh_proto::SurfaceId(sid.clone());
|
||||||
|
|
||||||
|
// set_state on the running surface
|
||||||
|
let r = req(&mut s, 3, Cmd::SetState { surface_id: surface_id.clone(), state: spacesh_proto::status::SurfaceState::Work }).await;
|
||||||
|
assert!(matches!(r, Envelope::Res { ok: true, .. }));
|
||||||
|
|
||||||
|
// status reflects it
|
||||||
|
let r = req(&mut s, 4, Cmd::Status).await;
|
||||||
|
let wss = res_data(&r)["workspaces"].as_array().unwrap();
|
||||||
|
let w0 = wss.iter().find(|w| w["id"] == ws).unwrap();
|
||||||
|
assert_eq!(w0["surfaces"][&sid]["state"], "work");
|
||||||
|
|
||||||
|
// unknown surface -> NOT_FOUND
|
||||||
|
let r = req(&mut s, 5, Cmd::SetState { surface_id: spacesh_proto::SurfaceId("s_nope".into()), state: spacesh_proto::status::SurfaceState::Done }).await;
|
||||||
|
match r { Envelope::Res { ok, error, .. } => { assert!(!ok); assert_eq!(error.unwrap().code, "NOT_FOUND"); }, _ => panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn cold_restart_restores_structure_stopped() {
|
async fn cold_restart_restores_structure_stopped() {
|
||||||
let _serial = crate::test_support::serial();
|
let _serial = crate::test_support::serial();
|
||||||
|
|||||||
Reference in New Issue
Block a user