Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2add06fe | ||
|
|
0388f06404 | ||
|
|
c98c11b5c3 | ||
|
|
d101574842 | ||
|
|
8110512d5b | ||
|
|
d39fbdbbfc | ||
|
|
d032036542 | ||
|
|
43b90ccf0a | ||
|
|
bdeec6eff5 | ||
|
|
345650172b | ||
|
|
999dbe3ea3 | ||
|
|
c9d089534b | ||
|
|
573a2b4a07 | ||
|
|
9ef3547a00 |
@@ -63,8 +63,8 @@ nix/ # Home Manager module (`services.maestro`)
|
|||||||
See `docs/INSTALL.md` for end-user install instructions.
|
See `docs/INSTALL.md` for end-user install instructions.
|
||||||
|
|
||||||
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
||||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
collisions, what blocks a kill, and what `--force` leaves behind. Read it
|
||||||
touches. Read it before changing anything that creates or removes one.
|
before changing anything that creates or removes one.
|
||||||
|
|
||||||
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
||||||
sidebars — line layout, activity colours, duration format, state precedence.
|
sidebars — line layout, activity colours, duration format, state precedence.
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// List retained worktrees, or reclaim one by ID (#86).
|
|
||||||
Worktrees(WorktreesArgs),
|
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
}
|
}
|
||||||
@@ -62,34 +60,6 @@ pub enum HooksCommand {
|
|||||||
Install,
|
Install,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro worktrees <list|reclaim>`
|
|
||||||
#[derive(Debug, clap::Args)]
|
|
||||||
pub struct WorktreesArgs {
|
|
||||||
#[command(subcommand)]
|
|
||||||
pub command: WorktreesCommand,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
|
||||||
pub enum WorktreesCommand {
|
|
||||||
/// List retained worktrees and what is in the way of reclaiming each.
|
|
||||||
List(ListWorktreesArgs),
|
|
||||||
/// Reclaim one retained worktree: removes the directory, never the branch.
|
|
||||||
Reclaim(ReclaimArgs),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
|
||||||
pub struct ListWorktreesArgs {
|
|
||||||
/// Output as JSON instead of a table.
|
|
||||||
#[arg(long)]
|
|
||||||
pub json: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
|
||||||
pub struct ReclaimArgs {
|
|
||||||
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
|
|
||||||
pub id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
pub struct ListArgs {
|
pub struct ListArgs {
|
||||||
/// Output as JSON instead of a table.
|
/// Output as JSON instead of a table.
|
||||||
@@ -138,8 +108,7 @@ pub struct KillArgs {
|
|||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
/// End the session even when its worktree holds unsaved work (#81). The
|
/// End the session even when its worktree holds unsaved work (#81). The
|
||||||
/// worktree is retained — never removed — and enters the retained store;
|
/// worktree is left on disk — never removed.
|
||||||
/// the output says where the work went.
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
@@ -301,63 +270,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
_ => panic!("wrong subcommand"),
|
_ => panic!("wrong subcommand"),
|
||||||
}
|
}
|
||||||
// No subcommand → help, like `worktrees`.
|
// No subcommand → help.
|
||||||
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
|
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err.kind(),
|
err.kind(),
|
||||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_worktrees_list() {
|
|
||||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
|
||||||
match cli.command {
|
|
||||||
Command::Worktrees(w) => match w.command {
|
|
||||||
WorktreesCommand::List(a) => assert!(a.json),
|
|
||||||
_ => panic!("wrong worktrees subcommand"),
|
|
||||||
},
|
|
||||||
_ => panic!("wrong subcommand"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_worktrees_reclaim() {
|
|
||||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
|
||||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
|
|
||||||
match cli.command {
|
|
||||||
Command::Worktrees(w) => match w.command {
|
|
||||||
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
|
|
||||||
_ => panic!("wrong worktrees subcommand"),
|
|
||||||
},
|
|
||||||
_ => panic!("wrong subcommand"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Contract rule: reclaim refuses to run without an explicit target.
|
|
||||||
/// There is no reclaim-everything convenience.
|
|
||||||
#[test]
|
|
||||||
fn reclaim_requires_an_explicit_target() {
|
|
||||||
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
err.kind(),
|
|
||||||
clap::error::ErrorKind::InvalidSubcommand
|
|
||||||
| clap::error::ErrorKind::MissingRequiredArgument
|
|
||||||
),
|
|
||||||
"{err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worktrees_without_subcommand_shows_help() {
|
|
||||||
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
err.kind(),
|
|
||||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
|
||||||
),
|
|
||||||
"{err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use maestro_protocol::DaemonClient;
|
use maestro_protocol::DaemonClient;
|
||||||
|
|
||||||
use crate::attach::{parse_detach_key, run_attach_url};
|
use crate::attach::{parse_detach_key, run_attach_url};
|
||||||
use crate::cli::{Command, WorktreesCommand};
|
use crate::cli::Command;
|
||||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||||
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
|
|
||||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -80,37 +79,10 @@ pub async fn dispatch(
|
|||||||
if !args.force {
|
if !args.force {
|
||||||
warn_if_blocked(&daemon, args.session_id).await;
|
warn_if_blocked(&daemon, args.session_id).await;
|
||||||
}
|
}
|
||||||
let outcome = run_kill(&daemon, &args).await?;
|
run_kill(&daemon, &args).await?;
|
||||||
println!("killed {}", args.session_id);
|
println!("killed {}", args.session_id);
|
||||||
if let Some(w) = outcome.retained {
|
|
||||||
println!(
|
|
||||||
"worktree retained at {} (branch {}, force-killed)",
|
|
||||||
w.path.display(),
|
|
||||||
w.branch
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||||
Command::Worktrees(args) => match args.command {
|
|
||||||
WorktreesCommand::List(list_args) => {
|
|
||||||
let mut stdout = std::io::stdout().lock();
|
|
||||||
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
|
|
||||||
}
|
|
||||||
WorktreesCommand::Reclaim(reclaim_args) => {
|
|
||||||
// The daemon removes the directory and the store entry; the
|
|
||||||
// branch is never deleted — worth saying out loud (#86).
|
|
||||||
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
|
|
||||||
match entry {
|
|
||||||
Some(wt) => println!(
|
|
||||||
"reclaimed {} ({})\nbranch '{}' kept",
|
|
||||||
wt.id,
|
|
||||||
wt.path.display(),
|
|
||||||
wt.branch
|
|
||||||
),
|
|
||||||
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -150,7 +122,7 @@ async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
|
|||||||
{
|
{
|
||||||
if !b.is_clear() {
|
if !b.is_clear() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
|
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to end it anyway"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,5 @@ pub mod attach;
|
|||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod worktrees;
|
|
||||||
|
|
||||||
pub use dispatch::{dispatch, CliError};
|
pub use dispatch::{dispatch, CliError};
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
pub async fn run_kill(
|
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
|
||||||
client: &DaemonClient,
|
|
||||||
args: &KillArgs,
|
|
||||||
) -> Result<maestro_protocol::KillOutcome, ClientError> {
|
|
||||||
client.kill_session(args.session_id, args.force).await
|
client.kill_session(args.session_id, args.force).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
|
|
||||||
//!
|
|
||||||
//! `reclaim` removes one directory; it never deletes the branch, which is
|
|
||||||
//! said out loud in the output because "reclaim" otherwise sounds like it
|
|
||||||
//! takes the commits too.
|
|
||||||
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
|
||||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
|
||||||
|
|
||||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
|
||||||
|
|
||||||
/// `maestro worktrees list`
|
|
||||||
pub async fn run_list(
|
|
||||||
client: &DaemonClient,
|
|
||||||
args: &ListWorktreesArgs,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) -> Result<(), ClientError> {
|
|
||||||
let worktrees = client.list_worktrees().await?;
|
|
||||||
let info = client.daemon_info().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if args.json {
|
|
||||||
let body = serde_json::json!({ "worktrees": worktrees });
|
|
||||||
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
print_worktree_table(out, &worktrees, &info);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `maestro worktrees reclaim <id>`
|
|
||||||
///
|
|
||||||
/// Returns the entry as listed just before the reclaim, when it was found in
|
|
||||||
/// the listing, so the confirmation can name what was removed.
|
|
||||||
pub async fn run_reclaim(
|
|
||||||
client: &DaemonClient,
|
|
||||||
args: &ReclaimArgs,
|
|
||||||
) -> Result<Option<RetainedWorktree>, ClientError> {
|
|
||||||
let listed = client
|
|
||||||
.list_worktrees()
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.find(|w| w.id == args.id);
|
|
||||||
client.reclaim_worktree(args.id).await?;
|
|
||||||
Ok(listed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
|
|
||||||
if worktrees.is_empty() {
|
|
||||||
writeln!(out, "no retained worktrees").ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
|
|
||||||
sorted.sort_by_key(|w| w.retained_at);
|
|
||||||
|
|
||||||
if !info.hostname.is_empty() {
|
|
||||||
let warn = if maestro_protocol::is_compatible(&info.version) {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
" (protocol mismatch)"
|
|
||||||
};
|
|
||||||
let version = if info.version.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!(" maestro {}{warn}", info.version)
|
|
||||||
};
|
|
||||||
writeln!(out, "\n{}{version}", info.hostname).ok();
|
|
||||||
}
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
|
|
||||||
"ID", "BRANCH", "PATH", "REASON"
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
for w in sorted {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
" {:<36} {:<24} {:<28} {:<24} {}",
|
|
||||||
w.id,
|
|
||||||
w.branch,
|
|
||||||
abbreviate_dir(&w.path, &info.home_dir),
|
|
||||||
reason_label(w.reason),
|
|
||||||
blocker_label(&w.in_the_way),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
// The branch survives every reclaim — see docs/WORKTREES.md.
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"\nreclaim removes the directory only; branches are never deleted"
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
|
|
||||||
match reason {
|
|
||||||
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
|
|
||||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same breakdown that blocks an unforced kill, as words: what is still
|
|
||||||
/// in the way, or `clear` when the work has since been committed and pushed.
|
|
||||||
fn blocker_label(in_the_way: &InTheWay) -> String {
|
|
||||||
if in_the_way.is_clear() {
|
|
||||||
return "clear".into();
|
|
||||||
}
|
|
||||||
let mut parts: Vec<String> = Vec::new();
|
|
||||||
if in_the_way.uncommitted_files > 0 {
|
|
||||||
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
|
|
||||||
}
|
|
||||||
if in_the_way.untracked_files > 0 {
|
|
||||||
parts.push(format!("{} untracked", in_the_way.untracked_files));
|
|
||||||
}
|
|
||||||
if in_the_way.unpushed_commits > 0 {
|
|
||||||
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
|
|
||||||
}
|
|
||||||
parts.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use chrono::{TimeZone, Utc};
|
|
||||||
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
|
||||||
RetainedWorktree {
|
|
||||||
id: Uuid::from_u128(id),
|
|
||||||
path: PathBuf::from(path),
|
|
||||||
base_repo: PathBuf::from("/home/naps/maestro"),
|
|
||||||
branch: branch.into(),
|
|
||||||
reason: RetainedReason::ForceKilled,
|
|
||||||
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
|
||||||
in_the_way: InTheWay::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn info(home: &str) -> DaemonInfo {
|
|
||||||
DaemonInfo {
|
|
||||||
hostname: "naps-laptop".into(),
|
|
||||||
home_dir: home.into(),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn formats_table() {
|
|
||||||
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
|
|
||||||
w.in_the_way = InTheWay {
|
|
||||||
uncommitted_files: 2,
|
|
||||||
untracked_files: 1,
|
|
||||||
unpushed_commits: 3,
|
|
||||||
};
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
|
|
||||||
let s = String::from_utf8(buf).unwrap();
|
|
||||||
assert!(s.contains("naps-laptop"), "{s}");
|
|
||||||
assert!(s.contains("feat/login"), "{s}");
|
|
||||||
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
|
|
||||||
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
|
|
||||||
assert!(s.contains("force-killed"), "{s}");
|
|
||||||
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
|
|
||||||
assert!(s.contains("IN THE WAY"), "{s}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Contract rule, visible to the operator: reclaiming a tree must not
|
|
||||||
/// throw away commits on its branch.
|
|
||||||
#[test]
|
|
||||||
fn says_the_branch_survives() {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
super::print_worktree_table(
|
|
||||||
&mut buf,
|
|
||||||
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
|
|
||||||
&info(""),
|
|
||||||
);
|
|
||||||
let s = String::from_utf8(buf).unwrap();
|
|
||||||
assert!(s.contains("never deleted"), "{s}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_tree_says_clear() {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
super::print_worktree_table(
|
|
||||||
&mut buf,
|
|
||||||
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
|
|
||||||
&info(""),
|
|
||||||
);
|
|
||||||
let s = String::from_utf8(buf).unwrap();
|
|
||||||
assert!(s.contains("clear"), "{s}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_store() {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
super::print_worktree_table(&mut buf, &[], &info(""));
|
|
||||||
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -180,20 +180,62 @@ pub enum TuiTheme {
|
|||||||
Light,
|
Light,
|
||||||
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
||||||
Ansi,
|
Ansi,
|
||||||
|
/// Solarized Dark.
|
||||||
|
SolarizedDark,
|
||||||
|
/// Solarized Light.
|
||||||
|
SolarizedLight,
|
||||||
|
/// Catppuccin Mocha.
|
||||||
|
Catppuccin,
|
||||||
|
/// GitHub Dark.
|
||||||
|
GithubDark,
|
||||||
|
/// GitHub Light.
|
||||||
|
GithubLight,
|
||||||
|
/// Tokyo Night.
|
||||||
|
TokyoNight,
|
||||||
|
/// Gruvbox Dark.
|
||||||
|
Gruvbox,
|
||||||
|
/// Nord.
|
||||||
|
Nord,
|
||||||
|
/// Dracula.
|
||||||
|
Dracula,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every `[tui].theme` name and its variant, in documentation order.
|
||||||
|
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
|
||||||
|
("dark", TuiTheme::Dark),
|
||||||
|
("light", TuiTheme::Light),
|
||||||
|
("ansi", TuiTheme::Ansi),
|
||||||
|
("solarized-dark", TuiTheme::SolarizedDark),
|
||||||
|
("solarized-light", TuiTheme::SolarizedLight),
|
||||||
|
("catppuccin", TuiTheme::Catppuccin),
|
||||||
|
("github-dark", TuiTheme::GithubDark),
|
||||||
|
("github-light", TuiTheme::GithubLight),
|
||||||
|
("tokyo-night", TuiTheme::TokyoNight),
|
||||||
|
("gruvbox", TuiTheme::Gruvbox),
|
||||||
|
("nord", TuiTheme::Nord),
|
||||||
|
("dracula", TuiTheme::Dracula),
|
||||||
|
];
|
||||||
|
|
||||||
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
|
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let raw = Option::<String>::deserialize(deserializer)?;
|
let raw = Option::<String>::deserialize(deserializer)?;
|
||||||
raw.map(|name| match name.as_str() {
|
raw.map(|name| {
|
||||||
"dark" => Ok(TuiTheme::Dark),
|
TUI_THEMES
|
||||||
"light" => Ok(TuiTheme::Light),
|
.iter()
|
||||||
"ansi" => Ok(TuiTheme::Ansi),
|
.find(|(n, _)| *n == name)
|
||||||
_ => Err(D::Error::custom(format!(
|
.map(|(_, theme)| *theme)
|
||||||
"invalid [tui].theme {name:?}; expected \"dark\", \"light\", or \"ansi\""
|
.ok_or_else(|| {
|
||||||
))),
|
let known = TUI_THEMES
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| format!("{n:?}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
D::Error::custom(format!(
|
||||||
|
"invalid [tui].theme {name:?}; expected one of {known}"
|
||||||
|
))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
@@ -630,23 +672,22 @@ bind_addr = "127.0.0.1:9090"
|
|||||||
#[test]
|
#[test]
|
||||||
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
|
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
|
||||||
assert_eq!(TuiSection::default().theme, None);
|
assert_eq!(TuiSection::default().theme, None);
|
||||||
let cfg = file_with("[tui]\ntheme = \"light\"\n");
|
for (name, theme) in TUI_THEMES {
|
||||||
assert_eq!(cfg.tui.theme, Some(TuiTheme::Light));
|
let cfg = file_with(&format!("[tui]\ntheme = \"{name}\"\n"));
|
||||||
let cfg = file_with("[tui]\ntheme = \"ansi\"\n");
|
assert_eq!(cfg.tui.theme, Some(*theme));
|
||||||
assert_eq!(cfg.tui.theme, Some(TuiTheme::Ansi));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tui_theme_rejects_unknown_names_with_the_field_name() {
|
fn tui_theme_rejects_unknown_names_with_the_field_name() {
|
||||||
let err =
|
let err = toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"glorb\"\n").unwrap_err();
|
||||||
toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"solarized\"\n").unwrap_err();
|
|
||||||
let message = err.to_string();
|
let message = err.to_string();
|
||||||
assert!(
|
assert!(
|
||||||
message.contains("[tui].theme"),
|
message.contains("[tui].theme"),
|
||||||
"diagnostic should name the field: {message}"
|
"diagnostic should name the field: {message}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
message.contains("solarized"),
|
message.contains("glorb"),
|
||||||
"diagnostic should name the invalid value: {message}"
|
"diagnostic should name the invalid value: {message}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
Some(p) => Tmux::new().with_tmpdir(p),
|
Some(p) => Tmux::new().with_tmpdir(p),
|
||||||
None => Tmux::new(),
|
None => Tmux::new(),
|
||||||
};
|
};
|
||||||
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
|
|
||||||
config.retained_worktree_dir(),
|
|
||||||
));
|
|
||||||
let manager = SessionManager::new_with_replay(
|
let manager = SessionManager::new_with_replay(
|
||||||
std::sync::Arc::new(tmux),
|
std::sync::Arc::new(tmux),
|
||||||
sidecar,
|
sidecar,
|
||||||
@@ -83,10 +80,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
||||||
config.spawn_history_path(),
|
config.spawn_history_path(),
|
||||||
config.spawn_history_days,
|
config.spawn_history_days,
|
||||||
))
|
));
|
||||||
// One store instance shared with the API handlers: forced kills (#81)
|
|
||||||
// record here, `GET /api/worktrees` (#80) reads back from here.
|
|
||||||
.with_retained_store((*retained).clone());
|
|
||||||
// Spawned sessions learn where to report back (#103). Derived from the
|
// Spawned sessions learn where to report back (#103). Derived from the
|
||||||
// configured bind address; set before any spawn can happen.
|
// configured bind address; set before any spawn can happen.
|
||||||
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
||||||
@@ -108,7 +102,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
projects,
|
projects,
|
||||||
retained,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -107,11 +107,6 @@ impl DaemonConfig {
|
|||||||
pub fn spawn_history_path(&self) -> PathBuf {
|
pub fn spawn_history_path(&self) -> PathBuf {
|
||||||
self.state_dir.join("spawn-history.jsonl")
|
self.state_dir.join("spawn-history.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
|
|
||||||
pub fn retained_worktree_dir(&self) -> PathBuf {
|
|
||||||
self.state_dir.join("retained-worktrees")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ pub mod panes;
|
|||||||
pub mod proc;
|
pub mod proc;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod retained_store;
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spawn_history;
|
pub mod spawn_history;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use axum::{Json, Router};
|
|||||||
use maestro_config::SpawnPreset;
|
use maestro_config::SpawnPreset;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
ReportActivityRequest, Session, PROTOCOL_VERSION,
|
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -38,24 +38,22 @@ pub struct LocalApiState {
|
|||||||
pub presets: Arc<Vec<SpawnPreset>>,
|
pub presets: Arc<Vec<SpawnPreset>>,
|
||||||
/// Project discovery (#54) backing `GET /api/projects`.
|
/// Project discovery (#54) backing `GET /api/projects`.
|
||||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||||
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
|
|
||||||
/// reclaim endpoint (#80).
|
|
||||||
pub retained: Arc<crate::retained_store::RetainedStore>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: LocalApiState) -> Router {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(
|
||||||
|
routes::patterns::SESSION,
|
||||||
|
delete(kill_session).patch(rename_session),
|
||||||
|
)
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
.route(routes::PROJECTS, get(projects))
|
.route(routes::PROJECTS, get(projects))
|
||||||
.route(routes::GITROOT, get(git_root))
|
.route(routes::GITROOT, get(git_root))
|
||||||
.route(routes::BRANCHES, get(crate::branches::handler))
|
.route(routes::BRANCHES, get(crate::branches::handler))
|
||||||
.route(routes::NAMES, get(names))
|
.route(routes::NAMES, get(names))
|
||||||
.route(routes::WORKTREES, get(list_worktrees))
|
|
||||||
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
|
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||||
@@ -102,8 +100,6 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
// The retention record failed before anything was torn down (#81).
|
|
||||||
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,9 +179,8 @@ async fn create_session(
|
|||||||
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
||||||
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
||||||
///
|
///
|
||||||
/// With `force=true` (#81) the refusal is overridden and the worktree
|
/// With `force=true` (#81) the refusal is overridden and the worktree is left
|
||||||
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
|
/// on disk rather than removed. Either way the answer is `204`.
|
||||||
/// Without it the historical `204` stands.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct KillQuery {
|
struct KillQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -198,13 +193,7 @@ async fn kill_session(
|
|||||||
Query(q): Query<KillQuery>,
|
Query(q): Query<KillQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match s.manager.kill(id, q.force).await {
|
match s.manager.kill(id, q.force).await {
|
||||||
Ok(outcome) => {
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
if q.force {
|
|
||||||
(StatusCode::OK, Json(outcome)).into_response()
|
|
||||||
} else {
|
|
||||||
StatusCode::NO_CONTENT.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
let status = session_error_status(&info.code);
|
let status = session_error_status(&info.code);
|
||||||
@@ -213,109 +202,25 @@ async fn kill_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
/// `PATCH /api/sessions/:id` (#125): rename a session. The name is trimmed
|
||||||
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
/// and capped server-side; a blank one clears `metadata.name` so the session
|
||||||
/// since been committed or pushed says so. Entries whose directory somebody
|
/// falls back to its command. Answers with the updated session, which also
|
||||||
/// removed by hand are pruned on the way through.
|
/// goes out as `SessionUpdated` to every events subscriber.
|
||||||
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
async fn rename_session(
|
||||||
let retained = s.retained.clone();
|
State(s): State<LocalApiState>,
|
||||||
let listed = tokio::task::spawn_blocking(move || {
|
Path(id): Path<Uuid>,
|
||||||
retained.prune_missing()?;
|
Json(body): Json<RenameSessionRequest>,
|
||||||
retained
|
) -> Response {
|
||||||
.load_all()?
|
match s.manager.rename(id, &body.name).await {
|
||||||
.iter()
|
Ok(session) => (StatusCode::OK, Json(session)).into_response(),
|
||||||
.map(|entry| {
|
|
||||||
// A tree git cannot read (permissions, corrupt checkout)
|
|
||||||
// still gets listed; it just reports nothing in the way.
|
|
||||||
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
|
||||||
Ok(maestro_protocol::RetainedWorktree {
|
|
||||||
id: entry.id,
|
|
||||||
path: entry.path.clone(),
|
|
||||||
base_repo: entry.base_repo.clone(),
|
|
||||||
branch: entry.branch.clone(),
|
|
||||||
reason: entry.reason,
|
|
||||||
retained_at: entry.retained_at,
|
|
||||||
in_the_way,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
match listed {
|
|
||||||
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
|
|
||||||
Ok(Err(e)) => error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"store_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
),
|
|
||||||
Err(e) => error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"join_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
|
|
||||||
/// the directory and drops the store entry, without consulting the guard —
|
|
||||||
/// reclaim is the deliberate destructive act, chosen while looking at what is
|
|
||||||
/// in the way. The branch itself is never deleted; commits on it survive.
|
|
||||||
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
|
||||||
let found = {
|
|
||||||
let retained = s.retained.clone();
|
|
||||||
tokio::task::spawn_blocking(move || -> Result<
|
|
||||||
Option<crate::retained_store::RetainedWorktreeRecord>,
|
|
||||||
crate::retained_store::RetainedStoreError,
|
|
||||||
> {
|
|
||||||
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
let entry = match found {
|
|
||||||
Ok(Ok(Some(entry))) => entry,
|
|
||||||
Ok(Ok(None)) => {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"not_found",
|
|
||||||
"no such retained worktree",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"store_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return error_response(
|
let info = e.to_error_info();
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
error_response(
|
||||||
"join_failed",
|
session_error_status(&info.code),
|
||||||
&e.to_string(),
|
info.code.as_str(),
|
||||||
|
info.message.as_str(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = {
|
|
||||||
let retained = s.retained.clone();
|
|
||||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
|
||||||
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
retained.remove(id).map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
match outcome {
|
|
||||||
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
|
||||||
// The directory could not be removed: leave the entry so the
|
|
||||||
// operator still sees the tree and can retry.
|
|
||||||
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
|
|
||||||
Err(e) => error_response(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"join_failed",
|
|
||||||
&e.to_string(),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +262,6 @@ pub fn state_for(
|
|||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
projects: Arc<crate::projects::ProjectScanner>,
|
projects: Arc<crate::projects::ProjectScanner>,
|
||||||
retained: Arc<crate::retained_store::RetainedStore>,
|
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
@@ -365,7 +269,6 @@ pub fn state_for(
|
|||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
presets: Arc::new(presets),
|
||||||
projects,
|
projects,
|
||||||
retained,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ pub trait Panes: std::fmt::Debug + Send + Sync + 'static {
|
|||||||
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError>;
|
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError>;
|
||||||
|
|
||||||
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError>;
|
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError>;
|
||||||
|
|
||||||
|
/// Escape-sequence snapshot of the pane's current screen, or `None` when
|
||||||
|
/// there is no pane to capture. See [`Tmux::capture_pane`].
|
||||||
|
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panes for Tmux {
|
impl Panes for Tmux {
|
||||||
@@ -76,6 +80,10 @@ impl Panes for Tmux {
|
|||||||
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
||||||
Tmux::resize_window(self, id, cols, rows)
|
Tmux::resize_window(self, id, cols, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
|
||||||
|
Tmux::capture_pane(self, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -92,6 +100,7 @@ pub enum Op {
|
|||||||
SendKeys,
|
SendKeys,
|
||||||
PipePane,
|
PipePane,
|
||||||
ResizeWindow,
|
ResizeWindow,
|
||||||
|
CapturePane,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One recorded call, in the order the manager made it. Rollback ordering is a
|
/// One recorded call, in the order the manager made it. Rollback ordering is a
|
||||||
@@ -116,6 +125,7 @@ pub enum Call {
|
|||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
},
|
},
|
||||||
|
CapturePane(Uuid),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory [`Panes`] for tests.
|
/// In-memory [`Panes`] for tests.
|
||||||
@@ -133,6 +143,7 @@ struct FakeState {
|
|||||||
live: HashSet<Uuid>,
|
live: HashSet<Uuid>,
|
||||||
panes: HashMap<Uuid, PaneInfo>,
|
panes: HashMap<Uuid, PaneInfo>,
|
||||||
calls: Vec<Call>,
|
calls: Vec<Call>,
|
||||||
|
captures: HashMap<Uuid, Vec<u8>>,
|
||||||
fail: HashMap<Op, String>,
|
fail: HashMap<Op, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +173,12 @@ impl FakePanes {
|
|||||||
self.lock().live.contains(&id)
|
self.lock().live.contains(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Screen snapshot `capture_pane` hands back for `id`. Unset means "no
|
||||||
|
/// pane", which is what a dead session looks like.
|
||||||
|
pub fn set_capture(&self, id: Uuid, bytes: impl Into<Vec<u8>>) {
|
||||||
|
self.lock().captures.insert(id, bytes.into());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_pane_info(&self, id: Uuid, info: PaneInfo) {
|
pub fn set_pane_info(&self, id: Uuid, info: PaneInfo) {
|
||||||
self.lock().panes.insert(id, info);
|
self.lock().panes.insert(id, info);
|
||||||
}
|
}
|
||||||
@@ -242,6 +259,12 @@ impl Panes for FakePanes {
|
|||||||
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
||||||
self.record(Call::ResizeWindow { id, cols, rows }, Op::ResizeWindow)
|
self.record(Call::ResizeWindow { id, cols, rows }, Op::ResizeWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
|
||||||
|
self.record(Call::CapturePane(id), Op::CapturePane)?;
|
||||||
|
let st = self.lock();
|
||||||
|
Ok(st.captures.get(&id).cloned())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -141,8 +141,21 @@ impl PtyBridge {
|
|||||||
/// `subscribe()` keeps the reader task from publishing in between, so
|
/// `subscribe()` keeps the reader task from publishing in between, so
|
||||||
/// the seam has no gap and no duplicates.
|
/// the seam has no gap and no duplicates.
|
||||||
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
|
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
|
||||||
|
self.subscribe_with_snapshot(|| None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`Self::subscribe_with_replay`], but lets the caller supply the
|
||||||
|
/// replay payload instead of the raw byte buffer — used to hand a fresh
|
||||||
|
/// client tmux's own view of the screen, which the byte tail cannot
|
||||||
|
/// reconstruct for a full-screen program. `capture` runs while the reader
|
||||||
|
/// is held off, so the payload and the live stream still meet with no gap
|
||||||
|
/// and no duplicate. Returning `None` falls back to the byte buffer.
|
||||||
|
pub fn subscribe_with_snapshot<F>(&self, capture: F) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>)
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Option<Vec<u8>>,
|
||||||
|
{
|
||||||
let guard = self.replay.lock_for_snapshot();
|
let guard = self.replay.lock_for_snapshot();
|
||||||
let snapshot = guard.snapshot();
|
let snapshot = capture().unwrap_or_else(|| guard.snapshot());
|
||||||
let rx = match &self.sender {
|
let rx = match &self.sender {
|
||||||
Some(tx) => tx.subscribe(),
|
Some(tx) => tx.subscribe(),
|
||||||
// Sealed: hand back the scrollback plus a receiver that is already
|
// Sealed: hand back the scrollback plus a receiver that is already
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
//! Store of retained worktrees (#78): worktrees the daemon created whose
|
|
||||||
//! session is gone.
|
|
||||||
//!
|
|
||||||
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
|
|
||||||
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
|
|
||||||
//! missing or malformed files tolerated on read. The store survives a daemon
|
|
||||||
//! restart; it is the recovery path for everything else in the worktree
|
|
||||||
//! lifecycle.
|
|
||||||
//!
|
|
||||||
//! It is not an ownership record: it must never be consulted to decide whether
|
|
||||||
//! a path may be adopted. Its only job is to remember what the daemon is
|
|
||||||
//! holding so it can be listed and reclaimed.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
|
|
||||||
/// so the store and the wire payload cannot drift apart.
|
|
||||||
pub use maestro_protocol::RetainedReason;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RetainedStoreError {
|
|
||||||
#[error("io: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("json: {0}")]
|
|
||||||
Json(#[from] serde_json::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct RetainedWorktreeRecord {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub base_repo: PathBuf,
|
|
||||||
pub branch: String,
|
|
||||||
pub reason: RetainedReason,
|
|
||||||
pub retained_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RetainedStore {
|
|
||||||
dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RetainedStore {
|
|
||||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
|
||||||
Self { dir: dir.into() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dir(&self) -> &Path {
|
|
||||||
&self.dir
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_for(&self, id: Uuid) -> PathBuf {
|
|
||||||
self.dir.join(format!("{id}.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a retained worktree and return the entry that was written.
|
|
||||||
pub fn retain(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
base_repo: &Path,
|
|
||||||
branch: &str,
|
|
||||||
reason: RetainedReason,
|
|
||||||
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
|
|
||||||
let entry = RetainedWorktreeRecord {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
base_repo: base_repo.to_path_buf(),
|
|
||||||
branch: branch.to_string(),
|
|
||||||
reason,
|
|
||||||
retained_at: Utc::now(),
|
|
||||||
};
|
|
||||||
self.overwrite(&entry)?;
|
|
||||||
Ok(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically write `entry`. Creates the directory if it does not exist.
|
|
||||||
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
|
|
||||||
std::fs::create_dir_all(&self.dir)?;
|
|
||||||
let final_path = self.path_for(entry.id);
|
|
||||||
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
|
||||||
let json = serde_json::to_vec_pretty(entry)?;
|
|
||||||
std::fs::write(&tmp, json)?;
|
|
||||||
std::fs::rename(&tmp, &final_path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
|
|
||||||
/// store already lost (e.g. pruned) must not fail.
|
|
||||||
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
|
|
||||||
match std::fs::remove_file(self.path_for(id)) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read every entry. Files that are missing or malformed are skipped
|
|
||||||
/// silently: a corrupt record must not hide the others, and an empty or
|
|
||||||
/// absent directory simply means nothing is retained.
|
|
||||||
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let entries = match std::fs::read_dir(&self.dir) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(bytes) = std::fs::read(&path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
|
|
||||||
out.push(worktree);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort_by_key(|w| w.retained_at);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop entries whose worktree directory no longer exists — somebody
|
|
||||||
/// removed it by hand. Returns the ids that were pruned.
|
|
||||||
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
|
|
||||||
let mut pruned = Vec::new();
|
|
||||||
for entry in self.load_all()? {
|
|
||||||
if !entry.path.exists() {
|
|
||||||
self.remove(entry.id)?;
|
|
||||||
pruned.push(entry.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(pruned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::Duration;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn tmp_dir() -> tempfile::TempDir {
|
|
||||||
tempfile::tempdir().expect("tempdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
|
|
||||||
let p = root.join(name);
|
|
||||||
std::fs::create_dir_all(&p).expect("mkdir");
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retain_then_load_round_trips_every_field() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
let repo = sample_path(dir.path(), "repo");
|
|
||||||
|
|
||||||
let entry = store
|
|
||||||
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
let all = store.load_all().expect("load_all");
|
|
||||||
assert_eq!(all, vec![entry.clone()]);
|
|
||||||
assert_eq!(entry.path, wt);
|
|
||||||
assert_eq!(entry.base_repo, repo);
|
|
||||||
assert_eq!(entry.branch, "feature/login");
|
|
||||||
assert_eq!(entry.reason, RetainedReason::ForceKilled);
|
|
||||||
let age = Utc::now() - entry.retained_at;
|
|
||||||
assert!(age >= Duration::zero() && age < Duration::minutes(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reason_serializes_kebab_case() {
|
|
||||||
let json =
|
|
||||||
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
|
|
||||||
assert_eq!(json, r#""exited-with-unsaved-work""#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entries_survive_a_restart() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
|
|
||||||
let first = RetainedStore::new(dir.path());
|
|
||||||
let entry = first
|
|
||||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
// A fresh store over the same state_dir — what a daemon restart sees.
|
|
||||||
let second = RetainedStore::new(dir.path());
|
|
||||||
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_store_reads_as_empty() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path().join("does-not-exist"));
|
|
||||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_entries_are_skipped_silently() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
store
|
|
||||||
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
|
|
||||||
|
|
||||||
let all = store.load_all().expect("load_all");
|
|
||||||
assert_eq!(all.len(), 1);
|
|
||||||
assert_eq!(all[0].branch, "kept");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ids_are_unique() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let a = store
|
|
||||||
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
let b = store
|
|
||||||
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
assert_ne!(a.id, b.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_drops_the_entry() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let wt = sample_path(dir.path(), "wt");
|
|
||||||
let entry = store
|
|
||||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
store.remove(entry.id).expect("remove");
|
|
||||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
|
||||||
|
|
||||||
// Removing an unknown id is a no-op, not an error.
|
|
||||||
store.remove(entry.id).expect("remove again");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_all_is_sorted_by_time() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let mut first = store
|
|
||||||
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
first.retained_at -= Duration::hours(1);
|
|
||||||
store.overwrite(&first).expect("rewrite");
|
|
||||||
|
|
||||||
let second = store
|
|
||||||
.retain(
|
|
||||||
dir.path(),
|
|
||||||
dir.path(),
|
|
||||||
"second",
|
|
||||||
RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.expect("retain");
|
|
||||||
|
|
||||||
let branches: Vec<_> = store
|
|
||||||
.load_all()
|
|
||||||
.expect("load_all")
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.branch)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(branches, vec!["first", "second"]);
|
|
||||||
let _ = second;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prune_missing_drops_gone_directories_only() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let store = RetainedStore::new(dir.path());
|
|
||||||
let alive = sample_path(dir.path(), "alive");
|
|
||||||
let gone = sample_path(dir.path(), "gone");
|
|
||||||
let keep = store
|
|
||||||
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
|
|
||||||
.expect("retain");
|
|
||||||
let drop = store
|
|
||||||
.retain(
|
|
||||||
&gone,
|
|
||||||
dir.path(),
|
|
||||||
"drop",
|
|
||||||
RetainedReason::ExitedWithUnsavedWork,
|
|
||||||
)
|
|
||||||
.expect("retain");
|
|
||||||
std::fs::remove_dir_all(&gone).expect("rm");
|
|
||||||
|
|
||||||
let pruned = store.prune_missing().expect("prune_missing");
|
|
||||||
assert_eq!(pruned, vec![drop.id]);
|
|
||||||
|
|
||||||
let branches: Vec<_> = store
|
|
||||||
.load_all()
|
|
||||||
.expect("load_all")
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.branch)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(branches, vec!["keep"]);
|
|
||||||
let _ = keep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
|
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
|
||||||
RetainedWorktree, Session, SessionStatus, WorktreeSpec,
|
WorktreeSpec,
|
||||||
};
|
};
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -51,11 +51,6 @@ pub struct SessionRecord {
|
|||||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||||
/// worktree remove` is invoked from the base repo.
|
/// worktree remove` is invoked from the base repo.
|
||||||
pub worktree_base_repo: Option<PathBuf>,
|
pub worktree_base_repo: Option<PathBuf>,
|
||||||
/// Branch the worktree was created for, recorded at spawn time so a
|
|
||||||
/// forced kill (#81) can name it in the retained store without asking
|
|
||||||
/// git. Sessions adopted from a sidecar have no branch on record and
|
|
||||||
/// fall back to a probe.
|
|
||||||
pub worktree_branch: Option<String>,
|
|
||||||
pub bridge: PtyBridge,
|
pub bridge: PtyBridge,
|
||||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||||
/// outranks the activity pass's own inference; when it lapses, inference
|
/// outranks the activity pass's own inference; when it lapses, inference
|
||||||
@@ -88,11 +83,6 @@ pub enum SessionError {
|
|||||||
/// so it stays the operator's handle on their own unfinished work.
|
/// so it stays the operator's handle on their own unfinished work.
|
||||||
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
||||||
KillGuarded(maestro_protocol::InTheWay),
|
KillGuarded(maestro_protocol::InTheWay),
|
||||||
/// A forced kill (#81) records the retained worktree *before* tearing
|
|
||||||
/// anything down; if that write fails nothing was destroyed, and the
|
|
||||||
/// error says so rather than silently dropping unrecorded work.
|
|
||||||
#[error("could not record the retained worktree: {0}")]
|
|
||||||
RetainFailed(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionError {
|
impl SessionError {
|
||||||
@@ -105,7 +95,6 @@ impl SessionError {
|
|||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
SessionError::KillGuarded(_) => "kill_guarded",
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
SessionError::RetainFailed(_) => "retain_failed",
|
|
||||||
};
|
};
|
||||||
let unsaved_work = match self {
|
let unsaved_work = match self {
|
||||||
SessionError::KillGuarded(b) => Some(*b),
|
SessionError::KillGuarded(b) => Some(*b),
|
||||||
@@ -138,9 +127,6 @@ struct Inner {
|
|||||||
/// `None` when not wired (tests, or before `with_spawn_history`); a present
|
/// `None` when not wired (tests, or before `with_spawn_history`); a present
|
||||||
/// but disabled (`0`-day) history no-ops internally.
|
/// but disabled (`0`-day) history no-ops internally.
|
||||||
spawn_history: Option<SpawnHistory>,
|
spawn_history: Option<SpawnHistory>,
|
||||||
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
|
||||||
/// kill (#81) without one fails rather than destroying unrecorded work.
|
|
||||||
retained: Option<crate::retained_store::RetainedStore>,
|
|
||||||
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
||||||
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
||||||
/// the variable. A mutex because the runner sets it after construction,
|
/// the variable. A mutex because the runner sets it after construction,
|
||||||
@@ -187,7 +173,6 @@ impl SessionManager {
|
|||||||
fifo_dir,
|
fifo_dir,
|
||||||
replay_bytes,
|
replay_bytes,
|
||||||
spawn_history: None,
|
spawn_history: None,
|
||||||
retained: None,
|
|
||||||
daemon_url: std::sync::Mutex::new(None),
|
daemon_url: std::sync::Mutex::new(None),
|
||||||
report_ttl: crate::activity::REPORT_TTL,
|
report_ttl: crate::activity::REPORT_TTL,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
@@ -212,16 +197,6 @@ impl SessionManager {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
|
|
||||||
/// to record what it retained; without one, forcing a guarded kill fails
|
|
||||||
/// instead of destroying unrecorded work.
|
|
||||||
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
|
|
||||||
let inner = Arc::get_mut(&mut self.inner)
|
|
||||||
.expect("with_retained_store must be called before the SessionManager is cloned");
|
|
||||||
inner.retained = Some(store);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Override the agent-report TTL (#105). Tests pass a short one to watch
|
/// Override the agent-report TTL (#105). Tests pass a short one to watch
|
||||||
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
|
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
|
||||||
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
|
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
|
||||||
@@ -285,8 +260,29 @@ impl SessionManager {
|
|||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
|
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
|
||||||
|
let tmux = self.inner.tmux.clone();
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
sessions.get(&id).map(|r| r.bridge.subscribe_with_replay())
|
let rec = sessions.get(&id)?;
|
||||||
|
// For a live session, tmux's own screen beats the byte tail: the tail
|
||||||
|
// starts mid-stream, so a full-screen program (an agent TUI) replayed
|
||||||
|
// from it renders as garbage until something forces a repaint. A dead
|
||||||
|
// session has no pane left to capture, so it keeps the byte tail.
|
||||||
|
let alive = rec.session.status == SessionStatus::Running;
|
||||||
|
// The capture shells out to tmux while the replay lock is held, which
|
||||||
|
// parks the reader for the length of one tmux round-trip. That only
|
||||||
|
// happens on attach, and the alternative is a seam in the stream.
|
||||||
|
Some(rec.bridge.subscribe_with_snapshot(|| {
|
||||||
|
if !alive {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match tmux.capture_pane(id) {
|
||||||
|
Ok(snapshot) => snapshot,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has(&self, id: Uuid) -> bool {
|
pub async fn has(&self, id: Uuid) -> bool {
|
||||||
@@ -442,7 +438,6 @@ impl SessionManager {
|
|||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path,
|
worktree_path,
|
||||||
worktree_base_repo,
|
worktree_base_repo,
|
||||||
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
|
||||||
bridge,
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -494,19 +489,19 @@ impl SessionManager {
|
|||||||
/// is removed; the error names what blocked it and how much of it there
|
/// is removed; the error names what blocked it and how much of it there
|
||||||
/// is. The session stays as the operator's handle on their own unfinished
|
/// is. The session stays as the operator's handle on their own unfinished
|
||||||
/// work, so ending a session that still matters becomes a deliberate
|
/// work, so ending a session that still matters becomes a deliberate
|
||||||
/// `--force`, which retains rather than destroys (WORKTREES.md).
|
/// `--force`, which leaves the tree alone rather than destroying it
|
||||||
|
/// (WORKTREES.md).
|
||||||
///
|
///
|
||||||
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
||||||
/// the session ends and the guarded worktree is retained into the store
|
/// the session ends and the guarded worktree is left on disk, never
|
||||||
/// with reason `force-killed`, never removed. The outcome names what was
|
/// removed. A tree whose guard is clear never needed the override; force
|
||||||
/// kept so a client can say where the work went. A tree whose guard is
|
/// removes it as usual.
|
||||||
/// clear never needed the override; force removes it as usual.
|
|
||||||
///
|
///
|
||||||
/// This is also how an already-dead session is dismissed. tmux is only
|
/// This is also how an already-dead session is dismissed. tmux is only
|
||||||
/// asked to kill something it still has — calling `kill-session` on a
|
/// asked to kill something it still has — calling `kill-session` on a
|
||||||
/// session tmux already lost fails, and that failure is not interesting.
|
/// session tmux already lost fails, and that failure is not interesting.
|
||||||
pub async fn kill(&self, id: Uuid, force: bool) -> Result<KillOutcome, SessionError> {
|
pub async fn kill(&self, id: Uuid, force: bool) -> Result<(), SessionError> {
|
||||||
let (worktree, alive, known_branch) = {
|
let (worktree, alive) = {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
match sessions.get(&id) {
|
match sessions.get(&id) {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
None => return Err(SessionError::NotFound(id)),
|
||||||
@@ -516,7 +511,6 @@ impl SessionManager {
|
|||||||
.clone()
|
.clone()
|
||||||
.zip(record.worktree_path.clone()),
|
.zip(record.worktree_path.clone()),
|
||||||
record.session.status == SessionStatus::Running,
|
record.session.status == SessionStatus::Running,
|
||||||
record.worktree_branch.clone(),
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -529,35 +523,13 @@ impl SessionManager {
|
|||||||
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
|
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut retained = None;
|
|
||||||
if guarded {
|
|
||||||
let entry = record_retention(
|
|
||||||
&self.inner,
|
|
||||||
worktree.as_ref().expect("guarded ⇒ worktree"),
|
|
||||||
known_branch,
|
|
||||||
RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
retained = Some((entry, probe.expect("guarded ⇒ probed")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let removed = {
|
let removed = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
sessions.remove(&id)
|
sessions.remove(&id)
|
||||||
};
|
};
|
||||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||||
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
|
cleanup_record(&self.inner, id, record, alive, guarded).await;
|
||||||
Ok(KillOutcome {
|
Ok(())
|
||||||
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
|
|
||||||
id: entry.id,
|
|
||||||
path: entry.path,
|
|
||||||
base_repo: entry.base_repo,
|
|
||||||
branch: entry.branch,
|
|
||||||
reason: entry.reason,
|
|
||||||
retained_at: entry.retained_at,
|
|
||||||
in_the_way,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a background task that polls tmux every `interval` and reaps any
|
/// Spawn a background task that polls tmux every `interval` and reaps any
|
||||||
@@ -669,9 +641,6 @@ impl SessionManager {
|
|||||||
// directory blocks that branch forever (#54 review).
|
// directory blocks that branch forever (#54 review).
|
||||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||||
// The sidecar carries no branch; a forced kill of a
|
|
||||||
// recovered session probes git for it at retain time.
|
|
||||||
worktree_branch: None,
|
|
||||||
bridge,
|
bridge,
|
||||||
reported_activity: None,
|
reported_activity: None,
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -716,6 +685,38 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
||||||
|
/// requested name is blank so the session falls back to its command.
|
||||||
|
///
|
||||||
|
/// The new name is persisted to the sidecar before the event goes out — a
|
||||||
|
/// rename the operator can see but that a daemon restart would undo is
|
||||||
|
/// worse than one that failed outright.
|
||||||
|
pub async fn rename(&self, id: Uuid, name: &str) -> Result<Session, SessionError> {
|
||||||
|
// The sidecar write happens under the sessions lock so what is on disk
|
||||||
|
// and what is in memory can never disagree about the newest rename.
|
||||||
|
let session = {
|
||||||
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
|
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
let mut renamed = rec.session.clone();
|
||||||
|
match maestro_protocol::normalize_session_name(name) {
|
||||||
|
Some(name) => renamed
|
||||||
|
.metadata
|
||||||
|
.insert("name".into(), serde_json::Value::String(name)),
|
||||||
|
None => renamed.metadata.remove("name"),
|
||||||
|
};
|
||||||
|
self.inner
|
||||||
|
.sidecar
|
||||||
|
.write(&renamed)
|
||||||
|
.map_err(SessionError::Sidecar)?;
|
||||||
|
rec.session = renamed.clone();
|
||||||
|
renamed
|
||||||
|
};
|
||||||
|
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
|
||||||
|
session: session.clone(),
|
||||||
|
});
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Accept an agent-reported activity state (#105), the only path that can
|
/// Accept an agent-reported activity state (#105), the only path that can
|
||||||
/// produce `ActivityState::Error`. The report outranks the activity pass's
|
/// produce `ActivityState::Error`. The report outranks the activity pass's
|
||||||
/// own inference until its TTL lapses — so an agent that dies mid-run
|
/// own inference until its TTL lapses — so an agent that dies mid-run
|
||||||
@@ -808,32 +809,6 @@ async fn probe_worktree(
|
|||||||
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
|
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a guarded worktree into the retained store with `reason`, BEFORE
|
|
||||||
/// the caller tears anything down: a failed write then leaves every artifact
|
|
||||||
/// untouched instead of destroying work nothing records anymore. Shared by
|
|
||||||
/// the forced kill (`ForceKilled`, #81) and the natural exit
|
|
||||||
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
|
|
||||||
async fn record_retention(
|
|
||||||
inner: &Arc<Inner>,
|
|
||||||
(base_repo, path): &(PathBuf, PathBuf),
|
|
||||||
known_branch: Option<String>,
|
|
||||||
reason: RetainedReason,
|
|
||||||
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
|
|
||||||
let store = inner
|
|
||||||
.retained
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
|
|
||||||
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
|
|
||||||
let branch = known_branch
|
|
||||||
.or_else(|| crate::worktree_probe::current_branch(path))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let base_repo = base_repo.clone();
|
|
||||||
let path = path.clone();
|
|
||||||
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
|
|
||||||
.await
|
|
||||||
.map_err(|e| SessionError::RetainFailed(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One pass of the liveness watcher: diff the in-memory session set against
|
/// One pass of the liveness watcher: diff the in-memory session set against
|
||||||
/// the tmux-reported one, and reap anything we still track but tmux has lost.
|
/// the tmux-reported one, and reap anything we still track but tmux has lost.
|
||||||
async fn run_liveness_pass(inner: &Arc<Inner>) {
|
async fn run_liveness_pass(inner: &Arc<Inner>) {
|
||||||
@@ -1037,13 +1012,11 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
||||||
/// still attach and read the replay buffer — the last thing a crashed agent
|
/// still attach and read the replay buffer — the last thing a crashed agent
|
||||||
/// printed. The worktree, though, is settled right away: a pristine tree is
|
/// printed. The worktree, though, is settled right away: a pristine tree is
|
||||||
/// removed, anything else is retained into the store with reason
|
/// removed (#82) — nobody is waiting for an answer here, so there is nothing
|
||||||
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
|
/// to refuse. Once settled, the session drops its worktree reference so a
|
||||||
/// so there is nothing to refuse. Once settled, the session drops its
|
/// later dismissal does not re-probe it. A tree holding unsaved work is left
|
||||||
/// worktree reference so a later dismissal neither re-probes nor retains
|
/// alone and stays attached to the dead record for a later forced dismissal
|
||||||
/// twice. When settling is impossible (probe or store failure) nothing is
|
/// (#81), which leaves it on disk too.
|
||||||
/// destroyed: the tree stays attached to the dead record for a later forced
|
|
||||||
/// dismissal (#81).
|
|
||||||
///
|
///
|
||||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||||
/// adopts only what tmux still holds.
|
/// adopts only what tmux still holds.
|
||||||
@@ -1061,21 +1034,20 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
.clone()
|
.clone()
|
||||||
.zip(record.worktree_path.clone());
|
.zip(record.worktree_path.clone());
|
||||||
let branch = record.worktree_branch.clone();
|
Some((session, worktree))
|
||||||
Some((session, worktree, branch))
|
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some((mut session, worktree, branch)) = flipped else {
|
let Some((mut session, worktree)) = flipped else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
tracing::info!(%id, "session vanished from tmux — marking dead");
|
tracing::info!(%id, "session vanished from tmux — marking dead");
|
||||||
|
|
||||||
if let Some(wt) = &worktree {
|
if let Some(wt) = &worktree {
|
||||||
if settle_worktree_on_exit(inner, id, wt, branch).await {
|
if settle_worktree_on_exit(inner, id, wt).await {
|
||||||
// The tree is settled (removed or retained); a stale "in the
|
// The tree is settled (removed); a stale "in the way" would
|
||||||
// way" would describe something the session no longer holds.
|
// describe something the session no longer holds.
|
||||||
session.blocked_work = None;
|
session.blocked_work = None;
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
@@ -1083,7 +1055,6 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
record.session.blocked_work = None;
|
record.session.blocked_work = None;
|
||||||
record.worktree_path = None;
|
record.worktree_path = None;
|
||||||
record.worktree_base_repo = None;
|
record.worktree_base_repo = None;
|
||||||
record.worktree_branch = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1102,10 +1073,8 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
|
|
||||||
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
||||||
/// would be lost through the kill's own predicate, then remove a pristine
|
/// would be lost through the kill's own predicate, then remove a pristine
|
||||||
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
|
/// tree. Anything holding unsaved work is left exactly where it is — the
|
||||||
/// before anything is torn down, exactly as the forced kill does (#81). A
|
/// same thing a forced kill does with a guarded tree (#81).
|
||||||
/// removal that fails falls back to retaining, since whatever blocked it is
|
|
||||||
/// by definition something worth keeping.
|
|
||||||
///
|
///
|
||||||
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
||||||
/// nothing was destroyed and the caller keeps the reference on the record.
|
/// nothing was destroyed and the caller keeps the reference on the record.
|
||||||
@@ -1113,7 +1082,6 @@ async fn settle_worktree_on_exit(
|
|||||||
inner: &Arc<Inner>,
|
inner: &Arc<Inner>,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
(base_repo, path): &(PathBuf, PathBuf),
|
(base_repo, path): &(PathBuf, PathBuf),
|
||||||
known_branch: Option<String>,
|
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
|
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
|
||||||
Ok(probe) => probe,
|
Ok(probe) => probe,
|
||||||
@@ -1127,45 +1095,25 @@ async fn settle_worktree_on_exit(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if probe.is_clear() {
|
if !probe.is_clear() {
|
||||||
match inner.worktree_mgr.remove(base_repo, path) {
|
tracing::info!(
|
||||||
Ok(()) => {
|
%id,
|
||||||
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
path = %path.display(),
|
||||||
return true;
|
"natural exit: worktree holds unsaved work; leaving it on disk"
|
||||||
}
|
);
|
||||||
Err(e) => {
|
return false;
|
||||||
tracing::warn!(
|
|
||||||
%id,
|
|
||||||
path = %path.display(),
|
|
||||||
error = ?e,
|
|
||||||
"natural exit: removal failed; falling back to retention"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
match record_retention(
|
match inner.worktree_mgr.remove(base_repo, path) {
|
||||||
inner,
|
Ok(()) => {
|
||||||
&(base_repo.clone(), path.clone()),
|
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
||||||
known_branch,
|
|
||||||
RetainedReason::ExitedWithUnsavedWork,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(entry) => {
|
|
||||||
tracing::info!(
|
|
||||||
%id,
|
|
||||||
path = %path.display(),
|
|
||||||
entry = %entry.id,
|
|
||||||
"natural exit: worktree retained with unsaved work"
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::warn!(
|
||||||
%id,
|
%id,
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
error = ?e,
|
error = ?e,
|
||||||
"natural exit: could not record the retained worktree; leaving it untouched"
|
"natural exit: removal failed; leaving the worktree on disk"
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -1182,15 +1130,15 @@ async fn settle_worktree_on_exit(
|
|||||||
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
||||||
/// liveness reaper (tmux already lost the session).
|
/// liveness reaper (tmux already lost the session).
|
||||||
///
|
///
|
||||||
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
|
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
|
||||||
/// worktree instead of removing it: the store entry is already written, so
|
/// session ends but the worktree is left on disk, so only the removal is
|
||||||
/// only the removal is skipped.
|
/// skipped.
|
||||||
async fn cleanup_record(
|
async fn cleanup_record(
|
||||||
inner: &Arc<Inner>,
|
inner: &Arc<Inner>,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
record: SessionRecord,
|
record: SessionRecord,
|
||||||
kill_tmux: bool,
|
kill_tmux: bool,
|
||||||
retain_worktree: bool,
|
keep_worktree: bool,
|
||||||
) {
|
) {
|
||||||
if kill_tmux {
|
if kill_tmux {
|
||||||
let tmux = inner.tmux.clone();
|
let tmux = inner.tmux.clone();
|
||||||
@@ -1201,11 +1149,11 @@ async fn cleanup_record(
|
|||||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||||
}
|
}
|
||||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||||
if retain_worktree {
|
if keep_worktree {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
%id,
|
%id,
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
"forced kill: worktree retained in the store"
|
"forced kill: worktree left on disk"
|
||||||
);
|
);
|
||||||
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||||
// The kill already passed the unsaved-work guard (#79), so this
|
// The kill already passed the unsaved-work guard (#79), so this
|
||||||
|
|||||||
@@ -246,6 +246,70 @@ impl Tmux {
|
|||||||
check(out)
|
check(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escape-sequence snapshot of a pane's current screen (#…), for clients
|
||||||
|
/// that just attached.
|
||||||
|
///
|
||||||
|
/// The raw byte replay buffer cannot reconstruct a full-screen program:
|
||||||
|
/// its tail starts mid-stream, long after the app set up the modes,
|
||||||
|
/// colours and cursor position that give those bytes meaning. A browser
|
||||||
|
/// refresh therefore painted garbage until something forced the app to
|
||||||
|
/// repaint. tmux already holds the authoritative screen, so ask it.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when the session or the tmux server is gone — the
|
||||||
|
/// caller falls back to the byte replay, which is all a dead session has.
|
||||||
|
pub fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
|
||||||
|
let name = session_name(id);
|
||||||
|
let start = format!("-{CAPTURE_HISTORY_LINES}");
|
||||||
|
// One invocation, so tmux runs both commands in a single pass of its
|
||||||
|
// command queue: the cursor position cannot belong to an older screen
|
||||||
|
// than the captured content. Two invocations raced — a program that
|
||||||
|
// switched to the alternate screen in between produced a snapshot with
|
||||||
|
// the new screen and the old cursor.
|
||||||
|
let out = self
|
||||||
|
.cmd()
|
||||||
|
.args([
|
||||||
|
"display-message",
|
||||||
|
"-p",
|
||||||
|
"-t",
|
||||||
|
&name,
|
||||||
|
"-F",
|
||||||
|
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag}",
|
||||||
|
";",
|
||||||
|
"capture-pane",
|
||||||
|
"-p",
|
||||||
|
"-e",
|
||||||
|
// Keep trailing spaces, so a line's background colour reaches
|
||||||
|
// the edge instead of stopping at the last glyph and letting
|
||||||
|
// the client's own background show through.
|
||||||
|
"-N",
|
||||||
|
"-t",
|
||||||
|
&name,
|
||||||
|
"-S",
|
||||||
|
&start,
|
||||||
|
])
|
||||||
|
.output()?;
|
||||||
|
let Some(stdout) = soft_output(out)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some((head, content)) = stdout.split_once('\n') else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(geom) = parse_screen_geometry(head) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut lines: Vec<&str> = content
|
||||||
|
.strip_suffix('\n')
|
||||||
|
.unwrap_or(content)
|
||||||
|
.split('\n')
|
||||||
|
.collect();
|
||||||
|
// Scrollback belongs to the normal screen. Replaying it under an
|
||||||
|
// alternate-screen program would paste the shell's history behind it.
|
||||||
|
if geom.alternate {
|
||||||
|
lines.drain(..lines.len().saturating_sub(geom.pane_height));
|
||||||
|
}
|
||||||
|
Ok(Some(render_screen_snapshot(&lines, geom)))
|
||||||
|
}
|
||||||
|
|
||||||
/// `tmux resize-window -t maestro-<id> -x <cols> -y <rows>`.
|
/// `tmux resize-window -t maestro-<id> -x <cols> -y <rows>`.
|
||||||
pub fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
pub fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
||||||
let name = session_name(id);
|
let name = session_name(id);
|
||||||
@@ -446,6 +510,124 @@ fn new_session_args(
|
|||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much scrollback a fresh attach inherits from tmux's history, in lines.
|
||||||
|
/// Enough to land in context after a browser refresh without making the first
|
||||||
|
/// frame huge.
|
||||||
|
const CAPTURE_HISTORY_LINES: usize = 300;
|
||||||
|
|
||||||
|
/// Cursor position and screen geometry of a pane, from `display-message`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct ScreenGeometry {
|
||||||
|
cursor_x: usize,
|
||||||
|
cursor_y: usize,
|
||||||
|
pane_height: usize,
|
||||||
|
alternate: bool,
|
||||||
|
cursor_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
||||||
|
let line = s.lines().next()?;
|
||||||
|
let mut it = line.split_whitespace();
|
||||||
|
let cursor_x = it.next()?.parse().ok()?;
|
||||||
|
let cursor_y = it.next()?.parse().ok()?;
|
||||||
|
let pane_height: usize = it.next()?.parse().ok()?;
|
||||||
|
let alternate = it.next()? == "1";
|
||||||
|
// Older tmux without `cursor_flag` just means "assume visible".
|
||||||
|
let cursor_visible = it.next().map(|f| f == "1").unwrap_or(true);
|
||||||
|
if pane_height == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ScreenGeometry {
|
||||||
|
cursor_x,
|
||||||
|
cursor_y,
|
||||||
|
pane_height,
|
||||||
|
alternate,
|
||||||
|
cursor_visible,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn captured pane lines into bytes a fresh terminal can replay: clear,
|
||||||
|
/// print the lines, then put the cursor back where the pane has it.
|
||||||
|
///
|
||||||
|
/// The cursor is restored with *relative* moves so the snapshot renders the
|
||||||
|
/// same whatever height the client's terminal has — it may not match the
|
||||||
|
/// pane's yet, since the client's first `Resize` arrives after this.
|
||||||
|
fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
|
||||||
|
// Screen rows are the last `pane_height` captured lines; anything before
|
||||||
|
// them is scrollback.
|
||||||
|
let screen_top = lines.len().saturating_sub(geom.pane_height);
|
||||||
|
let cursor_row = (screen_top + geom.cursor_y).min(lines.len().saturating_sub(1));
|
||||||
|
// Trailing blank rows would scroll the real content off a short client
|
||||||
|
// terminal, so drop them — but never the cursor's own row or anything
|
||||||
|
// above it, which is where a program that draws below its cursor lives.
|
||||||
|
let last = lines
|
||||||
|
.iter()
|
||||||
|
.rposition(|l| !strip_sgr(l).trim().is_empty())
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(cursor_row);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// Put the client on the same buffer the pane is using, so a program's
|
||||||
|
// repaints stay out of the client's scrollback while it runs.
|
||||||
|
if geom.alternate {
|
||||||
|
out.extend_from_slice(b"\x1b[?1049h");
|
||||||
|
}
|
||||||
|
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
|
||||||
|
for (i, line) in lines[..=last].iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
out.extend_from_slice(line.as_bytes());
|
||||||
|
}
|
||||||
|
out.extend_from_slice(b"\x1b[m\r");
|
||||||
|
if last > cursor_row {
|
||||||
|
out.extend_from_slice(format!("\x1b[{}A", last - cursor_row).as_bytes());
|
||||||
|
}
|
||||||
|
if geom.cursor_x > 0 {
|
||||||
|
out.extend_from_slice(format!("\x1b[{}C", geom.cursor_x).as_bytes());
|
||||||
|
}
|
||||||
|
if !geom.cursor_visible {
|
||||||
|
out.extend_from_slice(b"\x1b[?25l");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text of a line with SGR escapes removed, for emptiness checks.
|
||||||
|
fn strip_sgr(line: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(line.len());
|
||||||
|
let mut chars = line.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c != '\x1b' {
|
||||||
|
out.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip CSI ... final-byte. Anything else escape-ish is not produced by
|
||||||
|
// `capture-pane -e`, so treating it the same way is harmless.
|
||||||
|
for c in chars.by_ref() {
|
||||||
|
if c.is_ascii_alphabetic() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Ok(None)` when tmux failed because the server or the target is gone,
|
||||||
|
/// `Ok(Some(stdout))` on success, `Err` for anything else.
|
||||||
|
fn soft_output(out: Output) -> Result<Option<String>, TmuxError> {
|
||||||
|
if out.status.success() {
|
||||||
|
return Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()));
|
||||||
|
}
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
if is_no_server_error(&stderr) || stderr.contains("can't find") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(TmuxError::NonZero {
|
||||||
|
code: out.status.code().unwrap_or(-1),
|
||||||
|
stderr: stderr.into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn check(out: Output) -> Result<(), TmuxError> {
|
fn check(out: Output) -> Result<(), TmuxError> {
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -461,6 +643,85 @@ fn check(out: Output) -> Result<(), TmuxError> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
|
||||||
|
let g = parse_screen_geometry("12 3 40 1 0\n").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
g,
|
||||||
|
ScreenGeometry {
|
||||||
|
cursor_x: 12,
|
||||||
|
cursor_y: 3,
|
||||||
|
pane_height: 40,
|
||||||
|
alternate: true,
|
||||||
|
cursor_visible: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let g = parse_screen_geometry("0 0 24 0").unwrap();
|
||||||
|
assert!(!g.alternate);
|
||||||
|
assert!(g.cursor_visible, "missing cursor_flag means visible");
|
||||||
|
assert!(parse_screen_geometry("0 0 0 0").is_none());
|
||||||
|
assert!(parse_screen_geometry("garbage").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
|
||||||
|
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
|
||||||
|
let lines = ["old", "older", "prompt> hi", "", ""];
|
||||||
|
let out = render_screen_snapshot(
|
||||||
|
&lines,
|
||||||
|
ScreenGeometry {
|
||||||
|
cursor_x: 10,
|
||||||
|
cursor_y: 0,
|
||||||
|
pane_height: 3,
|
||||||
|
alternate: false,
|
||||||
|
cursor_visible: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let out = String::from_utf8(out).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"\x1b[H\x1b[2J\x1b[mold\r\nolder\r\nprompt> hi\x1b[m\r\x1b[10C"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_keeps_rows_below_the_cursor_and_moves_back_up() {
|
||||||
|
let lines = ["prompt> hi", "hint line", ""];
|
||||||
|
let out = render_screen_snapshot(
|
||||||
|
&lines,
|
||||||
|
ScreenGeometry {
|
||||||
|
cursor_x: 0,
|
||||||
|
cursor_y: 0,
|
||||||
|
pane_height: 3,
|
||||||
|
alternate: true,
|
||||||
|
cursor_visible: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let out = String::from_utf8(out).unwrap();
|
||||||
|
assert!(out.starts_with("\x1b[?1049h\x1b[H\x1b[2J"), "{out:?}");
|
||||||
|
assert!(
|
||||||
|
out.ends_with("prompt> hi\r\nhint line\x1b[m\r\x1b[1A\x1b[?25l"),
|
||||||
|
"{out:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_ignores_colour_only_rows_when_trimming() {
|
||||||
|
let lines = ["text", "\x1b[38;5;4m\x1b[m", ""];
|
||||||
|
let out = String::from_utf8(render_screen_snapshot(
|
||||||
|
&lines,
|
||||||
|
ScreenGeometry {
|
||||||
|
cursor_x: 4,
|
||||||
|
cursor_y: 0,
|
||||||
|
pane_height: 3,
|
||||||
|
alternate: false,
|
||||||
|
cursor_visible: true,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
|
||||||
|
}
|
||||||
|
|
||||||
/// Every phrasing must be recognised: an unmatched one makes
|
/// Every phrasing must be recognised: an unmatched one makes
|
||||||
/// `list_sessions` fail, and a failed pass reaps nothing.
|
/// `list_sessions` fail, and a failed pass reaps nothing.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
//! The one git probe for what a worktree holds that would be lost, plus
|
//! The one git probe for what a worktree holds that would be lost (#79).
|
||||||
//! guard-free reclaim (#79, #80).
|
|
||||||
//!
|
//!
|
||||||
//! [`in_the_way`] is the single predicate behind all three paths: the kill
|
//! [`in_the_way`] is the single predicate behind both paths: the kill refusal
|
||||||
//! refusal (#79), the natural-exit classifier (#82), and the retained list
|
//! (#79) and the natural-exit classifier (#82). It lives here rather than in
|
||||||
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
|
//! `worktree.rs` so that neither can answer the question differently.
|
||||||
//! answer the question differently — #79 and #80 were built in parallel and
|
|
||||||
//! each grew its own copy, which is exactly the drift this collapses.
|
|
||||||
//!
|
|
||||||
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
|
|
||||||
//! the operator chose it while looking at what was in the way.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -27,7 +21,7 @@ pub enum ProbeError {
|
|||||||
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
||||||
/// `target/`, and counting those would put every tree permanently in the way.
|
/// `target/`, and counting those would put every tree permanently in the way.
|
||||||
/// A directory that no longer exists reports nothing in the way: there is
|
/// A directory that no longer exists reports nothing in the way: there is
|
||||||
/// nothing left to lose, and the store's own pruning drops the entry.
|
/// nothing left to lose.
|
||||||
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||||
let mut breakdown = InTheWay::default();
|
let mut breakdown = InTheWay::default();
|
||||||
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
||||||
@@ -67,37 +61,6 @@ pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
|||||||
Ok(breakdown)
|
Ok(breakdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a retained worktree's directory without consulting any guard, per
|
|
||||||
/// the reclaim contract: the operator chose this while looking at what was in
|
|
||||||
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
|
|
||||||
/// the branch survive even when they were never pushed.
|
|
||||||
///
|
|
||||||
/// A directory that is already gone only leaves a stale registration behind;
|
|
||||||
/// `worktree prune` clears it so the branch can be spawned again.
|
|
||||||
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
|
|
||||||
if !path.exists() {
|
|
||||||
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
|
|
||||||
}
|
|
||||||
run_git(
|
|
||||||
base_repo,
|
|
||||||
[
|
|
||||||
"worktree",
|
|
||||||
"remove",
|
|
||||||
"--force",
|
|
||||||
path.to_str().expect("worktree path is valid utf-8"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
|
|
||||||
/// branch on record, so a forced kill (#81) asks the tree itself when it
|
|
||||||
/// needs one for the retained store. `None` when git cannot answer.
|
|
||||||
pub fn current_branch(path: &Path) -> Option<String> {
|
|
||||||
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
|
||||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
@@ -272,54 +235,4 @@ mod tests {
|
|||||||
let breakdown = in_the_way(&gone).expect("probe");
|
let breakdown = in_the_way(&gone).expect("probe");
|
||||||
assert!(breakdown.is_clear());
|
assert!(breakdown.is_clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
|
|
||||||
let r = repo_with_worktree();
|
|
||||||
r.commit_in_wt("precious");
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
|
||||||
assert!(!r.wt().exists());
|
|
||||||
|
|
||||||
// A second reclaim over the now-missing path just prunes.
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim again");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reclaim_keeps_the_branch_and_its_commits() {
|
|
||||||
let r = repo_with_worktree();
|
|
||||||
r.commit_in_wt("precious");
|
|
||||||
let sha = {
|
|
||||||
let out = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(r.wt())
|
|
||||||
.args(["rev-parse", "HEAD"])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
|
||||||
|
|
||||||
let out = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&r.repo)
|
|
||||||
.args(["rev-parse", "--verify", "refs/heads/feature"])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"branch must survive reclaim: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reclaim_is_guard_free_on_a_dirty_tree() {
|
|
||||||
let r = repo_with_worktree();
|
|
||||||
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
|
|
||||||
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
|
|
||||||
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
|
|
||||||
assert!(!r.wt().exists());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ async fn spawn_daemon_ws() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
std::sync::Arc::new(fx.retained_store()),
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ async fn spawn() -> Harness {
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||||
std::sync::Arc::new(fx.retained_store()),
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -176,11 +176,6 @@ impl DaemonFixture {
|
|||||||
manager =
|
manager =
|
||||||
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
||||||
}
|
}
|
||||||
// Same wiring as production: the manager records retained worktrees
|
|
||||||
// into the same dir the API reads back from (#78/#81).
|
|
||||||
let retained =
|
|
||||||
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
|
|
||||||
manager = manager.with_retained_store(retained.clone());
|
|
||||||
Self {
|
Self {
|
||||||
tmux,
|
tmux,
|
||||||
manager,
|
manager,
|
||||||
@@ -192,10 +187,6 @@ impl DaemonFixture {
|
|||||||
state_dir.path().join("spawn-history.jsonl")
|
state_dir.path().join("spawn-history.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retained_path(state_dir: &TempDir) -> PathBuf {
|
|
||||||
state_dir.path().join("retained-worktrees")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_history_path(&self) -> PathBuf {
|
pub fn spawn_history_path(&self) -> PathBuf {
|
||||||
Self::history_path(&self.state_dir)
|
Self::history_path(&self.state_dir)
|
||||||
}
|
}
|
||||||
@@ -204,15 +195,6 @@ impl DaemonFixture {
|
|||||||
self.state_dir.path().join("sessions")
|
self.state_dir.path().join("sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retained-worktree store rooted at the fixture's own state dir (#78).
|
|
||||||
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
|
|
||||||
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn retained_dir(&self) -> PathBuf {
|
|
||||||
self.state_dir.path().join("retained-worktrees")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fifo_dir(&self) -> PathBuf {
|
pub fn fifo_dir(&self) -> PathBuf {
|
||||||
self.state_dir.path().join("fifos")
|
self.state_dir.path().join("fifos")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ struct Harness {
|
|||||||
_fx: DaemonFixture,
|
_fx: DaemonFixture,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Harness {
|
|
||||||
/// Store over the same dir the manager records into.
|
|
||||||
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
|
|
||||||
self._fx.retained_store()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn(hostname: &str) -> Harness {
|
async fn spawn(hostname: &str) -> Harness {
|
||||||
spawn_with(
|
spawn_with(
|
||||||
hostname,
|
hostname,
|
||||||
@@ -55,7 +48,6 @@ async fn spawn_with(
|
|||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
projects,
|
projects,
|
||||||
std::sync::Arc::new(fx.retained_store()),
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
@@ -323,6 +315,48 @@ async fn delete_unknown_session_returns_404() {
|
|||||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #125: the rename endpoint answers with the updated session, and the name
|
||||||
|
/// it stored is the trimmed one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_session_renames_it() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let session = h
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sleep 60".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.patch(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||||
|
.json(&serde_json::json!({ "name": " crimson-otter " }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||||
|
let updated: Session = resp.json().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
updated.metadata.get("name").and_then(|v| v.as_str()),
|
||||||
|
Some("crimson-otter")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_unknown_session_returns_404() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.patch(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
|
||||||
|
.json(&serde_json::json!({ "name": "nope" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_owned_session_kills_it() {
|
async fn delete_owned_session_kills_it() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
@@ -495,11 +529,10 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
assert!(h.manager.has(session.id).await, "the session keeps running");
|
assert!(h.manager.has(session.id).await, "the session keeps running");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `?force=true` on the wire (#81): the refusal is overridden, the session
|
/// `?force=true` on the wire (#81): the refusal is overridden and the session
|
||||||
/// ends, and the answer is 200 with the retained worktree named — path,
|
/// ends, while the guarded worktree survives on disk.
|
||||||
/// branch, reason — while the directory survives on disk.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let repo = tempfile::TempDir::new().unwrap();
|
let repo = tempfile::TempDir::new().unwrap();
|
||||||
common::init_repo(repo.path());
|
common::init_repo(repo.path());
|
||||||
@@ -532,22 +565,11 @@ async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
|
|
||||||
let retained = body.retained.expect("force names what was kept");
|
|
||||||
assert_eq!(retained.path, wt_path);
|
|
||||||
assert_eq!(retained.branch, "feat/guarded");
|
|
||||||
assert_eq!(
|
|
||||||
retained.reason,
|
|
||||||
maestro_protocol::RetainedReason::ForceKilled
|
|
||||||
);
|
|
||||||
|
|
||||||
// The tree would have blocked; force must not have removed it.
|
// The tree would have blocked; force must not have removed it.
|
||||||
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
||||||
// And the store entry exists, so reclaim can find it (#80).
|
assert!(!h.manager.has(session.id).await, "the session is gone");
|
||||||
let all = h.fx_retained().load_all().unwrap();
|
|
||||||
assert_eq!(all.len(), 1);
|
|
||||||
assert_eq!(all[0].id, retained.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -704,234 +726,3 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `git -C dir args`, asserting success — the worktree endpoints are tested
|
|
||||||
/// against real git, like everything else in this crate.
|
|
||||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
|
||||||
let out = std::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(dir)
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.expect("git spawn");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"git {args:?} failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RetainedFixture {
|
|
||||||
repo: tempfile::TempDir,
|
|
||||||
wt: std::path::PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
|
|
||||||
/// an uncommitted tracked edit and an unpushed commit.
|
|
||||||
fn retained_fixture() -> RetainedFixture {
|
|
||||||
let repo = tempfile::tempdir().unwrap();
|
|
||||||
common::init_repo(repo.path());
|
|
||||||
// A pushed main, so "unpushed" means commits made on the feature branch,
|
|
||||||
// not the shared history.
|
|
||||||
let remote = repo.path().join("remote.git");
|
|
||||||
std::fs::create_dir_all(&remote).unwrap();
|
|
||||||
let remote_s = remote.to_string_lossy().into_owned();
|
|
||||||
git(&remote, &["init", "-q", "--bare"]);
|
|
||||||
git(repo.path(), &["remote", "add", "origin", &remote_s]);
|
|
||||||
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
|
|
||||||
git(
|
|
||||||
repo.path(),
|
|
||||||
&[
|
|
||||||
"worktree",
|
|
||||||
"add",
|
|
||||||
"-q",
|
|
||||||
"-b",
|
|
||||||
"feat/login",
|
|
||||||
"worktrees/feat/login",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let wt = repo.path().join("worktrees").join("feat").join("login");
|
|
||||||
RetainedFixture { repo, wt }
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RetainedFixture {
|
|
||||||
fn commit_in_wt(&self, msg: &str) {
|
|
||||||
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
|
|
||||||
git(&self.wt, &["add", "-A"]);
|
|
||||||
git(&self.wt, &["commit", "-qm", msg]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn branch_sha(&self, branch: &str) -> String {
|
|
||||||
let out = std::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(self.repo.path())
|
|
||||||
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(out.status.success(), "branch {branch} must exist");
|
|
||||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
// Uncommitted tracked edit + an untracked file; the branch has no commits
|
|
||||||
// beyond main yet.
|
|
||||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
|
|
||||||
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
|
|
||||||
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
let entry = store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(listed.len(), 1);
|
|
||||||
let w = &listed[0];
|
|
||||||
assert_eq!(w.id, entry.id);
|
|
||||||
assert_eq!(w.path, fx.wt);
|
|
||||||
assert_eq!(w.base_repo, fx.repo.path());
|
|
||||||
assert_eq!(w.branch, "feat/login");
|
|
||||||
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
|
|
||||||
assert_eq!(w.in_the_way.uncommitted_files, 1);
|
|
||||||
assert_eq!(w.in_the_way.untracked_files, 1);
|
|
||||||
assert_eq!(w.in_the_way.unpushed_commits, 0);
|
|
||||||
|
|
||||||
// The operator saves the work themselves. The stored entry is untouched —
|
|
||||||
// the next read must report the tree as it is *now*, not as it was when
|
|
||||||
// it was retained.
|
|
||||||
fx.commit_in_wt("saved");
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(listed.len(), 1, "same entry, still retained");
|
|
||||||
assert_eq!(listed[0].id, entry.id);
|
|
||||||
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
|
|
||||||
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
fx.commit_in_wt("precious");
|
|
||||||
let sha = fx.branch_sha("feat/login");
|
|
||||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
|
|
||||||
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
let entry = store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
assert!(!fx.wt.exists(), "reclaim removes the directory");
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(listed.is_empty(), "reclaim drops the store entry");
|
|
||||||
assert_eq!(
|
|
||||||
fx.branch_sha("feat/login"),
|
|
||||||
sha,
|
|
||||||
"the branch and its commits survive"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
let entry = store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
|
|
||||||
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
||||||
assert!(store.load_all().unwrap().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reclaim_unknown_id_returns_404() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let fx = retained_fixture();
|
|
||||||
let store = h._fx.retained_store();
|
|
||||||
store
|
|
||||||
.retain(
|
|
||||||
&fx.wt,
|
|
||||||
fx.repo.path(),
|
|
||||||
"feat/login",
|
|
||||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::remove_dir_all(&fx.wt).unwrap();
|
|
||||||
|
|
||||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
|
||||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(listed.is_empty(), "a hand-removed directory is not listed");
|
|
||||||
assert!(
|
|
||||||
store.load_all().unwrap().is_empty(),
|
|
||||||
"and its entry is gone"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Renaming a live session (#125): `SessionManager::rename` semantics —
|
||||||
|
//! what lands in `metadata.name`, what reaches the sidecar, and what goes out
|
||||||
|
//! on the event stream.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use common::TmuxFixture;
|
||||||
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
|
use maestro_daemon::SessionManager;
|
||||||
|
use maestro_protocol::{CatalogEvent, CreateSessionRequest, MAX_SESSION_NAME_LEN};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
manager: SessionManager,
|
||||||
|
sidecar: SidecarStore,
|
||||||
|
_tmux: TmuxFixture,
|
||||||
|
_state_dir: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
let tmux = TmuxFixture::new();
|
||||||
|
let state_dir = TempDir::new().unwrap();
|
||||||
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
Arc::new(tmux.tmux.clone()),
|
||||||
|
sidecar.clone(),
|
||||||
|
WorktreeManager::new(None),
|
||||||
|
state_dir.path().join("fifos"),
|
||||||
|
);
|
||||||
|
Fixture {
|
||||||
|
manager,
|
||||||
|
sidecar,
|
||||||
|
_tmux: tmux,
|
||||||
|
_state_dir: state_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||||
|
let metadata = match name {
|
||||||
|
Some(n) => std::collections::HashMap::from([(
|
||||||
|
"name".to_string(),
|
||||||
|
serde_json::Value::String(n.to_string()),
|
||||||
|
)]),
|
||||||
|
None => Default::default(),
|
||||||
|
};
|
||||||
|
manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sleep 60".into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata,
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_of(session: &maestro_protocol::Session) -> Option<&str> {
|
||||||
|
session.metadata.get("name").and_then(|v| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_on_unknown_session_is_not_found() {
|
||||||
|
let fx = fixture();
|
||||||
|
let err = fx
|
||||||
|
.manager
|
||||||
|
.rename(Uuid::new_v4(), "whatever")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_sets_the_name_in_memory_and_on_disk() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
||||||
|
|
||||||
|
let renamed = fx.manager.rename(id, " crimson-otter ").await.unwrap();
|
||||||
|
assert_eq!(name_of(&renamed), Some("crimson-otter"));
|
||||||
|
|
||||||
|
let snapshot = fx.manager.snapshot().await;
|
||||||
|
let live = snapshot.iter().find(|s| s.id == id).unwrap();
|
||||||
|
assert_eq!(name_of(live), Some("crimson-otter"));
|
||||||
|
|
||||||
|
// A rename a daemon restart would undo is worse than one that failed.
|
||||||
|
let persisted = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(name_of(&persisted), Some("crimson-otter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_blank_rename_clears_the_name() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
||||||
|
|
||||||
|
let renamed = fx.manager.rename(id, " ").await.unwrap();
|
||||||
|
assert_eq!(name_of(&renamed), None);
|
||||||
|
// Line 1 falls back to the command, per docs/SIDEBAR.md.
|
||||||
|
assert_eq!(renamed.command, "sleep 60");
|
||||||
|
assert_eq!(name_of(&fx.sidecar.read(id).unwrap().unwrap()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_caps_a_long_name() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, None).await;
|
||||||
|
let long = "x".repeat(MAX_SESSION_NAME_LEN + 20);
|
||||||
|
let renamed = fx.manager.rename(id, &long).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
name_of(&renamed).unwrap().chars().count(),
|
||||||
|
MAX_SESSION_NAME_LEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_broadcasts_a_session_update() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
|
||||||
|
fx.manager.rename(id, "crimson-otter").await.unwrap();
|
||||||
|
|
||||||
|
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
|
||||||
|
.await
|
||||||
|
.expect("an update within the timeout")
|
||||||
|
.expect("the event channel stays open");
|
||||||
|
match event {
|
||||||
|
CatalogEvent::SessionUpdated { session } => {
|
||||||
|
assert_eq!(session.id, id);
|
||||||
|
assert_eq!(name_of(&session), Some("crimson-otter"));
|
||||||
|
}
|
||||||
|
other => panic!("expected SessionUpdated, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,7 @@ use std::process::Command;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::{
|
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||||
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
|
|
||||||
};
|
|
||||||
use maestro_daemon::SessionError;
|
use maestro_daemon::SessionError;
|
||||||
use maestro_protocol::CreateSessionRequest;
|
use maestro_protocol::CreateSessionRequest;
|
||||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||||
@@ -190,6 +188,64 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
fx.manager.kill(s.id, false).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fresh attach must land on the pane's *current* screen. Replaying the raw
|
||||||
|
/// byte tail instead reproduced a browser refresh painting garbage over an
|
||||||
|
/// agent TUI: the tail starts mid-stream, so the clears, modes and cursor
|
||||||
|
/// moves that gave those bytes meaning are long gone.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
// Print a line, then switch to the alternate screen the way a
|
||||||
|
// full-screen program does on startup. Only the second marker is
|
||||||
|
// on the pane; the first belongs to the screen behind it.
|
||||||
|
command: "sh -c 'printf STALE_MARKER_LINE\\\\n; sleep 0.5; printf \"\\\\033[?1049h\\\\033[2J\\\\033[HLIVE_MARKER\"; exec sleep 30'"
|
||||||
|
.into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
let want = b"LIVE_MARKER";
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut replay = Vec::new();
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
let (bytes, _rx) = fx
|
||||||
|
.manager
|
||||||
|
.subscribe_pty_with_replay(s.id)
|
||||||
|
.await
|
||||||
|
.expect("subscribe");
|
||||||
|
replay = bytes;
|
||||||
|
if replay.windows(want.len()).any(|w| w == want) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&replay).into_owned();
|
||||||
|
assert!(
|
||||||
|
replay.starts_with(b"\x1b[?1049h\x1b[H\x1b[2J"),
|
||||||
|
"replay did not start with an alternate-screen snapshot: {text:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("LIVE_MARKER"),
|
||||||
|
"replay lost the live screen: {text:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("STALE_MARKER_LINE"),
|
||||||
|
"replay carried bytes from behind the alternate screen: {text:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
||||||
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
||||||
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
||||||
@@ -609,11 +665,9 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
||||||
/// but the guarded worktree is RETAINED — never removed — and enters the
|
/// but the guarded worktree is left on disk — never removed.
|
||||||
/// retained store with reason `force-killed`. The outcome names what was
|
|
||||||
/// kept, so a client can say where the work went.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
init_repo(repo.path());
|
init_repo(repo.path());
|
||||||
@@ -639,8 +693,7 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
|||||||
// Without force the same kill is refused (#79).
|
// Without force the same kill is refused (#79).
|
||||||
assert!(fx.manager.kill(s.id, false).await.is_err());
|
assert!(fx.manager.kill(s.id, false).await.is_err());
|
||||||
|
|
||||||
let outcome = fx
|
fx.manager
|
||||||
.manager
|
|
||||||
.kill(s.id, true)
|
.kill(s.id, true)
|
||||||
.await
|
.await
|
||||||
.expect("force overrides the refusal");
|
.expect("force overrides the refusal");
|
||||||
@@ -653,30 +706,11 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
|||||||
// ...but the session itself still ends.
|
// ...but the session itself still ends.
|
||||||
assert!(!fx.manager.has(s.id).await);
|
assert!(!fx.manager.has(s.id).await);
|
||||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
|
||||||
let retained = outcome.retained.expect("the outcome names what was kept");
|
|
||||||
assert_eq!(retained.path, wt_path);
|
|
||||||
assert_eq!(retained.branch, "feat/forced");
|
|
||||||
assert_eq!(
|
|
||||||
retained.reason,
|
|
||||||
maestro_protocol::RetainedReason::ForceKilled
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
retained.in_the_way.uncommitted_files >= 1,
|
|
||||||
"the outcome carries what blocked the unforced kill"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The store holds the same entry, so reclaim can find it later (#80).
|
|
||||||
let all = fx.retained_store().load_all().expect("load_all");
|
|
||||||
assert_eq!(all.len(), 1);
|
|
||||||
assert_eq!(all[0].id, retained.id);
|
|
||||||
assert_eq!(all[0].path, wt_path);
|
|
||||||
assert_eq!(all[0].branch, "feat/forced");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
|
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
|
||||||
/// guard is clear never needed the override, so a forced kill removes it as
|
/// guard is clear never needed the override, so a forced kill removes it as
|
||||||
/// usual and retains nothing.
|
/// usual.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -704,10 +738,8 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
init_bare_repo(origin.path());
|
init_bare_repo(origin.path());
|
||||||
push_all_to_origin(repo.path(), origin.path());
|
push_all_to_origin(repo.path(), origin.path());
|
||||||
|
|
||||||
let outcome = fx.manager.kill(s.id, true).await.expect("kill");
|
fx.manager.kill(s.id, true).await.expect("kill");
|
||||||
assert!(!wt_path.exists(), "a clear tree needs no override");
|
assert!(!wt_path.exists(), "a clear tree needs no override");
|
||||||
assert_eq!(outcome.retained, None);
|
|
||||||
assert!(fx.retained_store().load_all().unwrap().is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
||||||
@@ -757,12 +789,11 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
|
|
||||||
/// Natural exit (#82): a session that ends on its own never reaches the kill
|
/// Natural exit (#82): a session that ends on its own never reaches the kill
|
||||||
/// path, so the watcher applies the kill's rule itself. A worktree holding
|
/// path, so the watcher applies the kill's rule itself. A worktree holding
|
||||||
/// unsaved work is RETAINED into the store with reason
|
/// unsaved work is LEFT ON DISK and stays attached to the dead record; the
|
||||||
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
|
/// session stays in the catalog marked dead until dismissed, and dismissing
|
||||||
/// until dismissed — and dismissing it afterwards neither refuses nor
|
/// it goes through `--force`, which leaves the tree alone too.
|
||||||
/// retains a second time.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
init_repo(repo.path());
|
init_repo(repo.path());
|
||||||
@@ -793,56 +824,35 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// The tree would have blocked a kill, so the exit kept it...
|
// The settle step has been applied or refused either way — here refused,
|
||||||
|
// since the tree holds unsaved work. Only from this point is "the tree
|
||||||
|
// survived" a settled fact rather than a race (#115).
|
||||||
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
|
fx.manager.is_settled(s.id).await == Some(true)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The tree would have blocked a kill, so the exit kept it.
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
wt_path.exists(),
|
||||||
"a dirty worktree must be retained on natural exit"
|
"a dirty worktree must be left alone on natural exit"
|
||||||
);
|
);
|
||||||
// ...in the store, under the exit reason.
|
|
||||||
// (`Dead` is observable a moment before the settle step's store write lands.)
|
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
|
||||||
fx.retained_store()
|
|
||||||
.load_all()
|
|
||||||
.map(|all| all.len() == 1)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let all = fx.retained_store().load_all().expect("load_all");
|
|
||||||
assert_eq!(all.len(), 1);
|
|
||||||
assert_eq!(all[0].path, wt_path);
|
|
||||||
assert_eq!(all[0].branch, "feat/exited");
|
|
||||||
assert_eq!(
|
|
||||||
all[0].reason,
|
|
||||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
|
|
||||||
);
|
|
||||||
|
|
||||||
// The session itself stays until dismissed; its worktree reference is
|
|
||||||
// settled, so dismissal is neither refused nor retained twice.
|
|
||||||
// (`Dead` is observable a moment before the settle step finishes.)
|
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
|
||||||
let s = &fx.manager.snapshot().await[0];
|
|
||||||
s.worktree.is_none() && s.blocked_work.is_none()
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let snap = fx.manager.snapshot().await;
|
let snap = fx.manager.snapshot().await;
|
||||||
assert_eq!(snap.len(), 1);
|
assert_eq!(snap.len(), 1);
|
||||||
assert_eq!(snap[0].worktree, None);
|
|
||||||
|
|
||||||
fx.manager.kill(s.id, false).await.expect("dismiss");
|
|
||||||
assert!(!fx.manager.has(s.id).await);
|
|
||||||
assert!(
|
assert!(
|
||||||
wt_path.exists(),
|
snap[0].worktree.is_some(),
|
||||||
"dismissal must not remove what was retained"
|
"the tree stays attached to the dead record"
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fx.retained_store().load_all().unwrap().len(),
|
|
||||||
1,
|
|
||||||
"dismissal must not record a second entry for the same tree"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dismissing it needs --force, and force never removes what would block.
|
||||||
|
assert!(fx.manager.kill(s.id, false).await.is_err());
|
||||||
|
fx.manager.kill(s.id, true).await.expect("forced dismissal");
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
assert!(wt_path.exists(), "dismissal must not remove the work");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
|
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
|
||||||
/// nothing to keep — the worktree is removed and no store entry appears.
|
/// nothing to keep — the worktree is removed.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
@@ -879,92 +889,18 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// (`Dead` is observable a moment before the settle step removes the tree.)
|
// (`Dead` is observable a moment before the settle step removes the tree.)
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
wait_for_async(Duration::from_secs(3), || async { !wt_path.exists() }).await;
|
||||||
!wt_path.exists()
|
|
||||||
&& fx
|
|
||||||
.retained_store()
|
|
||||||
.load_all()
|
|
||||||
.map(|all| all.is_empty())
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!wt_path.exists(),
|
!wt_path.exists(),
|
||||||
"a pristine worktree leaves nothing behind"
|
"a pristine worktree leaves nothing behind"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
fx.retained_store().load_all().unwrap().is_empty(),
|
|
||||||
"nothing was retained"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
fx.manager.has(s.id).await,
|
fx.manager.has(s.id).await,
|
||||||
"the session stays until dismissed"
|
"the session stays until dismissed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When nothing can be recorded — here: no retained store wired — the exit
|
|
||||||
/// destroys nothing. The tree stays on disk AND stays attached to the dead
|
|
||||||
/// record, where dismissing it later still goes through #81's forced-kill
|
|
||||||
/// retention. Losing the store must not lose the work.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
|
||||||
let tmux = TmuxFixture::new();
|
|
||||||
let state_dir = TempDir::new().unwrap();
|
|
||||||
let manager = maestro_daemon::SessionManager::new(
|
|
||||||
Arc::new(tmux.tmux.clone()),
|
|
||||||
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
|
|
||||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
|
||||||
state_dir.path().join("fifos"),
|
|
||||||
);
|
|
||||||
// Deliberately NO with_retained_store.
|
|
||||||
|
|
||||||
let repo = TempDir::new().unwrap();
|
|
||||||
init_repo(repo.path());
|
|
||||||
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
|
|
||||||
|
|
||||||
let s = manager
|
|
||||||
.create(CreateSessionRequest {
|
|
||||||
cwd: repo.path().to_path_buf(),
|
|
||||||
command: "sleep 30".into(),
|
|
||||||
env: HashMap::new(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
worktree: Some(WorktreeSpec {
|
|
||||||
base_repo: repo.path().to_path_buf(),
|
|
||||||
branch: "feat/nostore".into(),
|
|
||||||
create_branch: true,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("create");
|
|
||||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
|
||||||
|
|
||||||
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
|
||||||
manager.start_liveness_watcher(Duration::from_millis(50));
|
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
|
||||||
manager.status(s.id).await == Some(SessionStatus::Dead)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// The settle step has been applied or refused either way — here refused,
|
|
||||||
// since there is no store to record the tree into. Only from this point
|
|
||||||
// is "the tree survived" a settled fact rather than a race (#115).
|
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
|
||||||
manager.is_settled(s.id).await == Some(true)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
wt_path.exists(),
|
|
||||||
"nothing may be destroyed when it cannot be recorded"
|
|
||||||
);
|
|
||||||
let snap = manager.snapshot().await;
|
|
||||||
assert!(
|
|
||||||
snap[0].worktree.is_some(),
|
|
||||||
"the tree must stay attached to the record so a later forced kill can retain it"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
|
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
|
||||||
RetainedWorktree, Session,
|
|
||||||
};
|
};
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
@@ -65,20 +64,12 @@ impl TestClient {
|
|||||||
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
|
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill with `force=true`: overrides a kill refusal and retains the
|
/// Kill with `force=true`: overrides a kill refusal and leaves the
|
||||||
/// guarded worktree (#81). Answers with what was kept.
|
/// guarded worktree on disk (#81).
|
||||||
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
|
pub async fn kill_session_forced(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
Ok(self.daemon.kill_session(id, true).await?)
|
Ok(self.daemon.kill_session(id, true).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
|
||||||
Ok(self.daemon.list_worktrees().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
|
||||||
Ok(self.daemon.reclaim_worktree(id).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||||
Ok(self.daemon.list_projects().await?)
|
Ok(self.daemon.list_projects().await?)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-151
@@ -415,7 +415,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Worktree lifecycle: collisions, retention, reclaim (#88)
|
// Worktree lifecycle: collisions, guards, settling (#88)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
|
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
|
||||||
@@ -621,10 +621,10 @@ async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
|
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
|
||||||
/// session anyway but RETAINS the worktree — force never destroys blocked
|
/// session anyway but LEAVES the worktree on disk — force never destroys
|
||||||
/// work, it parks it in the retained store with reason `force-killed`.
|
/// blocked work.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
let base = TempDir::new().unwrap();
|
||||||
@@ -655,17 +655,7 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force overrides the refusal and nothing else: session gone, tree kept.
|
// Force overrides the refusal and nothing else: session gone, tree kept.
|
||||||
let outcome = client.kill_session_forced(s.id).await.expect("forced kill");
|
client.kill_session_forced(s.id).await.expect("forced kill");
|
||||||
let retained = outcome
|
|
||||||
.retained
|
|
||||||
.expect("force must retain the guarded tree");
|
|
||||||
assert_eq!(
|
|
||||||
retained.reason,
|
|
||||||
maestro_protocol::RetainedReason::ForceKilled
|
|
||||||
);
|
|
||||||
assert_eq!(retained.path, wt);
|
|
||||||
assert_eq!(retained.branch, "feature/local");
|
|
||||||
assert_eq!(retained.in_the_way.unpushed_commits, 1);
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!client
|
!client
|
||||||
@@ -676,18 +666,18 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
|||||||
.any(|x| x.id == s.id),
|
.any(|x| x.id == s.id),
|
||||||
"the forced kill ends the session"
|
"the forced kill ends the session"
|
||||||
);
|
);
|
||||||
assert!(wt.is_dir(), "a force-retained worktree must stay on disk");
|
assert!(wt.is_dir(), "a guarded worktree must stay on disk");
|
||||||
assert!(
|
assert!(
|
||||||
git_worktree_list(base.path())
|
git_worktree_list(base.path())
|
||||||
.iter()
|
.iter()
|
||||||
.any(|l| l.contains("feature/local")),
|
.any(|l| l.contains("feature/local")),
|
||||||
"the retained tree stays registered in git"
|
"the kept tree stays registered in git"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #82/#88: a session that exits on its own never reaches the kill path, but
|
/// #82/#88: a session that exits on its own never reaches the kill path, but
|
||||||
/// its worktree follows the same rule. Dirty → retained with reason
|
/// its worktree follows the same rule. Dirty: left on disk. Pristine:
|
||||||
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind.
|
/// removed, leaving nothing behind.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||||
let (_daemon, client) = bring_up().await;
|
let (_daemon, client) = bring_up().await;
|
||||||
@@ -734,46 +724,18 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
|
|
||||||
// The watcher reaps dead tmux sessions every couple of ticks and settles
|
// The watcher reaps dead tmux sessions every couple of ticks and settles
|
||||||
// each worktree accordingly. Poll for both outcomes.
|
// each worktree accordingly. Poll for both outcomes.
|
||||||
wait_for_async(
|
|
||||||
"dirty exit retains its worktree",
|
|
||||||
Duration::from_secs(20),
|
|
||||||
|| {
|
|
||||||
let client = client.clone();
|
|
||||||
let path = dirty_wt.clone();
|
|
||||||
async move {
|
|
||||||
client
|
|
||||||
.list_worktrees()
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|wts| {
|
|
||||||
wts.iter().any(|w| {
|
|
||||||
w.path == path
|
|
||||||
&& w.reason
|
|
||||||
== maestro_protocol::RetainedReason::ExitedWithUnsavedWork
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
wait_for_async(
|
wait_for_async(
|
||||||
"pristine exit removes its worktree entirely",
|
"pristine exit removes its worktree entirely",
|
||||||
Duration::from_secs(20),
|
Duration::from_secs(20),
|
||||||
|| {
|
|| {
|
||||||
let client = client.clone();
|
|
||||||
let repo = pristine_repo.path().to_path_buf();
|
let repo = pristine_repo.path().to_path_buf();
|
||||||
let path = pristine_wt.clone();
|
let path = pristine_wt.clone();
|
||||||
async move {
|
async move {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let listed = client.list_worktrees().await.ok();
|
|
||||||
let listed_clear = listed
|
|
||||||
.map(|wts| !wts.iter().any(|w| w.path == path))
|
|
||||||
.unwrap_or(false);
|
|
||||||
let registered = git_worktree_list(&repo);
|
let registered = git_worktree_list(&repo);
|
||||||
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear
|
!registered.iter().any(|l| l.contains("feature/tidy"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -797,94 +759,6 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the
|
|
||||||
/// reason and a live in-the-way breakdown; reclaim removes the directory and
|
|
||||||
/// the store entry but NEVER the branch — its commits outlive the reclaim.
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() {
|
|
||||||
let (_daemon, client) = bring_up().await;
|
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
|
||||||
init_repo(base.path());
|
|
||||||
let _origin = throwaway_origin(base.path());
|
|
||||||
|
|
||||||
let wt = base.path().join("worktrees").join("feature").join("parked");
|
|
||||||
let s = client
|
|
||||||
.spawn_session(&worktree_req(base.path(), "feature/parked", true))
|
|
||||||
.await
|
|
||||||
.expect("spawn");
|
|
||||||
std::fs::write(wt.join("README"), b"worth keeping\n").unwrap();
|
|
||||||
commit_all(&wt, "worth keeping");
|
|
||||||
let sha = branch_sha(&wt, "feature/parked");
|
|
||||||
|
|
||||||
let outcome = client.kill_session_forced(s.id).await.expect("force kill");
|
|
||||||
assert!(outcome.retained.is_some());
|
|
||||||
|
|
||||||
let listed = client.list_worktrees().await.expect("list worktrees");
|
|
||||||
assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}");
|
|
||||||
let entry = &listed[0];
|
|
||||||
assert_eq!(entry.path, wt);
|
|
||||||
assert_eq!(entry.branch, "feature/parked");
|
|
||||||
assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled);
|
|
||||||
assert_eq!(entry.in_the_way.unpushed_commits, 1);
|
|
||||||
|
|
||||||
client.reclaim_worktree(entry.id).await.expect("reclaim");
|
|
||||||
assert!(!wt.exists(), "reclaim removes the directory");
|
|
||||||
assert!(
|
|
||||||
client.list_worktrees().await.unwrap().is_empty(),
|
|
||||||
"reclaim drops the store entry"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The whole point of reclaim: the branch and its commits survive.
|
|
||||||
assert_eq!(
|
|
||||||
branch_sha(base.path(), "feature/parked"),
|
|
||||||
sha,
|
|
||||||
"the branch must keep pointing at the same commit after reclaim"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The retained store is state-dir backed (#80): a daemon restart must not
|
|
||||||
/// orphan the trees it is holding — they are still on disk with entries
|
|
||||||
/// pointing at them, and still reclaimable afterwards.
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn retained_store_survives_a_daemon_restart() {
|
|
||||||
let (mut daemon, client) = bring_up().await;
|
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
|
||||||
init_repo(base.path());
|
|
||||||
let _origin = throwaway_origin(base.path());
|
|
||||||
|
|
||||||
let wt = base
|
|
||||||
.path()
|
|
||||||
.join("worktrees")
|
|
||||||
.join("feature")
|
|
||||||
.join("durable");
|
|
||||||
let s = client
|
|
||||||
.spawn_session(&worktree_req(base.path(), "feature/durable", true))
|
|
||||||
.await
|
|
||||||
.expect("spawn");
|
|
||||||
std::fs::write(wt.join("README"), b"survives restarts\n").unwrap();
|
|
||||||
commit_all(&wt, "survives restarts");
|
|
||||||
client.kill_session_forced(s.id).await.expect("force kill");
|
|
||||||
|
|
||||||
daemon.restart().await;
|
|
||||||
|
|
||||||
let listed = client.list_worktrees().await.expect("list after restart");
|
|
||||||
assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}");
|
|
||||||
assert_eq!(listed[0].path, wt);
|
|
||||||
assert_eq!(
|
|
||||||
listed[0].reason,
|
|
||||||
maestro_protocol::RetainedReason::ForceKilled
|
|
||||||
);
|
|
||||||
assert!(wt.is_dir(), "the tree itself must survive the restart too");
|
|
||||||
|
|
||||||
client
|
|
||||||
.reclaim_worktree(listed[0].id)
|
|
||||||
.await
|
|
||||||
.expect("reclaim");
|
|
||||||
assert!(!wt.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/projects + /api/branches (#54/#55/#66)
|
// GET /api/projects + /api/branches (#54/#55/#66)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1280,21 +1154,6 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
|||||||
run_git(&["commit", "-q", "-m", message], dir);
|
run_git(&["commit", "-q", "-m", message], dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn branch_sha(repo: &std::path::Path, branch: &str) -> String {
|
|
||||||
let out = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(repo)
|
|
||||||
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
|
||||||
.output()
|
|
||||||
.expect("git rev-parse");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"branch {branch} missing: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
||||||
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session,
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -115,9 +115,8 @@ impl DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Kill a session. With `force`, the daemon overrides the kill refusal
|
/// Kill a session. With `force`, the daemon overrides the kill refusal
|
||||||
/// (#79) and retains the worktree instead of removing it (#81); the
|
/// (#79) and leaves the worktree on disk instead of removing it (#81).
|
||||||
/// outcome then names what was kept and where.
|
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<(), ClientError> {
|
||||||
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
|
|
||||||
let query: &[(&str, &str)] = if force {
|
let query: &[(&str, &str)] = if force {
|
||||||
&[(routes::FORCE_QUERY, "true")]
|
&[(routes::FORCE_QUERY, "true")]
|
||||||
} else {
|
} else {
|
||||||
@@ -126,12 +125,19 @@ impl DaemonClient {
|
|||||||
let resp = self
|
let resp = self
|
||||||
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
||||||
.await?;
|
.await?;
|
||||||
let resp = check(resp).await?;
|
check(resp).await?;
|
||||||
// An unforced kill keeps its historical `204 No Content`.
|
Ok(())
|
||||||
if resp.status() == reqwest::StatusCode::NO_CONTENT {
|
}
|
||||||
return Ok(KillOutcome { retained: None });
|
|
||||||
}
|
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
|
||||||
json(resp).await
|
/// clears the custom one, so the session falls back to its command. The
|
||||||
|
/// daemon trims and caps the name and answers with the updated session.
|
||||||
|
pub async fn rename_session(&self, id: Uuid, name: &str) -> Result<Session, ClientError> {
|
||||||
|
let req = RenameSessionRequest { name: name.into() };
|
||||||
|
let resp = self
|
||||||
|
.send(Method::PATCH, &routes::session(id), &[], Some(&req))
|
||||||
|
.await?;
|
||||||
|
json(check(resp).await?).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
||||||
@@ -175,23 +181,6 @@ impl DaemonClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
|
|
||||||
/// breakdown as of this read.
|
|
||||||
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
|
||||||
self.get(routes::WORKTREES, &[]).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
|
|
||||||
/// daemon removes the directory and drops the store entry; the branch is
|
|
||||||
/// never deleted.
|
|
||||||
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
|
||||||
let resp = self
|
|
||||||
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
|
|
||||||
.await?;
|
|
||||||
check(resp).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- plumbing -----------------------------------------------------------
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
async fn get<T: DeserializeOwned>(
|
async fn get<T: DeserializeOwned>(
|
||||||
|
|||||||
@@ -222,23 +222,9 @@ pub struct SessionWorktree {
|
|||||||
pub base_repo: PathBuf,
|
pub base_repo: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a retained worktree outlived its session (#80). Same values the
|
/// What a kill refusal reports about a worktree (#80) — the breakdown that
|
||||||
/// daemon's retained store persists; new variants are additive but clients
|
/// blocks an unforced kill, recomputed on every probe so it reflects the tree
|
||||||
/// should render unknown reasons generically.
|
/// now.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum RetainedReason {
|
|
||||||
/// The session was killed with `--force` while its worktree held unsaved
|
|
||||||
/// work.
|
|
||||||
ForceKilled,
|
|
||||||
/// The session ended by itself and its worktree held unsaved work.
|
|
||||||
ExitedWithUnsavedWork,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a kill refusal would report about a worktree right now (#80) — the
|
|
||||||
/// same breakdown that blocks an unforced kill, recomputed on every read of
|
|
||||||
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
|
|
||||||
/// retained.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct InTheWay {
|
pub struct InTheWay {
|
||||||
/// Tracked files with uncommitted modifications.
|
/// Tracked files with uncommitted modifications.
|
||||||
@@ -259,34 +245,6 @@ impl InTheWay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
|
||||||
/// in-the-way state as of this read. Reclaiming it via
|
|
||||||
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RetainedWorktree {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub base_repo: PathBuf,
|
|
||||||
pub branch: String,
|
|
||||||
pub reason: RetainedReason,
|
|
||||||
pub retained_at: DateTime<Utc>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub in_the_way: InTheWay,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81).
|
|
||||||
/// A forced kill ends the session and retains its worktree instead of
|
|
||||||
/// removing it; this names what was kept, where, and why, so a client can say
|
|
||||||
/// where the work went rather than leaving the operator to wonder.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct KillOutcome {
|
|
||||||
/// Set when the worktree was retained with reason `force-killed`; `None`
|
|
||||||
/// when there was nothing to retain — no worktree at all, or one whose
|
|
||||||
/// guard was clear, which a forced kill removes as usual.
|
|
||||||
#[serde(default)]
|
|
||||||
pub retained: Option<RetainedWorktree>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for InTheWay {
|
impl std::fmt::Display for InTheWay {
|
||||||
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
|
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
|
||||||
/// all-zero renders "no unsaved work". This is the phrasing every client
|
/// all-zero renders "no unsaved work". This is the phrasing every client
|
||||||
@@ -495,6 +453,31 @@ pub struct NameBatch {
|
|||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Longest session name the daemon stores (#125), in characters. A name is a
|
||||||
|
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
||||||
|
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
||||||
|
|
||||||
|
/// HTTP `PATCH /api/sessions/:id` body (#125): rename a live session.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RenameSessionRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim a requested session name and cap it at [`MAX_SESSION_NAME_LEN`]
|
||||||
|
/// characters. `None` when nothing is left — that clears `metadata.name` and
|
||||||
|
/// the card falls back to the command, the same rule the spawn form uses when
|
||||||
|
/// its name field is blank.
|
||||||
|
///
|
||||||
|
/// Shared so the daemon and every Rust client agree on what a name becomes;
|
||||||
|
/// `web/src/types.ts` carries the same function for the browser.
|
||||||
|
pub fn normalize_session_name(name: &str) -> Option<String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(trimmed.chars().take(MAX_SESSION_NAME_LEN).collect())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Catalog events (`WS /api/events`)
|
// Catalog events (`WS /api/events`)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -848,76 +831,6 @@ mod tests {
|
|||||||
assert_eq!(spec, round_trip(&spec));
|
assert_eq!(spec, round_trip(&spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_retained_worktree() -> RetainedWorktree {
|
|
||||||
RetainedWorktree {
|
|
||||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
|
||||||
path: PathBuf::from("/work/maestro/worktrees/feat/login"),
|
|
||||||
base_repo: PathBuf::from("/work/maestro"),
|
|
||||||
branch: "feat/login".into(),
|
|
||||||
reason: RetainedReason::ForceKilled,
|
|
||||||
retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z")
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc),
|
|
||||||
in_the_way: InTheWay {
|
|
||||||
uncommitted_files: 2,
|
|
||||||
untracked_files: 1,
|
|
||||||
unpushed_commits: 3,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retained_worktree_round_trips() {
|
|
||||||
let wt = sample_retained_worktree();
|
|
||||||
assert_eq!(wt, round_trip(&wt));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A payload from a daemon predating `in_the_way` still decodes; the
|
|
||||||
/// breakdown defaults to "nothing in the way".
|
|
||||||
#[test]
|
|
||||||
fn retained_worktree_defaults_in_the_way_on_legacy_payload() {
|
|
||||||
let legacy = serde_json::json!({
|
|
||||||
"id": Uuid::from_u128(1),
|
|
||||||
"path": "/w/t",
|
|
||||||
"base_repo": "/w",
|
|
||||||
"branch": "feat/login",
|
|
||||||
"reason": "force-killed",
|
|
||||||
"retained_at": "2026-08-24T10:00:00Z",
|
|
||||||
});
|
|
||||||
let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap();
|
|
||||||
assert_eq!(wt.reason, RetainedReason::ForceKilled);
|
|
||||||
assert_eq!(wt.in_the_way, InTheWay::default());
|
|
||||||
assert!(wt.in_the_way.is_clear());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retained_reason_serializes_kebab_case() {
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(RetainedReason::ForceKilled).unwrap(),
|
|
||||||
serde_json::json!("force-killed")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(),
|
|
||||||
serde_json::json!("exited-with-unsaved-work")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn kill_outcome_round_trips_a_retained_worktree() {
|
|
||||||
let outcome = KillOutcome {
|
|
||||||
retained: Some(sample_retained_worktree()),
|
|
||||||
};
|
|
||||||
assert_eq!(outcome, round_trip(&outcome));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A kill with nothing to retain — no worktree, or a clean one removed as
|
|
||||||
/// usual — decodes from an absent field.
|
|
||||||
#[test]
|
|
||||||
fn kill_outcome_defaults_to_nothing_retained() {
|
|
||||||
let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap();
|
|
||||||
assert_eq!(outcome.retained, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_event_round_trips_and_tags() {
|
fn catalog_event_round_trips_and_tags() {
|
||||||
let session = sample_session();
|
let session = sample_session();
|
||||||
@@ -964,4 +877,42 @@ mod tests {
|
|||||||
assert_eq!(c, round_trip(&c));
|
assert_eq!(c, round_trip(&c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #125: a rename request is trimmed and capped; a blank one clears the
|
||||||
|
/// name so the card falls back to the command.
|
||||||
|
#[test]
|
||||||
|
fn session_names_are_trimmed_capped_and_blankable() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_session_name(" amber-falcon "),
|
||||||
|
Some("amber-falcon".into())
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_session_name(""), None);
|
||||||
|
assert_eq!(normalize_session_name(" \t "), None);
|
||||||
|
let long = "x".repeat(MAX_SESSION_NAME_LEN + 10);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_session_name(&long).unwrap().chars().count(),
|
||||||
|
MAX_SESSION_NAME_LEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cap counts characters, not bytes — a multi-byte name must not be
|
||||||
|
/// sliced mid-codepoint.
|
||||||
|
#[test]
|
||||||
|
fn session_name_cap_counts_characters() {
|
||||||
|
let name = "é".repeat(MAX_SESSION_NAME_LEN + 5);
|
||||||
|
let capped = normalize_session_name(&name).unwrap();
|
||||||
|
assert_eq!(capped.chars().count(), MAX_SESSION_NAME_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_request_round_trips() {
|
||||||
|
let req = RenameSessionRequest {
|
||||||
|
name: "amber-falcon".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(req, round_trip(&req));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&req).unwrap(),
|
||||||
|
serde_json::json!({"name": "amber-falcon"})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ pub const BRANCHES: &str = "/api/branches";
|
|||||||
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
||||||
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
||||||
pub const NAMES: &str = "/api/names";
|
pub const NAMES: &str = "/api/names";
|
||||||
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
|
|
||||||
/// breakdown recomputed on read.
|
|
||||||
pub const WORKTREES: &str = "/api/worktrees";
|
|
||||||
/// `WS` catalog snapshot + session deltas.
|
/// `WS` catalog snapshot + session deltas.
|
||||||
pub const EVENTS: &str = "/api/events";
|
pub const EVENTS: &str = "/api/events";
|
||||||
|
|
||||||
@@ -39,7 +36,7 @@ pub const PATH_QUERY: &str = "path";
|
|||||||
pub const COUNT_QUERY: &str = "count";
|
pub const COUNT_QUERY: &str = "count";
|
||||||
|
|
||||||
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
|
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
|
||||||
/// `force=true` overrides the kill refusal and retains the worktree.
|
/// `force=true` overrides the kill refusal and leaves the worktree on disk.
|
||||||
pub const FORCE_QUERY: &str = "force";
|
pub const FORCE_QUERY: &str = "force";
|
||||||
|
|
||||||
/// `DELETE /api/sessions/:id`.
|
/// `DELETE /api/sessions/:id`.
|
||||||
@@ -52,11 +49,6 @@ pub fn session_activity(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/activity")
|
format!("{SESSIONS}/{id}/activity")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
|
||||||
pub fn worktree(id: Uuid) -> String {
|
|
||||||
format!("{WORKTREES}/{id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||||
pub fn attach(id: Uuid) -> String {
|
pub fn attach(id: Uuid) -> String {
|
||||||
format!("{API_PREFIX}/attach/{id}")
|
format!("{API_PREFIX}/attach/{id}")
|
||||||
@@ -68,7 +60,6 @@ pub mod patterns {
|
|||||||
pub const SESSION: &str = "/api/sessions/:id";
|
pub const SESSION: &str = "/api/sessions/:id";
|
||||||
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||||
pub const ATTACH: &str = "/api/attach/:id";
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
pub const WORKTREE: &str = "/api/worktrees/:id";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
@@ -183,10 +174,6 @@ mod tests {
|
|||||||
attach(id()),
|
attach(id()),
|
||||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
worktree(id()),
|
|
||||||
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||||
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
@@ -209,10 +196,6 @@ mod tests {
|
|||||||
patterns::ATTACH.trim_end_matches(":id"),
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
attach(id()).trim_end_matches(&id().to_string())
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
patterns::WORKTREE.trim_end_matches(":id"),
|
|
||||||
worktree(id()).trim_end_matches(&id().to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -226,7 +209,6 @@ mod tests {
|
|||||||
GITROOT,
|
GITROOT,
|
||||||
BRANCHES,
|
BRANCHES,
|
||||||
NAMES,
|
NAMES,
|
||||||
WORKTREES,
|
|
||||||
EVENTS,
|
EVENTS,
|
||||||
] {
|
] {
|
||||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ pub enum Action {
|
|||||||
FocusSidebar,
|
FocusSidebar,
|
||||||
FocusPane(Direction),
|
FocusPane(Direction),
|
||||||
|
|
||||||
|
// Session scrollback
|
||||||
|
ScrollPanePageUp,
|
||||||
|
ScrollPanePageDown,
|
||||||
|
|
||||||
// Sidebar highlight movement
|
// Sidebar highlight movement
|
||||||
SidebarNext,
|
SidebarNext,
|
||||||
SidebarPrev,
|
SidebarPrev,
|
||||||
@@ -44,15 +48,9 @@ pub enum Action {
|
|||||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||||
FastForwardSpawn,
|
FastForwardSpawn,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
/// Open the retained-worktrees view (#85).
|
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||||
ShowWorktrees,
|
/// its current name.
|
||||||
|
RenameHighlighted,
|
||||||
// Tabs
|
|
||||||
NewTab,
|
|
||||||
CloseTab,
|
|
||||||
NextTab,
|
|
||||||
PrevTab,
|
|
||||||
GotoTab(u8),
|
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
@@ -71,8 +69,39 @@ pub enum ActionParseError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Action {
|
impl Action {
|
||||||
/// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`,
|
/// Plain-language label for the in-app help menu. Configuration keeps the
|
||||||
/// or `"GotoTab(3)"`.
|
/// stable enum spelling; operators should not have to decode it.
|
||||||
|
pub fn help_label(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Action::Noop => "Do nothing".into(),
|
||||||
|
Action::EnterInsert => "Send keys to session".into(),
|
||||||
|
Action::LeaveInsert => "Return to sidebar".into(),
|
||||||
|
Action::FocusSidebar => "Focus sidebar".into(),
|
||||||
|
Action::FocusPane(direction) => {
|
||||||
|
format!(
|
||||||
|
"Focus session pane ({})",
|
||||||
|
format!("{direction:?}").to_lowercase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Action::ScrollPanePageUp => "Scroll session up one page".into(),
|
||||||
|
Action::ScrollPanePageDown => "Scroll session down one page".into(),
|
||||||
|
Action::SidebarNext => "Select next sidebar item".into(),
|
||||||
|
Action::SidebarPrev => "Select previous sidebar item".into(),
|
||||||
|
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
|
||||||
|
Action::AttachHighlighted => "Open selected item".into(),
|
||||||
|
Action::ToggleAttachHighlighted => "Open selected item".into(),
|
||||||
|
Action::NewDraftPane => "Create a session".into(),
|
||||||
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
|
Action::RenameHighlighted => "Rename selected session".into(),
|
||||||
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
|
Action::ShowHelp => "Show help".into(),
|
||||||
|
Action::Quit => "Quit Maestro".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a canonical action string like `"EnterInsert"` or
|
||||||
|
/// `"FocusPane(Left)"`.
|
||||||
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
|
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
match s {
|
match s {
|
||||||
@@ -80,6 +109,8 @@ impl Action {
|
|||||||
"EnterInsert" => return Ok(Action::EnterInsert),
|
"EnterInsert" => return Ok(Action::EnterInsert),
|
||||||
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
||||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||||
|
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
|
||||||
|
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
|
||||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||||
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
||||||
@@ -88,11 +119,7 @@ impl Action {
|
|||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||||
"NewTab" => return Ok(Action::NewTab),
|
|
||||||
"CloseTab" => return Ok(Action::CloseTab),
|
|
||||||
"NextTab" => return Ok(Action::NextTab),
|
|
||||||
"PrevTab" => return Ok(Action::PrevTab),
|
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
"Quit" => return Ok(Action::Quit),
|
"Quit" => return Ok(Action::Quit),
|
||||||
@@ -113,13 +140,6 @@ impl Action {
|
|||||||
})?;
|
})?;
|
||||||
Ok(Action::FocusPane(dir))
|
Ok(Action::FocusPane(dir))
|
||||||
}
|
}
|
||||||
"GotoTab" => {
|
|
||||||
let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
|
|
||||||
action: name.to_string(),
|
|
||||||
detail: format!("expected u8 0..=255, got {arg:?}"),
|
|
||||||
})?;
|
|
||||||
Ok(Action::GotoTab(n))
|
|
||||||
}
|
|
||||||
_ => Err(ActionParseError::Unknown(name.to_string())),
|
_ => Err(ActionParseError::Unknown(name.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +159,6 @@ impl fmt::Display for Action {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
|
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
|
||||||
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
|
|
||||||
other => write!(f, "{other:?}"),
|
other => write!(f, "{other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +175,13 @@ mod tests {
|
|||||||
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_labels_are_plain_language() {
|
||||||
|
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
|
||||||
|
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
|
||||||
|
assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_focus_pane_directions() {
|
fn parses_focus_pane_directions() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -168,12 +194,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_goto_tab() {
|
|
||||||
assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
|
|
||||||
assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_action() {
|
fn rejects_unknown_action() {
|
||||||
let err = Action::parse("Frobnicate").unwrap_err();
|
let err = Action::parse("Frobnicate").unwrap_err();
|
||||||
@@ -188,22 +208,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_non_numeric_tab_arg() {
|
|
||||||
let err = Action::parse("GotoTab(abc)").unwrap_err();
|
|
||||||
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_overflow_tab_arg() {
|
|
||||||
let err = Action::parse("GotoTab(9999)").unwrap_err();
|
|
||||||
assert!(matches!(err, ActionParseError::BadArg { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unclosed_paren() {
|
fn rejects_unclosed_paren() {
|
||||||
let err = Action::parse("GotoTab(3").unwrap_err();
|
let err = Action::parse("FocusPane(Left").unwrap_err();
|
||||||
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
|
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fast-forward pair sits next to the draft-pane key (#67).
|
/// The fast-forward pair sits next to the draft-pane key (#67).
|
||||||
@@ -216,14 +224,28 @@ mod tests {
|
|||||||
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_action_round_trips() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("RenameHighlighted").unwrap(),
|
||||||
|
Action::RenameHighlighted
|
||||||
|
);
|
||||||
|
assert_eq!(Action::RenameHighlighted.to_string(), "RenameHighlighted");
|
||||||
|
assert_eq!(
|
||||||
|
Action::RenameHighlighted.help_label(),
|
||||||
|
"Rename selected session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
|
Action::RenameHighlighted,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::NewDraftPane,
|
Action::NewDraftPane,
|
||||||
|
Action::ScrollPanePageDown,
|
||||||
Action::FocusPane(Direction::Right),
|
Action::FocusPane(Direction::Right),
|
||||||
Action::GotoTab(7),
|
|
||||||
] {
|
] {
|
||||||
let s = a.to_string();
|
let s = a.to_string();
|
||||||
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
||||||
|
|||||||
+358
-287
@@ -18,9 +18,7 @@ use std::time::Duration;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||||
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree,
|
|
||||||
};
|
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -40,8 +38,7 @@ use crate::render::{draw, RegionMap, RenderInputs};
|
|||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||||
use crate::tabs::TabBar;
|
use crate::text_input::TextInput;
|
||||||
use crate::worktrees::WorktreesView;
|
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -61,8 +58,7 @@ pub struct App {
|
|||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon: DaemonClient,
|
daemon: DaemonClient,
|
||||||
/// Results of draft data loads (and the worktrees view fetch, #85),
|
/// Results of draft data loads, delivered back to the event loop.
|
||||||
/// delivered back to the event loop.
|
|
||||||
///
|
///
|
||||||
/// These are network calls — `/api/projects` on a cold cache walks all of
|
/// These are network calls — `/api/projects` on a cold cache walks all of
|
||||||
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
||||||
@@ -73,9 +69,9 @@ pub struct App {
|
|||||||
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
||||||
/// `GET /api/names` (#64).
|
/// `GET /api/names` (#64).
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||||
/// rather than in `AppState` so the overlay kind stays payload-free.
|
/// same reason the worktree entries are.
|
||||||
worktrees: WorktreesView,
|
rename_input: TextInput,
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -119,11 +115,12 @@ pub enum Effect {
|
|||||||
},
|
},
|
||||||
/// Fetch the draft target's branches from `/api/branches` (#65).
|
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||||
LoadBranches(usize),
|
LoadBranches(usize),
|
||||||
/// Fetch `/api/worktrees` for the retained-worktrees view (#85).
|
/// Rename a session (#125). An empty name clears the custom one, so the
|
||||||
LoadWorktrees,
|
/// card falls back to the command.
|
||||||
/// Reclaim one retained worktree (#85) — removes its directory; the
|
Rename {
|
||||||
/// branch is never deleted.
|
id: Uuid,
|
||||||
ReclaimWorktree(Uuid),
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -148,7 +145,7 @@ impl App {
|
|||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
http,
|
http,
|
||||||
worktrees: WorktreesView::default(),
|
rename_input: TextInput::default(),
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
@@ -208,6 +205,11 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||||
|
self.perform(effect.into_iter().collect()).await;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||||
match self.chord.feed(&key, &known) {
|
match self.chord.feed(&key, &known) {
|
||||||
ChordResult::Pending => Ok(false),
|
ChordResult::Pending => Ok(false),
|
||||||
@@ -278,8 +280,7 @@ impl App {
|
|||||||
self.spawn_probe_git_root(tab, seq, path)
|
self.spawn_probe_git_root(tab, seq, path)
|
||||||
}
|
}
|
||||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
||||||
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||||
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
|
||||||
Effect::Forward { id, bytes } => {
|
Effect::Forward { id, bytes } => {
|
||||||
if let Some(handle) = self.emulators.get(id) {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
@@ -302,43 +303,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => (OverlayDecision::Consumed, Vec::new()),
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
||||||
},
|
},
|
||||||
Some(OverlayKind::Worktrees) => match key {
|
Some(OverlayKind::Rename(id)) => match key {
|
||||||
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
InputKey::Enter => {
|
||||||
self.worktrees.move_highlight(1);
|
let name = self.rename_input.value().to_string();
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, vec![Effect::Rename { id, name }])
|
||||||
}
|
}
|
||||||
InputKey::Char { c: 'k', .. } | InputKey::Up => {
|
InputKey::Esc => {
|
||||||
self.worktrees.move_highlight(-1);
|
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
|
||||||
}
|
|
||||||
InputKey::Char { c: 'r', .. } => {
|
|
||||||
if let Some(wt) = self.worktrees.selected() {
|
|
||||||
self.state.show_overlay(OverlayKind::ReclaimConfirm {
|
|
||||||
id: wt.id,
|
|
||||||
branch: wt.branch.clone(),
|
|
||||||
// Shared phrasing: InTheWay's Display impl is what
|
|
||||||
// the kill refusal and the web client use too.
|
|
||||||
blockers: wt.in_the_way.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
|
||||||
}
|
|
||||||
InputKey::Char { c: 'q', .. } | InputKey::Esc => {
|
|
||||||
self.state.dismiss_overlay();
|
self.state.dismiss_overlay();
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
}
|
}
|
||||||
_ => (OverlayDecision::Consumed, Vec::new()),
|
other => {
|
||||||
},
|
self.rename_input.handle_key(other);
|
||||||
Some(OverlayKind::ReclaimConfirm { id, .. }) => match key {
|
|
||||||
InputKey::Char { c: 'y', .. } => {
|
|
||||||
self.state.dismiss_overlay();
|
|
||||||
(OverlayDecision::Consumed, vec![Effect::ReclaimWorktree(id)])
|
|
||||||
}
|
|
||||||
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
|
|
||||||
self.state.show_overlay(OverlayKind::Worktrees);
|
|
||||||
(OverlayDecision::Consumed, Vec::new())
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
}
|
}
|
||||||
_ => (OverlayDecision::Consumed, Vec::new()),
|
|
||||||
},
|
},
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
self.state.dismiss_overlay();
|
self.state.dismiss_overlay();
|
||||||
@@ -361,13 +339,20 @@ impl App {
|
|||||||
match act {
|
match act {
|
||||||
Action::Noop | Action::Quit => {}
|
Action::Noop | Action::Quit => {}
|
||||||
Action::EnterInsert => self.state.mode = Mode::Insert,
|
Action::EnterInsert => self.state.mode = Mode::Insert,
|
||||||
Action::LeaveInsert => self.state.mode = Mode::Normal,
|
Action::LeaveInsert => {
|
||||||
|
self.state.mode = Mode::Normal;
|
||||||
|
self.state.focus = Focus::Sidebar;
|
||||||
|
}
|
||||||
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
|
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
|
||||||
Action::FocusPane(_) => {
|
Action::FocusPane(_) => {
|
||||||
// v1 has one pane per tab; geometric focus motion lands
|
// v1 has one pane per tab; geometric focus motion lands
|
||||||
// either in the sidebar or back in the pane.
|
// either in the sidebar or back in the pane.
|
||||||
self.state.focus = Focus::Pane;
|
self.state.focus = Focus::Pane;
|
||||||
}
|
}
|
||||||
|
Action::ScrollPanePageUp => self.scroll_active_pane(self.pane_page_rows() as isize),
|
||||||
|
Action::ScrollPanePageDown => {
|
||||||
|
self.scroll_active_pane(-(self.pane_page_rows() as isize))
|
||||||
|
}
|
||||||
Action::SidebarNext => {
|
Action::SidebarNext => {
|
||||||
let order = visible_sidebar_order(
|
let order = visible_sidebar_order(
|
||||||
&self.catalog.snapshot(),
|
&self.catalog.snapshot(),
|
||||||
@@ -385,15 +370,6 @@ impl App {
|
|||||||
self.state.focus = Focus::Sidebar;
|
self.state.focus = Focus::Sidebar;
|
||||||
}
|
}
|
||||||
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
||||||
Action::NewTab => self.state.new_tab(),
|
|
||||||
Action::CloseTab => {
|
|
||||||
if let Some(sid) = self.state.close_tab() {
|
|
||||||
self.release_session(sid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Action::NextTab => self.state.next_tab(),
|
|
||||||
Action::PrevTab => self.state.prev_tab(),
|
|
||||||
Action::GotoTab(n) => self.state.goto_tab(n),
|
|
||||||
Action::ToggleSidebar => self.state.toggle_sidebar(),
|
Action::ToggleSidebar => self.state.toggle_sidebar(),
|
||||||
Action::ShowHelp => {
|
Action::ShowHelp => {
|
||||||
self.help = HelpOverlay {
|
self.help = HelpOverlay {
|
||||||
@@ -402,13 +378,24 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::Help);
|
self.state.show_overlay(OverlayKind::Help);
|
||||||
}
|
}
|
||||||
Action::NewDraftPane => {
|
Action::NewDraftPane => {
|
||||||
|
if let Some(sid) = self.state.active_session() {
|
||||||
|
self.release_session(sid);
|
||||||
|
}
|
||||||
self.state.open_draft_pane();
|
self.state.open_draft_pane();
|
||||||
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
||||||
}
|
}
|
||||||
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
||||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(SidebarItem::Group(key)) = self.state.sidebar_highlight.clone() {
|
||||||
effects.push(Effect::Attach(sid));
|
self.toggle_sidebar_group(key);
|
||||||
|
} else if let Some(sid) = self.pick_sidebar_target() {
|
||||||
|
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
|
||||||
|
if self.state.active_session() != Some(sid) {
|
||||||
|
if let Some(previous) = self.state.active_session() {
|
||||||
|
self.release_session(previous);
|
||||||
|
}
|
||||||
|
effects.push(Effect::Attach(sid));
|
||||||
|
}
|
||||||
self.state.attach_to_focused(sid);
|
self.state.attach_to_focused(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,9 +404,22 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ShowWorktrees => {
|
Action::RenameHighlighted => {
|
||||||
self.state.show_overlay(OverlayKind::Worktrees);
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
effects.push(Effect::LoadWorktrees);
|
// Pre-fill with the custom name only: a session that has
|
||||||
|
// none starts empty rather than with its command already
|
||||||
|
// typed in, which Enter would then pin as the name.
|
||||||
|
self.rename_input = TextInput::with_value(
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == sid)
|
||||||
|
.and_then(crate::sidebar::custom_name_for)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
effects
|
effects
|
||||||
@@ -497,11 +497,79 @@ impl App {
|
|||||||
/// attached to send it to.
|
/// attached to send it to.
|
||||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
let sid = self.state.active_session()?;
|
let sid = self.state.active_session()?;
|
||||||
self.emulators.get(sid)?;
|
let handle = self.emulators.get(sid)?;
|
||||||
let bytes = encode_key(key)?;
|
let bytes = encode_key(key)?;
|
||||||
|
handle
|
||||||
|
.screen
|
||||||
|
.lock()
|
||||||
|
.expect("vt100 parser mutex")
|
||||||
|
.set_scrollback(0);
|
||||||
Some(Effect::Forward { id: sid, bytes })
|
Some(Effect::Forward { id: sid, bytes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pane_page_rows(&self) -> usize {
|
||||||
|
self.region
|
||||||
|
.pane
|
||||||
|
.map_or(1, |area| usize::from(area.height.saturating_sub(1).max(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Positive rows move into older history; negative rows return toward the
|
||||||
|
/// live bottom. vt100 clamps both ends to the retained buffer.
|
||||||
|
fn scroll_active_pane(&mut self, rows: isize) {
|
||||||
|
let Some(sid) = self.state.active_session() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(handle) = self.emulators.get(sid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||||
|
let current = parser.screen().scrollback();
|
||||||
|
let next = if rows >= 0 {
|
||||||
|
current.saturating_add(rows as usize)
|
||||||
|
} else {
|
||||||
|
current.saturating_sub(rows.unsigned_abs())
|
||||||
|
};
|
||||||
|
parser.set_scrollback(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wheel input belongs to the terminal surface in every mode. Normal
|
||||||
|
/// shells move through Maestro's retained history; full-screen programs
|
||||||
|
/// that enabled terminal mouse reporting receive a real wheel event and
|
||||||
|
/// scroll their own alternate-screen content.
|
||||||
|
fn handle_pane_scroll(&mut self, key: &InputKey) -> Option<Option<Effect>> {
|
||||||
|
let InputKey::Mouse {
|
||||||
|
kind,
|
||||||
|
region: Region::Pane,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
} = key
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let lines = match kind {
|
||||||
|
MouseKind::ScrollUp => 3,
|
||||||
|
MouseKind::ScrollDown => -3,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let sid = self.state.active_session()?;
|
||||||
|
let handle = self.emulators.get(sid)?;
|
||||||
|
let pane = self.region.pane?;
|
||||||
|
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||||
|
if parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None {
|
||||||
|
let bytes = encode_mouse_wheel(
|
||||||
|
*kind,
|
||||||
|
col.saturating_sub(pane.x).saturating_add(1),
|
||||||
|
row.saturating_sub(pane.y).saturating_add(1),
|
||||||
|
parser.screen().mouse_protocol_encoding(),
|
||||||
|
);
|
||||||
|
drop(parser);
|
||||||
|
return Some(Some(Effect::Forward { id: sid, bytes }));
|
||||||
|
}
|
||||||
|
drop(parser);
|
||||||
|
self.scroll_active_pane(lines);
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||||
/// effect when the user accepted a valid form; an invalid one names the
|
/// effect when the user accepted a valid form; an invalid one names the
|
||||||
/// missing field inside the form (#64) instead of posting.
|
/// missing field inside the form (#64) instead of posting.
|
||||||
@@ -666,22 +734,13 @@ impl App {
|
|||||||
self.fast_forward_spawn(info, projects, names).await;
|
self.fast_forward_spawn(info, projects, names).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let DraftUpdate::WorktreesLoaded {
|
|
||||||
worktrees,
|
|
||||||
home_dir,
|
|
||||||
} = update
|
|
||||||
{
|
|
||||||
self.worktrees.apply(worktrees);
|
|
||||||
self.worktrees.home_dir = home_dir;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let want = match &update {
|
let want = match &update {
|
||||||
DraftUpdate::Data { draft, .. }
|
DraftUpdate::Data { draft, .. }
|
||||||
| DraftUpdate::Names { draft, .. }
|
| DraftUpdate::Names { draft, .. }
|
||||||
| DraftUpdate::GitRoot { draft, .. }
|
| DraftUpdate::GitRoot { draft, .. }
|
||||||
| DraftUpdate::Branches { draft, .. } => *draft,
|
| DraftUpdate::Branches { draft, .. } => *draft,
|
||||||
// Handled above; it targets no particular draft.
|
// Handled above; it targets no particular draft.
|
||||||
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||||
};
|
};
|
||||||
// Find it by identity: closing a tab shifts every index to its right,
|
// Find it by identity: closing a tab shifts every index to its right,
|
||||||
// so an in-flight load addressed by index would be dropped or land on
|
// so an in-flight load addressed by index would be dropped or land on
|
||||||
@@ -736,7 +795,7 @@ impl App {
|
|||||||
DraftUpdate::Branches { cwd, branches, .. } => {
|
DraftUpdate::Branches { cwd, branches, .. } => {
|
||||||
draft.set_branches(&cwd, branches);
|
draft.set_branches(&cwd, branches);
|
||||||
}
|
}
|
||||||
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
DraftUpdate::FastForward { .. } => unreachable!(),
|
||||||
}
|
}
|
||||||
if follow_up_branches {
|
if follow_up_branches {
|
||||||
self.spawn_load_branches(tab_idx);
|
self.spawn_load_branches(tab_idx);
|
||||||
@@ -787,11 +846,6 @@ impl App {
|
|||||||
let resolved =
|
let resolved =
|
||||||
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
||||||
if let Some(req) = resolved.build_request() {
|
if let Some(req) = resolved.build_request() {
|
||||||
// Never eat a pane already showing something: the spawn takes a
|
|
||||||
// fresh tab, same as opening a draft over a session would.
|
|
||||||
if self.state.active_tab().pane.is_some() {
|
|
||||||
self.state.new_tab();
|
|
||||||
}
|
|
||||||
self.submit_spawn(req).await;
|
self.submit_spawn(req).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -817,6 +871,9 @@ impl App {
|
|||||||
// Fill the pane in place with the new session (web parity,
|
// Fill the pane in place with the new session (web parity,
|
||||||
// #57) and attach to it right away.
|
// #57) and attach to it right away.
|
||||||
let sid = session.id;
|
let sid = session.id;
|
||||||
|
if let Some(previous) = self.state.active_session() {
|
||||||
|
self.release_session(previous);
|
||||||
|
}
|
||||||
self.state.complete_spawn(sid);
|
self.state.complete_spawn(sid);
|
||||||
self.attach(sid);
|
self.attach(sid);
|
||||||
}
|
}
|
||||||
@@ -838,46 +895,16 @@ impl App {
|
|||||||
|
|
||||||
async fn submit_kill(&mut self, sid: Uuid) {
|
async fn submit_kill(&mut self, sid: Uuid) {
|
||||||
match self.daemon.kill_session(sid, false).await {
|
match self.daemon.kill_session(sid, false).await {
|
||||||
Ok(outcome) => {
|
Ok(()) => self.release_session(sid),
|
||||||
if let Some(w) = outcome.retained {
|
|
||||||
self.state
|
|
||||||
.toast(format!("worktree retained: {}", w.path.display()));
|
|
||||||
}
|
|
||||||
self.release_session(sid)
|
|
||||||
}
|
|
||||||
Err(e) => self.state.toast(format!("kill failed: {e}")),
|
Err(e) => self.state.toast(format!("kill failed: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the retained-worktree store without blocking the loop (#85) —
|
/// Rename a session (#125). The catalog updates from the daemon's
|
||||||
/// same off-loop pattern as the draft loaders.
|
/// `SessionUpdated` broadcast, so nothing is patched locally here.
|
||||||
fn spawn_load_worktrees(&self) {
|
async fn submit_rename(&mut self, sid: Uuid, name: &str) {
|
||||||
let (daemon, _, _, tx) = self.loader_handles();
|
if let Err(e) = self.daemon.rename_session(sid, name).await {
|
||||||
tokio::spawn(async move {
|
self.state.toast(format!("rename failed: {e}"));
|
||||||
let (worktrees, info) = tokio::join!(daemon.list_worktrees(), daemon.daemon_info());
|
|
||||||
let _ = tx.send(DraftUpdate::WorktreesLoaded {
|
|
||||||
worktrees: worktrees.unwrap_or_default(),
|
|
||||||
home_dir: info.map(|i| i.home_dir).unwrap_or_default(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reclaim one retained worktree (#85): the directory goes, the branch
|
|
||||||
/// never does. The open view drops the entry so the list tells the truth
|
|
||||||
/// without waiting for a re-fetch.
|
|
||||||
async fn submit_reclaim(&mut self, id: Uuid) {
|
|
||||||
match self.daemon.reclaim_worktree(id).await {
|
|
||||||
Ok(()) => {
|
|
||||||
self.worktrees.remove(id);
|
|
||||||
self.state.toast("worktree reclaimed; branch kept");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let message = e
|
|
||||||
.daemon_message()
|
|
||||||
.map(String::from)
|
|
||||||
.unwrap_or_else(|| e.to_string());
|
|
||||||
self.state.toast(format!("reclaim failed: {message}"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,10 +924,6 @@ impl App {
|
|||||||
highlight: self.state.sidebar_highlight.clone(),
|
highlight: self.state.sidebar_highlight.clone(),
|
||||||
attached,
|
attached,
|
||||||
};
|
};
|
||||||
let tab_bar = TabBar {
|
|
||||||
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
|
||||||
active_idx: self.state.active_tab_idx,
|
|
||||||
};
|
|
||||||
let active_sid = self.state.active_session();
|
let active_sid = self.state.active_session();
|
||||||
let pane = active_sid.and_then(|sid| {
|
let pane = active_sid.and_then(|sid| {
|
||||||
self.emulators.get(sid).map(|h| Pane {
|
self.emulators.get(sid).map(|h| Pane {
|
||||||
@@ -913,16 +936,26 @@ impl App {
|
|||||||
Some(PaneRef::Draft(d)) => Some(d),
|
Some(PaneRef::Draft(d)) => Some(d),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let pane_title = match self.state.active_tab().pane.as_ref() {
|
||||||
|
Some(PaneRef::Session(sid)) => snap
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|session| session.id == *sid)
|
||||||
|
.map(crate::sidebar::name_for)
|
||||||
|
.unwrap_or_else(|| "session".into()),
|
||||||
|
Some(PaneRef::Draft(_)) => "new session".into(),
|
||||||
|
None => "session".into(),
|
||||||
|
};
|
||||||
|
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
home_dir: &self.home_dir,
|
home_dir: &self.home_dir,
|
||||||
tab_bar: &tab_bar,
|
pane_title: &pane_title,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
draft,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
worktrees: Some(&self.worktrees),
|
rename_input: &self.rename_input,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_region = RegionMap::default();
|
let mut new_region = RegionMap::default();
|
||||||
@@ -1008,6 +1041,8 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
|||||||
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||||
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||||
InputKey::End => Some(b"\x1b[F".to_vec()),
|
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||||
|
InputKey::PageUp => Some(b"\x1b[5~".to_vec()),
|
||||||
|
InputKey::PageDown => Some(b"\x1b[6~".to_vec()),
|
||||||
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||||
InputKey::Paste(text) => {
|
InputKey::Paste(text) => {
|
||||||
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
||||||
@@ -1023,6 +1058,39 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_mouse_wheel(
|
||||||
|
kind: MouseKind,
|
||||||
|
col: u16,
|
||||||
|
row: u16,
|
||||||
|
encoding: vt100::MouseProtocolEncoding,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let button = match kind {
|
||||||
|
MouseKind::ScrollUp => 64,
|
||||||
|
MouseKind::ScrollDown => 65,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
match encoding {
|
||||||
|
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(),
|
||||||
|
vt100::MouseProtocolEncoding::Default => vec![
|
||||||
|
0x1b,
|
||||||
|
b'[',
|
||||||
|
b'M',
|
||||||
|
(button + 32) as u8,
|
||||||
|
col.min(223).saturating_add(32) as u8,
|
||||||
|
row.min(223).saturating_add(32) as u8,
|
||||||
|
],
|
||||||
|
vt100::MouseProtocolEncoding::Utf8 => {
|
||||||
|
let mut out = b"\x1b[M".to_vec();
|
||||||
|
for value in [button + 32, u32::from(col) + 32, u32::from(row) + 32] {
|
||||||
|
let ch = char::from_u32(value).expect("mouse coordinate is a valid codepoint");
|
||||||
|
let mut bytes = [0; 4];
|
||||||
|
out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the lines shown by the help overlay from the live keymap, so the
|
/// Build the lines shown by the help overlay from the live keymap, so the
|
||||||
/// user always sees the bindings that are actually active (defaults + their
|
/// user always sees the bindings that are actually active (defaults + their
|
||||||
/// own `[tui.keymap.*]` overrides).
|
/// own `[tui.keymap.*]` overrides).
|
||||||
@@ -1030,14 +1098,14 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
|||||||
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
|
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
|
||||||
let mut out = vec![format!("{label} mode"), String::new()];
|
let mut out = vec![format!("{label} mode"), String::new()];
|
||||||
for (input, action) in cfg.keymap.entries(mode) {
|
for (input, action) in cfg.keymap.entries(mode) {
|
||||||
out.push(format!(" {input:<24} {action}"));
|
out.push(format!(" {input:<24} {}", action.help_label()));
|
||||||
}
|
}
|
||||||
out.push(String::new());
|
out.push(String::new());
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
let leader = cfg.leader.canonical();
|
let leader = cfg.leader.canonical();
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
format!("Leader: {leader} (Insert → Normal)"),
|
format!("Leader: {leader} (return to sidebar)"),
|
||||||
String::new(),
|
String::new(),
|
||||||
];
|
];
|
||||||
lines.extend(section(Mode::Normal, "Normal", cfg));
|
lines.extend(section(Mode::Normal, "Normal", cfg));
|
||||||
@@ -1075,11 +1143,6 @@ enum DraftUpdate {
|
|||||||
projects: Option<Vec<maestro_protocol::Project>>,
|
projects: Option<Vec<maestro_protocol::Project>>,
|
||||||
names: Vec<String>,
|
names: Vec<String>,
|
||||||
},
|
},
|
||||||
/// The retained-worktree store arrived for the worktrees view (#85).
|
|
||||||
WorktreesLoaded {
|
|
||||||
worktrees: Vec<RetainedWorktree>,
|
|
||||||
home_dir: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
||||||
@@ -1149,19 +1212,11 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
assert_eq!(app.state.mode, Mode::Normal);
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
app.dispatch(Action::EnterInsert);
|
app.dispatch(Action::EnterInsert);
|
||||||
|
app.state.focus = Focus::Pane;
|
||||||
assert_eq!(app.state.mode, Mode::Insert);
|
assert_eq!(app.state.mode, Mode::Insert);
|
||||||
app.dispatch(Action::LeaveInsert);
|
app.dispatch(Action::LeaveInsert);
|
||||||
assert_eq!(app.state.mode, Mode::Normal);
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
}
|
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_tab_action_extends_tabs() {
|
|
||||||
let mut app = make_app();
|
|
||||||
app.dispatch(Action::NewTab);
|
|
||||||
app.dispatch(Action::NewTab);
|
|
||||||
assert_eq!(app.state.tabs.len(), 3);
|
|
||||||
app.dispatch(Action::CloseTab);
|
|
||||||
assert_eq!(app.state.tabs.len(), 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1171,6 +1226,18 @@ mod tests {
|
|||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_uses_plain_language_instead_of_action_names() {
|
||||||
|
let app = make_app();
|
||||||
|
let help = render_help_lines(&app.cfg).join("\n");
|
||||||
|
assert!(help.contains("Send keys to session"));
|
||||||
|
assert!(help.contains("Create a session"));
|
||||||
|
assert!(help.contains("Expand or collapse directory"));
|
||||||
|
assert!(!help.contains("EnterInsert"));
|
||||||
|
assert!(!help.contains("NewDraftPane"));
|
||||||
|
assert!(!help.contains("ToggleSidebarGroup"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
@@ -1184,21 +1251,19 @@ mod tests {
|
|||||||
assert!(app.state.overlay().is_none(), "no overlay involved");
|
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tab already showing a session gets a new tab for the draft.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
fn new_draft_pane_over_a_session_reuses_the_pane() {
|
||||||
let sid = Uuid::from_u128(10);
|
let sid = Uuid::from_u128(10);
|
||||||
let mut app = app_with_highlighted_session(sid);
|
let mut app = app_with_highlighted_session(sid);
|
||||||
app.dispatch(Action::AttachHighlighted);
|
app.dispatch(Action::AttachHighlighted);
|
||||||
|
|
||||||
app.dispatch(Action::NewDraftPane);
|
app.dispatch(Action::NewDraftPane);
|
||||||
assert_eq!(app.state.tabs.len(), 2);
|
assert_eq!(app.state.tabs.len(), 1);
|
||||||
assert_eq!(app.state.active_tab_idx, 1);
|
assert_eq!(app.state.active_tab_idx, 0);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
app.state.active_tab().pane,
|
app.state.active_tab().pane,
|
||||||
Some(PaneRef::Draft(_))
|
Some(PaneRef::Draft(_))
|
||||||
));
|
));
|
||||||
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seed the catalog with one session and highlight it.
|
/// Seed the catalog with one session and highlight it.
|
||||||
@@ -1261,10 +1326,11 @@ mod tests {
|
|||||||
vec![Effect::Attach(sid)]
|
vec![Effect::Attach(sid)]
|
||||||
);
|
);
|
||||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||||
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn group_highlight_is_not_a_session_action_target() {
|
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
|
||||||
use maestro_protocol::CatalogEvent;
|
use maestro_protocol::CatalogEvent;
|
||||||
|
|
||||||
let sid = Uuid::from_u128(14);
|
let sid = Uuid::from_u128(14);
|
||||||
@@ -1278,6 +1344,15 @@ mod tests {
|
|||||||
Some(SidebarItem::Group(PathBuf::from("/repo")))
|
Some(SidebarItem::Group(PathBuf::from("/repo")))
|
||||||
);
|
);
|
||||||
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/repo")));
|
||||||
|
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||||
|
assert!(!app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/repo")));
|
||||||
assert!(app.dispatch(Action::KillHighlighted).is_empty());
|
assert!(app.dispatch(Action::KillHighlighted).is_empty());
|
||||||
assert!(app.state.overlay().is_none());
|
assert!(app.state.overlay().is_none());
|
||||||
}
|
}
|
||||||
@@ -1309,7 +1384,7 @@ mod tests {
|
|||||||
kind: MouseKind::Click,
|
kind: MouseKind::Click,
|
||||||
region: Region::SidebarRow,
|
region: Region::SidebarRow,
|
||||||
col: 3,
|
col: 3,
|
||||||
row: 2,
|
row: 1,
|
||||||
};
|
};
|
||||||
assert!(app.handle_sidebar_mouse(&click));
|
assert!(app.handle_sidebar_mouse(&click));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1337,7 +1412,7 @@ mod tests {
|
|||||||
.handle_event(CtEvent::Mouse(MouseEvent {
|
.handle_event(CtEvent::Mouse(MouseEvent {
|
||||||
kind: MouseEventKind::Down(MouseButton::Left),
|
kind: MouseEventKind::Down(MouseButton::Left),
|
||||||
column: 3,
|
column: 3,
|
||||||
row: 2,
|
row: 1,
|
||||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||||
}))
|
}))
|
||||||
.await
|
.await
|
||||||
@@ -1805,6 +1880,49 @@ mod tests {
|
|||||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
|
||||||
|
use crate::session_task::{SessionHandle, SessionState};
|
||||||
|
use std::sync::atomic::AtomicU8;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(91);
|
||||||
|
let mut parser = vt100::Parser::new(2, 20, 20);
|
||||||
|
parser.process(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
|
||||||
|
let screen = Arc::new(Mutex::new(parser));
|
||||||
|
let handle = SessionHandle {
|
||||||
|
tx: mpsc::channel(1).0,
|
||||||
|
screen: Arc::clone(&screen),
|
||||||
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||||
|
};
|
||||||
|
let mut app = make_app();
|
||||||
|
app.state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
app.emulators.insert(sid, handle);
|
||||||
|
|
||||||
|
app.scroll_active_pane(3);
|
||||||
|
assert!(screen.lock().unwrap().screen().scrollback() > 0);
|
||||||
|
assert!(app.forward_to_session(&InputKey::Enter).is_some());
|
||||||
|
assert_eq!(screen.lock().unwrap().screen().scrollback(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_wheel_encoding_supports_sgr_terminals() {
|
||||||
|
assert_eq!(
|
||||||
|
encode_mouse_wheel(MouseKind::ScrollUp, 7, 4, vt100::MouseProtocolEncoding::Sgr,),
|
||||||
|
b"\x1b[<64;7;4M"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_mouse_wheel(
|
||||||
|
MouseKind::ScrollDown,
|
||||||
|
7,
|
||||||
|
4,
|
||||||
|
vt100::MouseProtocolEncoding::Sgr,
|
||||||
|
),
|
||||||
|
b"\x1b[<65;7;4M"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||||
/// paste into an attached session vanished once bracketed paste was
|
/// paste into an attached session vanished once bracketed paste was
|
||||||
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
||||||
@@ -1860,161 +1978,114 @@ mod tests {
|
|||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- retained worktrees (#85) -------------------------------------------
|
// -- rename (#125) ------------------------------------------------------
|
||||||
|
|
||||||
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
fn seed_session(app: &mut App, sid: Uuid, name: Option<&str>) {
|
||||||
use chrono::TimeZone;
|
use chrono::Utc;
|
||||||
maestro_protocol::RetainedWorktree {
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
id: Uuid::from_u128(id),
|
let metadata = match name {
|
||||||
path: "/home/naps/tea/maestro/worktrees/feat/login".into(),
|
Some(n) => {
|
||||||
base_repo: "/home/naps/tea/maestro".into(),
|
HashMap::from([("name".to_string(), serde_json::Value::String(n.to_string()))])
|
||||||
branch: "feat/login".into(),
|
}
|
||||||
reason: maestro_protocol::RetainedReason::ForceKilled,
|
None => Default::default(),
|
||||||
retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
};
|
||||||
in_the_way: maestro_protocol::InTheWay {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
uncommitted_files: 2,
|
sessions: vec![Session {
|
||||||
untracked_files: 1,
|
id: sid,
|
||||||
unpushed_commits: 3,
|
command: "bash".into(),
|
||||||
},
|
cwd: std::path::PathBuf::from("/"),
|
||||||
}
|
env: Default::default(),
|
||||||
}
|
metadata,
|
||||||
|
worktree: None,
|
||||||
fn app_with_one_retained_worktree() -> App {
|
status: SessionStatus::Running,
|
||||||
let mut app = make_app();
|
created_at: Utc::now(),
|
||||||
app.worktrees.home_dir = "/home/naps".into();
|
foreground: None,
|
||||||
app.worktrees.apply(vec![sample_worktree(42)]);
|
activity: Default::default(),
|
||||||
app.state.show_overlay(OverlayKind::Worktrees);
|
activity_detail: None,
|
||||||
app
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_worktrees_opens_the_view_and_fetches() {
|
fn rename_highlighted_opens_a_prompt_prefilled_with_the_current_name() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("amber-falcon"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Rename(s)) if *s == sid));
|
||||||
|
assert_eq!(app.rename_input.value(), "amber-falcon");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session with no name of its own opens an empty field: pre-filling
|
||||||
|
/// with the command would let Enter pin the command as the name.
|
||||||
|
#[test]
|
||||||
|
fn rename_prompt_starts_empty_for_an_unnamed_session() {
|
||||||
|
let mut app = make_app();
|
||||||
|
seed_session(&mut app, Uuid::from_u128(7), None);
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
assert_eq!(app.rename_input.value(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_then_enter_submits_the_edited_name() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("amber"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
for c in "-fox".chars() {
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
||||||
|
c,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
});
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.dispatch(Action::ShowWorktrees),
|
effects,
|
||||||
vec![Effect::LoadWorktrees]
|
vec![Effect::Rename {
|
||||||
|
id: sid,
|
||||||
|
name: "amber-fox".into()
|
||||||
|
}]
|
||||||
);
|
);
|
||||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn r_over_the_highlighted_worktree_asks_for_a_confirmation() {
|
|
||||||
let mut app = app_with_one_retained_worktree();
|
|
||||||
|
|
||||||
let (decision, effects) = app.handle_overlay_input(&InputKey::char('r'));
|
|
||||||
assert_eq!(decision, OverlayDecision::Consumed);
|
|
||||||
assert!(effects.is_empty());
|
|
||||||
match app.state.overlay() {
|
|
||||||
Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => {
|
|
||||||
assert_eq!(*id, Uuid::from_u128(42));
|
|
||||||
assert_eq!(branch, "feat/login");
|
|
||||||
}
|
|
||||||
other => panic!("expected a reclaim confirm, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The confirmation names what is in the way using the shared phrasing,
|
|
||||||
/// so the confirm step cannot drift from the list or the CLI.
|
|
||||||
#[test]
|
|
||||||
fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() {
|
|
||||||
let mut app = app_with_one_retained_worktree();
|
|
||||||
app.handle_overlay_input(&InputKey::char('r'));
|
|
||||||
match app.state.overlay() {
|
|
||||||
Some(OverlayKind::ReclaimConfirm { blockers, .. }) => {
|
|
||||||
assert_eq!(
|
|
||||||
blockers,
|
|
||||||
"2 uncommitted files, 1 untracked file, 3 unpushed commits"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => panic!("expected a reclaim confirm, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn confirming_a_reclaim_asks_for_the_reclaim() {
|
|
||||||
let mut app = app_with_one_retained_worktree();
|
|
||||||
app.handle_overlay_input(&InputKey::char('r'));
|
|
||||||
|
|
||||||
let (decision, effects) = app.handle_overlay_input(&InputKey::char('y'));
|
|
||||||
assert_eq!(decision, OverlayDecision::Consumed);
|
|
||||||
assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]);
|
|
||||||
assert!(app.state.overlay().is_none());
|
assert!(app.state.overlay().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn declining_a_reclaim_returns_to_the_list() {
|
fn escape_cancels_the_rename_without_an_effect() {
|
||||||
let mut app = app_with_one_retained_worktree();
|
let mut app = make_app();
|
||||||
app.handle_overlay_input(&InputKey::char('r'));
|
seed_session(&mut app, Uuid::from_u128(7), Some("amber"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
let (decision, effects) = app.handle_overlay_input(&InputKey::char('n'));
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
||||||
assert_eq!(decision, OverlayDecision::Consumed);
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
assert!(effects.is_empty());
|
assert!(effects.is_empty());
|
||||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
|
assert!(app.state.overlay().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clearing the field and submitting is how the name is dropped, so the
|
||||||
|
/// card falls back to the command.
|
||||||
#[test]
|
#[test]
|
||||||
fn j_and_k_move_the_highlight_inside_the_view() {
|
fn an_emptied_field_submits_a_blank_name() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.worktrees
|
let sid = Uuid::from_u128(7);
|
||||||
.apply(vec![sample_worktree(1), sample_worktree(2)]);
|
seed_session(&mut app, sid, Some("ab"));
|
||||||
// Distinct ids so movement is observable.
|
app.dispatch(Action::RenameHighlighted);
|
||||||
app.worktrees.items[1].branch = "other".into();
|
app.handle_overlay_input(&InputKey::Backspace);
|
||||||
app.state.show_overlay(OverlayKind::Worktrees);
|
app.handle_overlay_input(&InputKey::Backspace);
|
||||||
|
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
app.handle_overlay_input(&InputKey::char('j'));
|
assert_eq!(
|
||||||
assert_eq!(app.worktrees.selected().unwrap().branch, "other");
|
effects,
|
||||||
app.handle_overlay_input(&InputKey::char('k'));
|
vec![Effect::Rename {
|
||||||
assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login");
|
id: sid,
|
||||||
}
|
name: String::new()
|
||||||
|
}]
|
||||||
#[tokio::test]
|
);
|
||||||
async fn loading_worktrees_fills_the_view_from_the_daemon() {
|
|
||||||
use axum::routing::{delete, get};
|
|
||||||
use axum::Json;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
async fn info() -> Json<serde_json::Value> {
|
|
||||||
Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"}))
|
|
||||||
}
|
|
||||||
async fn worktrees() -> Json<serde_json::Value> {
|
|
||||||
Json(json!([{
|
|
||||||
"id": Uuid::from_u128(42).to_string(),
|
|
||||||
"path": "/home/naps/tea/maestro/worktrees/feat/login",
|
|
||||||
"base_repo": "/home/naps/tea/maestro",
|
|
||||||
"branch": "feat/login",
|
|
||||||
"reason": "force-killed",
|
|
||||||
"retained_at": "2026-08-24T10:00:00Z",
|
|
||||||
"in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3},
|
|
||||||
}]))
|
|
||||||
}
|
|
||||||
async fn reclaim() -> Json<serde_json::Value> {
|
|
||||||
Json(json!(null))
|
|
||||||
}
|
|
||||||
|
|
||||||
let app_router = axum::Router::new()
|
|
||||||
.route(maestro_protocol::routes::INFO, get(info))
|
|
||||||
.route(maestro_protocol::routes::WORKTREES, get(worktrees))
|
|
||||||
.route("/api/worktrees/:id", delete(reclaim));
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
||||||
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
|
||||||
|
|
||||||
let mut app = make_app_at(&base);
|
|
||||||
let effects = app.dispatch(Action::ShowWorktrees);
|
|
||||||
app.perform(effects).await;
|
|
||||||
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
|
||||||
.await
|
|
||||||
.expect("load completed")
|
|
||||||
.expect("channel open");
|
|
||||||
app.apply_draft_update(update).await;
|
|
||||||
|
|
||||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
|
|
||||||
assert_eq!(app.worktrees.items.len(), 1);
|
|
||||||
assert_eq!(app.worktrees.items[0].branch, "feat/login");
|
|
||||||
assert_eq!(app.worktrees.home_dir, "/home/naps");
|
|
||||||
|
|
||||||
// Reclaiming drops the entry from the open view.
|
|
||||||
app.submit_reclaim(Uuid::from_u128(42)).await;
|
|
||||||
assert!(app.worktrees.items.is_empty());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub enum TuiConfigError {
|
|||||||
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||||
// Mode
|
// Mode
|
||||||
("i", "EnterInsert"),
|
("i", "EnterInsert"),
|
||||||
// Sidebar nav
|
// Sidebar navigation
|
||||||
("j", "SidebarNext"),
|
("j", "SidebarNext"),
|
||||||
("k", "SidebarPrev"),
|
("k", "SidebarPrev"),
|
||||||
("down", "SidebarNext"),
|
("down", "SidebarNext"),
|
||||||
@@ -53,20 +53,18 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("S", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
("w", "ShowWorktrees"),
|
("r", "RenameHighlighted"),
|
||||||
// Tabs
|
|
||||||
("ctrl-t", "NewTab"),
|
|
||||||
("g t", "NextTab"),
|
|
||||||
("g T", "PrevTab"),
|
|
||||||
// Focus
|
// Focus
|
||||||
("ctrl-w h", "FocusSidebar"),
|
("ctrl-w h", "FocusSidebar"),
|
||||||
|
// Session scrollback
|
||||||
|
("pageup", "ScrollPanePageUp"),
|
||||||
|
("pagedown", "ScrollPanePageDown"),
|
||||||
// Chrome
|
// Chrome
|
||||||
("ctrl-w b", "ToggleSidebar"),
|
("ctrl-w b", "ToggleSidebar"),
|
||||||
("?", "ShowHelp"),
|
("?", "ShowHelp"),
|
||||||
("ctrl-w q", "Quit"),
|
("ctrl-w q", "Quit"),
|
||||||
// Mouse
|
// Mouse
|
||||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||||
("mouse:click(tab)", "NextTab"),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
||||||
@@ -83,6 +81,15 @@ impl TuiConfig {
|
|||||||
TuiTheme::Dark => Palette::DARK,
|
TuiTheme::Dark => Palette::DARK,
|
||||||
TuiTheme::Light => Palette::LIGHT,
|
TuiTheme::Light => Palette::LIGHT,
|
||||||
TuiTheme::Ansi => Palette::ANSI,
|
TuiTheme::Ansi => Palette::ANSI,
|
||||||
|
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
|
||||||
|
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
|
||||||
|
TuiTheme::Catppuccin => Palette::CATPPUCCIN,
|
||||||
|
TuiTheme::GithubDark => Palette::GITHUB_DARK,
|
||||||
|
TuiTheme::GithubLight => Palette::GITHUB_LIGHT,
|
||||||
|
TuiTheme::TokyoNight => Palette::TOKYO_NIGHT,
|
||||||
|
TuiTheme::Gruvbox => Palette::GRUVBOX,
|
||||||
|
TuiTheme::Nord => Palette::NORD,
|
||||||
|
TuiTheme::Dracula => Palette::DRACULA,
|
||||||
};
|
};
|
||||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
||||||
let leader = InputKey::parse(leader_str)
|
let leader = InputKey::parse(leader_str)
|
||||||
@@ -166,6 +173,15 @@ mod tests {
|
|||||||
(TuiTheme::Dark, Palette::DARK),
|
(TuiTheme::Dark, Palette::DARK),
|
||||||
(TuiTheme::Light, Palette::LIGHT),
|
(TuiTheme::Light, Palette::LIGHT),
|
||||||
(TuiTheme::Ansi, Palette::ANSI),
|
(TuiTheme::Ansi, Palette::ANSI),
|
||||||
|
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
|
||||||
|
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
|
||||||
|
(TuiTheme::Catppuccin, Palette::CATPPUCCIN),
|
||||||
|
(TuiTheme::GithubDark, Palette::GITHUB_DARK),
|
||||||
|
(TuiTheme::GithubLight, Palette::GITHUB_LIGHT),
|
||||||
|
(TuiTheme::TokyoNight, Palette::TOKYO_NIGHT),
|
||||||
|
(TuiTheme::Gruvbox, Palette::GRUVBOX),
|
||||||
|
(TuiTheme::Nord, Palette::NORD),
|
||||||
|
(TuiTheme::Dracula, Palette::DRACULA),
|
||||||
] {
|
] {
|
||||||
let section = TuiSection {
|
let section = TuiSection {
|
||||||
theme: Some(theme),
|
theme: Some(theme),
|
||||||
@@ -186,10 +202,6 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "enter"),
|
cfg.keymap.resolve(Mode::Normal, "enter"),
|
||||||
Some(Action::AttachHighlighted),
|
Some(Action::AttachHighlighted),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
|
||||||
Some(Action::NextTab),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||||
Some(Action::NewDraftPane),
|
Some(Action::NewDraftPane),
|
||||||
@@ -198,10 +210,6 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||||
Some(Action::FastForwardSpawn),
|
Some(Action::FastForwardSpawn),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
cfg.keymap.resolve(Mode::Normal, "w"),
|
|
||||||
Some(Action::ShowWorktrees),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, W
|
|||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::input::InputKey;
|
use crate::input::InputKey;
|
||||||
@@ -699,12 +699,13 @@ impl DraftPane {
|
|||||||
order.push(DraftField::DirPicker);
|
order.push(DraftField::DirPicker);
|
||||||
if self.cwd_raw_visible {
|
if self.cwd_raw_visible {
|
||||||
order.push(DraftField::CwdRaw);
|
order.push(DraftField::CwdRaw);
|
||||||
} else if self.selected_group_key.is_some() {
|
|
||||||
order.push(DraftField::WorktreeChoice);
|
|
||||||
}
|
}
|
||||||
order.push(DraftField::Name);
|
order.push(DraftField::Name);
|
||||||
order.push(DraftField::Advanced);
|
order.push(DraftField::Advanced);
|
||||||
if !self.advanced_collapsed {
|
if !self.advanced_collapsed {
|
||||||
|
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||||
|
order.push(DraftField::WorktreeChoice);
|
||||||
|
}
|
||||||
order.push(DraftField::WorktreeToggle);
|
order.push(DraftField::WorktreeToggle);
|
||||||
if self.worktree_body_visible() {
|
if self.worktree_body_visible() {
|
||||||
order.push(DraftField::BranchMode);
|
order.push(DraftField::BranchMode);
|
||||||
@@ -1078,20 +1079,17 @@ impl DraftPane {
|
|||||||
/// cursor placement the same way a session pane hides its cursor when
|
/// cursor placement the same way a session pane hides its cursor when
|
||||||
/// unfocused.
|
/// unfocused.
|
||||||
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
|
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
|
||||||
let block = Block::default().title(" draft ").borders(Borders::ALL);
|
let inner = area;
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
let show_custom = self.is_custom();
|
let show_custom = self.is_custom();
|
||||||
let show_raw = self.cwd_raw_visible;
|
let show_raw = self.cwd_raw_visible;
|
||||||
// The worktree toggle and its body stay behind the "Advanced options"
|
// The worktree choice step, the worktree toggle and its body all stay
|
||||||
// disclosure (#93): collapsed hides both, regardless of what they'd
|
// behind the "Advanced options" disclosure (#93): collapsed hides
|
||||||
// otherwise show. The worktree *choice* step is not part of that —
|
// them, regardless of what they'd otherwise show.
|
||||||
// it belongs to the directory picker (#95 rule 3), so it stays
|
|
||||||
// visible once a project is picked.
|
|
||||||
let show_wt_toggle = !self.advanced_collapsed;
|
let show_wt_toggle = !self.advanced_collapsed;
|
||||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||||
let show_wt_choice = self.selected_group_key.is_some() && !show_raw;
|
let show_wt_choice =
|
||||||
|
!self.advanced_collapsed && self.selected_group_key.is_some() && !show_raw;
|
||||||
let wt_choice_rows_data = if show_wt_choice {
|
let wt_choice_rows_data = if show_wt_choice {
|
||||||
self.worktree_choice_rows()
|
self.worktree_choice_rows()
|
||||||
} else {
|
} else {
|
||||||
@@ -1120,11 +1118,12 @@ impl DraftPane {
|
|||||||
constraints.push(Constraint::Length(list_capacity as u16));
|
constraints.push(Constraint::Length(list_capacity as u16));
|
||||||
if show_raw {
|
if show_raw {
|
||||||
constraints.push(Constraint::Length(1));
|
constraints.push(Constraint::Length(1));
|
||||||
} else if show_wt_choice {
|
|
||||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
|
||||||
}
|
}
|
||||||
constraints.push(Constraint::Length(1)); // name
|
constraints.push(Constraint::Length(1)); // name
|
||||||
constraints.push(Constraint::Length(1)); // advanced header
|
constraints.push(Constraint::Length(1)); // advanced header
|
||||||
|
if show_wt_choice {
|
||||||
|
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||||
|
}
|
||||||
if show_wt_toggle {
|
if show_wt_toggle {
|
||||||
constraints.push(Constraint::Length(1)); // worktree toggle
|
constraints.push(Constraint::Length(1)); // worktree toggle
|
||||||
}
|
}
|
||||||
@@ -1270,7 +1269,52 @@ impl DraftPane {
|
|||||||
if self.focused_field == DraftField::CwdRaw {
|
if self.focused_field == DraftField::CwdRaw {
|
||||||
cursor = Some((col, row.y));
|
cursor = Some((col, row.y));
|
||||||
}
|
}
|
||||||
} else if show_wt_choice {
|
}
|
||||||
|
|
||||||
|
// -- name + reroll ---------------------------------------------------------
|
||||||
|
let name_row = next_row();
|
||||||
|
draw_gutter(f, name_row, "name:");
|
||||||
|
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
|
||||||
|
if self.focused_field == DraftField::Name {
|
||||||
|
cursor = Some((col, name_row.y));
|
||||||
|
}
|
||||||
|
// Dim reroll hint, tucked after the typed name without covering it.
|
||||||
|
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
|
||||||
|
.min(name_row.width.saturating_sub(10));
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(Span::styled(
|
||||||
|
"^R reroll",
|
||||||
|
Style::default().add_modifier(Modifier::DIM),
|
||||||
|
))),
|
||||||
|
Rect {
|
||||||
|
x: name_row.x + hint_offset,
|
||||||
|
y: name_row.y,
|
||||||
|
width: name_row.width.saturating_sub(hint_offset),
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- advanced disclosure (#93) ------------------------------------------
|
||||||
|
let advanced_row = next_row();
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(format!("{:<GUTTER$}", "adv:")),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"[{}] Advanced options",
|
||||||
|
if self.advanced_collapsed { "+" } else { "-" }
|
||||||
|
),
|
||||||
|
field_style(DraftField::Advanced),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!(" — {}", self.advanced_summary()),
|
||||||
|
Style::default().add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
advanced_row,
|
||||||
|
);
|
||||||
|
|
||||||
|
if show_wt_choice {
|
||||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||||
// worktrees newest-first.
|
// worktrees newest-first.
|
||||||
let choice_rows = wt_choice_rows_data;
|
let choice_rows = wt_choice_rows_data;
|
||||||
@@ -1319,49 +1363,6 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- name + reroll ---------------------------------------------------------
|
|
||||||
let name_row = next_row();
|
|
||||||
draw_gutter(f, name_row, "name:");
|
|
||||||
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
|
|
||||||
if self.focused_field == DraftField::Name {
|
|
||||||
cursor = Some((col, name_row.y));
|
|
||||||
}
|
|
||||||
// Dim reroll hint, tucked after the typed name without covering it.
|
|
||||||
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
|
|
||||||
.min(name_row.width.saturating_sub(10));
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(Line::from(Span::styled(
|
|
||||||
"^R reroll",
|
|
||||||
Style::default().add_modifier(Modifier::DIM),
|
|
||||||
))),
|
|
||||||
Rect {
|
|
||||||
x: name_row.x + hint_offset,
|
|
||||||
y: name_row.y,
|
|
||||||
width: name_row.width.saturating_sub(hint_offset),
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// -- advanced disclosure (#93) ------------------------------------------
|
|
||||||
let advanced_row = next_row();
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(Line::from(vec![
|
|
||||||
Span::raw(format!("{:<GUTTER$}", "adv:")),
|
|
||||||
Span::styled(
|
|
||||||
format!(
|
|
||||||
"[{}] Advanced options",
|
|
||||||
if self.advanced_collapsed { "+" } else { "-" }
|
|
||||||
),
|
|
||||||
field_style(DraftField::Advanced),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!(" — {}", self.advanced_summary()),
|
|
||||||
Style::default().add_modifier(Modifier::DIM),
|
|
||||||
),
|
|
||||||
])),
|
|
||||||
advanced_row,
|
|
||||||
);
|
|
||||||
|
|
||||||
// -- worktree (#65) -----------------------------------------------------
|
// -- worktree (#65) -----------------------------------------------------
|
||||||
if show_wt_toggle {
|
if show_wt_toggle {
|
||||||
let wt_row = next_row();
|
let wt_row = next_row();
|
||||||
@@ -2117,7 +2118,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The terminal cursor must sit right after the typed text in the focused
|
/// The terminal cursor must sit right after the typed text in the focused
|
||||||
/// field (gutter is 5 chars, block border takes column 0).
|
/// field (gutter is 5 chars and the pane has no border).
|
||||||
#[test]
|
#[test]
|
||||||
fn render_places_the_cursor_in_the_focused_text_field() {
|
fn render_places_the_cursor_in_the_focused_text_field() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
@@ -2129,10 +2130,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).unwrap();
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
||||||
let pos = terminal.get_cursor_position().unwrap();
|
let pos = terminal.get_cursor_position().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
|
||||||
pos,
|
|
||||||
ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2212,9 +2210,14 @@ mod tests {
|
|||||||
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
|
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
|
||||||
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
|
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
|
||||||
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
|
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
|
||||||
|
assert!(
|
||||||
|
!draft.field_order().contains(&DraftField::WorktreeChoice),
|
||||||
|
"step two lives behind the collapsed \"Advanced options\" disclosure"
|
||||||
|
);
|
||||||
|
draft.toggle_advanced();
|
||||||
assert!(
|
assert!(
|
||||||
draft.field_order().contains(&DraftField::WorktreeChoice),
|
draft.field_order().contains(&DraftField::WorktreeChoice),
|
||||||
"step two is now reachable"
|
"step two is reachable once advanced is open"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||||
@@ -2627,6 +2630,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
draft.focused_field = DraftField::DirPicker;
|
draft.focused_field = DraftField::DirPicker;
|
||||||
draft.on_input(&InputKey::Enter); // picks the "main" box
|
draft.on_input(&InputKey::Enter); // picks the "main" box
|
||||||
|
let collapsed = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
assert!(
|
||||||
|
!collapsed.contains("+ new"),
|
||||||
|
"step two hides with \"Advanced options\" closed:\n{collapsed}"
|
||||||
|
);
|
||||||
|
|
||||||
|
draft.toggle_advanced();
|
||||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ pub enum InputKey {
|
|||||||
Right,
|
Right,
|
||||||
Home,
|
Home,
|
||||||
End,
|
End,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
Delete,
|
Delete,
|
||||||
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
||||||
/// text fields can insert it as a single operation.
|
/// text fields can insert it as a single operation.
|
||||||
@@ -63,7 +65,6 @@ pub enum MouseKind {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Region {
|
pub enum Region {
|
||||||
SidebarRow,
|
SidebarRow,
|
||||||
Tab,
|
|
||||||
Pane,
|
Pane,
|
||||||
Modal,
|
Modal,
|
||||||
Unknown,
|
Unknown,
|
||||||
@@ -136,6 +137,8 @@ impl InputKey {
|
|||||||
InputKey::Right => "right".into(),
|
InputKey::Right => "right".into(),
|
||||||
InputKey::Home => "home".into(),
|
InputKey::Home => "home".into(),
|
||||||
InputKey::End => "end".into(),
|
InputKey::End => "end".into(),
|
||||||
|
InputKey::PageUp => "pageup".into(),
|
||||||
|
InputKey::PageDown => "pagedown".into(),
|
||||||
InputKey::Delete => "delete".into(),
|
InputKey::Delete => "delete".into(),
|
||||||
// Pastes are not bindable; the canonical form exists only to
|
// Pastes are not bindable; the canonical form exists only to
|
||||||
// satisfy exhaustiveness and never round-trips through `parse`.
|
// satisfy exhaustiveness and never round-trips through `parse`.
|
||||||
@@ -150,7 +153,6 @@ impl InputKey {
|
|||||||
};
|
};
|
||||||
let r = match region {
|
let r = match region {
|
||||||
Region::SidebarRow => "sidebar-row",
|
Region::SidebarRow => "sidebar-row",
|
||||||
Region::Tab => "tab",
|
|
||||||
Region::Pane => "pane",
|
Region::Pane => "pane",
|
||||||
Region::Modal => "modal",
|
Region::Modal => "modal",
|
||||||
Region::Unknown => "unknown",
|
Region::Unknown => "unknown",
|
||||||
@@ -182,7 +184,6 @@ impl InputKey {
|
|||||||
};
|
};
|
||||||
let region = match r {
|
let region = match r {
|
||||||
"sidebar-row" => Region::SidebarRow,
|
"sidebar-row" => Region::SidebarRow,
|
||||||
"tab" => Region::Tab,
|
|
||||||
"pane" => Region::Pane,
|
"pane" => Region::Pane,
|
||||||
"modal" => Region::Modal,
|
"modal" => Region::Modal,
|
||||||
"unknown" => Region::Unknown,
|
"unknown" => Region::Unknown,
|
||||||
@@ -207,6 +208,8 @@ impl InputKey {
|
|||||||
"right" => return Ok(InputKey::Right),
|
"right" => return Ok(InputKey::Right),
|
||||||
"home" => return Ok(InputKey::Home),
|
"home" => return Ok(InputKey::Home),
|
||||||
"end" => return Ok(InputKey::End),
|
"end" => return Ok(InputKey::End),
|
||||||
|
"pageup" => return Ok(InputKey::PageUp),
|
||||||
|
"pagedown" => return Ok(InputKey::PageDown),
|
||||||
"delete" => return Ok(InputKey::Delete),
|
"delete" => return Ok(InputKey::Delete),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -281,6 +284,8 @@ where
|
|||||||
KeyCode::Right => InputKey::Right,
|
KeyCode::Right => InputKey::Right,
|
||||||
KeyCode::Home => InputKey::Home,
|
KeyCode::Home => InputKey::Home,
|
||||||
KeyCode::End => InputKey::End,
|
KeyCode::End => InputKey::End,
|
||||||
|
KeyCode::PageUp => InputKey::PageUp,
|
||||||
|
KeyCode::PageDown => InputKey::PageDown,
|
||||||
KeyCode::Delete => InputKey::Delete,
|
KeyCode::Delete => InputKey::Delete,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
@@ -617,6 +622,8 @@ mod tests {
|
|||||||
"right",
|
"right",
|
||||||
"home",
|
"home",
|
||||||
"end",
|
"end",
|
||||||
|
"pageup",
|
||||||
|
"pagedown",
|
||||||
"delete",
|
"delete",
|
||||||
] {
|
] {
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_chord() {
|
fn resolves_chord() {
|
||||||
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
|
let k = build(&[(Mode::Normal, "g h", "FocusSidebar")]);
|
||||||
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
|
assert_eq!(k.resolve(Mode::Normal, "g h"), Some(Action::FocusSidebar));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -123,11 +123,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn known_bindings_sees_everything() {
|
fn known_bindings_sees_everything() {
|
||||||
let k = build(&[
|
let k = build(&[
|
||||||
(Mode::Normal, "g t", "NextTab"),
|
(Mode::Normal, "g h", "FocusSidebar"),
|
||||||
(Mode::Normal, "i", "EnterInsert"),
|
(Mode::Normal, "i", "EnterInsert"),
|
||||||
]);
|
]);
|
||||||
let kb = k.known_bindings(Mode::Normal);
|
let kb = k.known_bindings(Mode::Normal);
|
||||||
assert!(kb.contains("g t"));
|
assert!(kb.contains("g h"));
|
||||||
assert!(kb.contains("i"));
|
assert!(kb.contains("i"));
|
||||||
assert_eq!(kb.len(), 2);
|
assert_eq!(kb.len(), 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ pub mod render;
|
|||||||
pub mod session_task;
|
pub mod session_task;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tabs;
|
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod text_input;
|
pub mod text_input;
|
||||||
pub mod worktrees;
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
|
//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay,
|
||||||
|
//! transient toast.
|
||||||
//!
|
//!
|
||||||
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
||||||
//! panes ([`crate::draft::DraftPane`]).
|
//! panes ([`crate::draft::DraftPane`]).
|
||||||
@@ -7,10 +8,13 @@
|
|||||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::text_input::TextInput;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -29,7 +33,6 @@ impl KillConfirm {
|
|||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
|
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
|
||||||
if let Some(blockers) = &self.blockers {
|
if let Some(blockers) = &self.blockers {
|
||||||
// Same phrasing the reclaim confirmation uses for its own guard.
|
|
||||||
lines.push(Line::from(format!("Currently in the way: {blockers}")));
|
lines.push(Line::from(format!("Currently in the way: {blockers}")));
|
||||||
lines.push(Line::from("The kill will be refused unless forced."));
|
lines.push(Line::from("The kill will be refused unless forced."));
|
||||||
}
|
}
|
||||||
@@ -39,6 +42,49 @@ impl KillConfirm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename prompt for the highlighted session (#125).
|
||||||
|
///
|
||||||
|
/// Borrows its text buffer rather than owning it: the buffer lives on `App`,
|
||||||
|
/// so [`crate::state::OverlayKind`] stays a plain comparable enum.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RenamePrompt<'a> {
|
||||||
|
/// What the card shows today — the session's name, or the command it
|
||||||
|
/// falls back to when it has none.
|
||||||
|
pub current: &'a str,
|
||||||
|
pub input: &'a TextInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenamePrompt<'_> {
|
||||||
|
/// Draws the prompt and returns where the terminal cursor belongs.
|
||||||
|
pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> {
|
||||||
|
let area = centered(parent, 64, 7);
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
let block = Block::default().title(" rename ").borders(Borders::ALL);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
if inner.height < 4 || inner.width == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let row = |offset: u16| Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y: inner.y + offset,
|
||||||
|
width: inner.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(format!("Rename {}", self.current))),
|
||||||
|
row(0),
|
||||||
|
);
|
||||||
|
let field = row(1);
|
||||||
|
let col = self.input.render(field, f.buffer_mut(), Style::default());
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from("[enter] save [esc] cancel")),
|
||||||
|
row(3),
|
||||||
|
);
|
||||||
|
Some((col, field.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HelpOverlay {
|
pub struct HelpOverlay {
|
||||||
pub lines: Vec<String>,
|
pub lines: Vec<String>,
|
||||||
@@ -151,16 +197,32 @@ mod tests {
|
|||||||
assert!(rendered.contains("refused"), "{rendered}");
|
assert!(rendered.contains("refused"), "{rendered}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_prompt_shows_the_current_name_and_the_edit_buffer() {
|
||||||
|
let input = TextInput::with_value("crimson-otter");
|
||||||
|
let prompt = RenamePrompt {
|
||||||
|
current: "amber-falcon",
|
||||||
|
input: &input,
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(70, 12, |f| {
|
||||||
|
prompt.render(f, f.area());
|
||||||
|
});
|
||||||
|
assert!(rendered.contains("Rename amber-falcon"), "{rendered}");
|
||||||
|
assert!(rendered.contains("crimson-otter"), "{rendered}");
|
||||||
|
assert!(rendered.contains("[enter] save"), "{rendered}");
|
||||||
|
assert!(rendered.contains("[esc] cancel"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_overlay_lists_lines() {
|
fn help_overlay_lists_lines() {
|
||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
lines: vec![
|
lines: vec![
|
||||||
"i EnterInsert".into(),
|
"i Send keys to session".into(),
|
||||||
"s NewDraftPane".into(),
|
"s Create a session".into(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||||
assert!(rendered.contains("EnterInsert"));
|
assert!(rendered.contains("Send keys to session"));
|
||||||
assert!(rendered.contains("NewDraftPane"));
|
assert!(rendered.contains("Create a session"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
||||||
//!
|
//!
|
||||||
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
|
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
|
||||||
//! [`Palette::ANSI`] uses terminal-provided ANSI colours.
|
//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining
|
||||||
|
//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized,
|
||||||
|
//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual
|
||||||
|
//! colours lifted or darkened where the originals fall short of the contrast
|
||||||
|
//! floors asserted below.
|
||||||
//!
|
//!
|
||||||
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||||
//! pane and pass through `pane::translate_color` verbatim.
|
//! pane and pass through `pane::translate_color` verbatim.
|
||||||
@@ -109,6 +113,8 @@ pub struct Palette {
|
|||||||
/// paint this background too; otherwise its contrast depends on the
|
/// paint this background too; otherwise its contrast depends on the
|
||||||
/// operator's terminal theme.
|
/// operator's terminal theme.
|
||||||
pub chrome_bg: Color,
|
pub chrome_bg: Color,
|
||||||
|
/// Subtle full-panel background while the sidebar owns focus.
|
||||||
|
pub sidebar_focus_bg: Color,
|
||||||
/// Panel border while that panel holds focus.
|
/// Panel border while that panel holds focus.
|
||||||
pub border_focused: Color,
|
pub border_focused: Color,
|
||||||
/// Panel border while it does not.
|
/// Panel border while it does not.
|
||||||
@@ -152,6 +158,7 @@ impl Palette {
|
|||||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||||
},
|
},
|
||||||
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a),
|
||||||
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
|
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
|
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
|
||||||
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
|
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
@@ -186,6 +193,7 @@ impl Palette {
|
|||||||
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
||||||
},
|
},
|
||||||
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef),
|
||||||
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
|
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
|
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
|
||||||
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
|
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
@@ -220,6 +228,7 @@ impl Palette {
|
|||||||
error: Color::LightRed,
|
error: Color::LightRed,
|
||||||
},
|
},
|
||||||
chrome_bg: Color::Black,
|
chrome_bg: Color::Black,
|
||||||
|
sidebar_focus_bg: Color::DarkGray,
|
||||||
border_focused: Color::LightGreen,
|
border_focused: Color::LightGreen,
|
||||||
border_unfocused: Color::DarkGray,
|
border_unfocused: Color::DarkGray,
|
||||||
focus_accent: Color::LightGreen,
|
focus_accent: Color::LightGreen,
|
||||||
@@ -231,30 +240,353 @@ impl Palette {
|
|||||||
error_text: Color::LightRed,
|
error_text: Color::LightRed,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Solarized Dark — Ethan Schoonover's low-contrast classic,
|
||||||
|
/// accents lifted where the originals fall short of WCAG AA.
|
||||||
|
pub const SOLARIZED_DARK: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
fg: Color::Rgb(0x93, 0xa1, 0xa1),
|
||||||
|
muted: Color::Rgb(0x83, 0x94, 0x96),
|
||||||
|
dim: Color::Rgb(0x65, 0x7b, 0x83),
|
||||||
|
active: Color::Rgb(0x85, 0x99, 0x00),
|
||||||
|
idle: Color::Rgb(0x83, 0x94, 0x96),
|
||||||
|
awaiting: Color::Rgb(0xb5, 0x89, 0x00),
|
||||||
|
error: Color::Rgb(0xe5, 0x68, 0x65),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x10, 0x53, 0x62),
|
||||||
|
fg: Color::Rgb(0xb6, 0xc0, 0xc0),
|
||||||
|
muted: Color::Rgb(0xb5, 0xc0, 0xc1),
|
||||||
|
dim: Color::Rgb(0x8b, 0x9f, 0xa6),
|
||||||
|
active: Color::Rgb(0xaf, 0xca, 0x00),
|
||||||
|
idle: Color::Rgb(0xb5, 0xc0, 0xc1),
|
||||||
|
awaiting: Color::Rgb(0xef, 0xb5, 0x00),
|
||||||
|
error: Color::Rgb(0xf1, 0xad, 0xab),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x00, 0x1a, 0x21),
|
||||||
|
border_focused: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
border_unfocused: Color::Rgb(0x5e, 0x76, 0x7e),
|
||||||
|
focus_accent: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
group_row: Color::Rgb(0x93, 0xa1, 0xa1),
|
||||||
|
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
mode_insert_bg: Color::Rgb(0xb5, 0x89, 0x00),
|
||||||
|
mode_insert_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
error_text: Color::Rgb(0xe5, 0x68, 0x65),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Solarized Light — the warm cream-paper peer of Solarized Dark.
|
||||||
|
pub const SOLARIZED_LIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||||
|
fg: Color::Rgb(0x58, 0x6e, 0x75),
|
||||||
|
muted: Color::Rgb(0x5e, 0x73, 0x7a),
|
||||||
|
dim: Color::Rgb(0x7e, 0x8f, 0x92),
|
||||||
|
active: Color::Rgb(0x67, 0x76, 0x00),
|
||||||
|
idle: Color::Rgb(0x5e, 0x73, 0x7a),
|
||||||
|
awaiting: Color::Rgb(0x8d, 0x6a, 0x00),
|
||||||
|
error: Color::Rgb(0xd7, 0x27, 0x24),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0xcb, 0xbd, 0x93),
|
||||||
|
fg: Color::Rgb(0x3f, 0x4e, 0x53),
|
||||||
|
muted: Color::Rgb(0x40, 0x4e, 0x53),
|
||||||
|
dim: Color::Rgb(0x59, 0x67, 0x69),
|
||||||
|
active: Color::Rgb(0x46, 0x50, 0x00),
|
||||||
|
idle: Color::Rgb(0x40, 0x4e, 0x53),
|
||||||
|
awaiting: Color::Rgb(0x60, 0x48, 0x00),
|
||||||
|
error: Color::Rgb(0x94, 0x1b, 0x19),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0xf7, 0xf4, 0xed),
|
||||||
|
border_focused: Color::Rgb(0x29, 0x9b, 0x93),
|
||||||
|
border_unfocused: Color::Rgb(0x7f, 0x8f, 0x8f),
|
||||||
|
focus_accent: Color::Rgb(0x29, 0x9b, 0x93),
|
||||||
|
group_row: Color::Rgb(0x58, 0x6e, 0x75),
|
||||||
|
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||||
|
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||||
|
mode_insert_bg: Color::Rgb(0x8d, 0x6a, 0x00),
|
||||||
|
mode_insert_fg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||||
|
error_text: Color::Rgb(0xd7, 0x27, 0x24),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Catppuccin Mocha — soothing pastels on a soft dark base.
|
||||||
|
pub const CATPPUCCIN: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
||||||
|
muted: Color::Rgb(0xa6, 0xad, 0xc8),
|
||||||
|
dim: Color::Rgb(0x7f, 0x84, 0x9c),
|
||||||
|
active: Color::Rgb(0xa6, 0xe3, 0xa1),
|
||||||
|
idle: Color::Rgb(0xa6, 0xad, 0xc8),
|
||||||
|
awaiting: Color::Rgb(0xf9, 0xe2, 0xaf),
|
||||||
|
error: Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x45, 0x47, 0x5a),
|
||||||
|
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
||||||
|
muted: Color::Rgb(0xb2, 0xb8, 0xcf),
|
||||||
|
dim: Color::Rgb(0x92, 0x97, 0xab),
|
||||||
|
active: Color::Rgb(0xb5, 0xe8, 0xb1),
|
||||||
|
idle: Color::Rgb(0xb2, 0xb8, 0xcf),
|
||||||
|
awaiting: Color::Rgb(0xfa, 0xe9, 0xc2),
|
||||||
|
error: Color::Rgb(0xf5, 0xa1, 0xb8),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x2a, 0x2a, 0x3c),
|
||||||
|
border_focused: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||||
|
border_unfocused: Color::Rgb(0x6c, 0x70, 0x86),
|
||||||
|
focus_accent: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||||
|
group_row: Color::Rgb(0xb4, 0xbe, 0xfe),
|
||||||
|
mode_normal_bg: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||||
|
mode_normal_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
mode_insert_bg: Color::Rgb(0xf9, 0xe2, 0xaf),
|
||||||
|
mode_insert_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||||
|
error_text: Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GitHub Dark — the github.com dark default.
|
||||||
|
pub const GITHUB_DARK: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
||||||
|
muted: Color::Rgb(0x91, 0x98, 0xa1),
|
||||||
|
dim: Color::Rgb(0x6e, 0x76, 0x81),
|
||||||
|
active: Color::Rgb(0x3f, 0xb9, 0x50),
|
||||||
|
idle: Color::Rgb(0x91, 0x98, 0xa1),
|
||||||
|
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
|
||||||
|
error: Color::Rgb(0xf8, 0x51, 0x49),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x1f, 0x3a, 0x5f),
|
||||||
|
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
||||||
|
muted: Color::Rgb(0x9f, 0xa5, 0xad),
|
||||||
|
dim: Color::Rgb(0x7e, 0x86, 0x91),
|
||||||
|
active: Color::Rgb(0x40, 0xbc, 0x51),
|
||||||
|
idle: Color::Rgb(0x9f, 0xa5, 0xad),
|
||||||
|
awaiting: Color::Rgb(0xd4, 0x9b, 0x22),
|
||||||
|
error: Color::Rgb(0xfa, 0x81, 0x7b),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x16, 0x1b, 0x26),
|
||||||
|
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
|
||||||
|
border_unfocused: Color::Rgb(0x58, 0x64, 0x70),
|
||||||
|
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
|
||||||
|
group_row: Color::Rgb(0x91, 0x98, 0xa1),
|
||||||
|
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
|
||||||
|
mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
|
||||||
|
mode_insert_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||||
|
error_text: Color::Rgb(0xf8, 0x51, 0x49),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GitHub Light — the github.com light default.
|
||||||
|
pub const GITHUB_LIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
||||||
|
muted: Color::Rgb(0x59, 0x63, 0x6e),
|
||||||
|
dim: Color::Rgb(0x81, 0x8b, 0x98),
|
||||||
|
active: Color::Rgb(0x1a, 0x7f, 0x37),
|
||||||
|
idle: Color::Rgb(0x59, 0x63, 0x6e),
|
||||||
|
awaiting: Color::Rgb(0x9a, 0x67, 0x00),
|
||||||
|
error: Color::Rgb(0xd1, 0x24, 0x2f),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0xa5, 0xcd, 0xf0),
|
||||||
|
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
||||||
|
muted: Color::Rgb(0x4b, 0x53, 0x5d),
|
||||||
|
dim: Color::Rgb(0x64, 0x6d, 0x7a),
|
||||||
|
active: Color::Rgb(0x14, 0x5f, 0x29),
|
||||||
|
idle: Color::Rgb(0x4b, 0x53, 0x5d),
|
||||||
|
awaiting: Color::Rgb(0x71, 0x4b, 0x00),
|
||||||
|
error: Color::Rgb(0xa1, 0x1c, 0x24),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0xf0, 0xf6, 0xfc),
|
||||||
|
border_focused: Color::Rgb(0x09, 0x69, 0xda),
|
||||||
|
border_unfocused: Color::Rgb(0x89, 0x93, 0x9d),
|
||||||
|
focus_accent: Color::Rgb(0x09, 0x69, 0xda),
|
||||||
|
group_row: Color::Rgb(0x59, 0x63, 0x6e),
|
||||||
|
mode_normal_bg: Color::Rgb(0x09, 0x69, 0xda),
|
||||||
|
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
mode_insert_bg: Color::Rgb(0x9a, 0x67, 0x00),
|
||||||
|
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
error_text: Color::Rgb(0xd1, 0x24, 0x2f),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tokyo Night — neon-tinted blues of the VS Code / vim favourite.
|
||||||
|
pub const TOKYO_NIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
||||||
|
muted: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||||
|
dim: Color::Rgb(0x5d, 0x67, 0x95),
|
||||||
|
active: Color::Rgb(0x9e, 0xce, 0x6a),
|
||||||
|
idle: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||||
|
awaiting: Color::Rgb(0xe0, 0xaf, 0x68),
|
||||||
|
error: Color::Rgb(0xf7, 0x76, 0x8e),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x33, 0x46, 0x7c),
|
||||||
|
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
||||||
|
muted: Color::Rgb(0xb0, 0xb8, 0xd9),
|
||||||
|
dim: Color::Rgb(0x8e, 0x96, 0xb8),
|
||||||
|
active: Color::Rgb(0xa8, 0xd3, 0x79),
|
||||||
|
idle: Color::Rgb(0xb0, 0xb8, 0xd9),
|
||||||
|
awaiting: Color::Rgb(0xe1, 0xb1, 0x6b),
|
||||||
|
error: Color::Rgb(0xf9, 0xa0, 0xb0),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x24, 0x28, 0x3b),
|
||||||
|
border_focused: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||||
|
border_unfocused: Color::Rgb(0x5d, 0x67, 0x95),
|
||||||
|
focus_accent: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||||
|
group_row: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||||
|
mode_normal_bg: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||||
|
mode_normal_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
mode_insert_bg: Color::Rgb(0xe0, 0xaf, 0x68),
|
||||||
|
mode_insert_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||||
|
error_text: Color::Rgb(0xf7, 0x76, 0x8e),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Gruvbox Dark — retro warm earth tones.
|
||||||
|
pub const GRUVBOX: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
||||||
|
muted: Color::Rgb(0xbd, 0xae, 0x93),
|
||||||
|
dim: Color::Rgb(0x92, 0x83, 0x74),
|
||||||
|
active: Color::Rgb(0xb8, 0xbb, 0x26),
|
||||||
|
idle: Color::Rgb(0xbd, 0xae, 0x93),
|
||||||
|
awaiting: Color::Rgb(0xfa, 0xbd, 0x2f),
|
||||||
|
error: Color::Rgb(0xfb, 0x58, 0x44),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x50, 0x49, 0x45),
|
||||||
|
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
||||||
|
muted: Color::Rgb(0xc7, 0xbb, 0xa4),
|
||||||
|
dim: Color::Rgb(0xa4, 0x98, 0x8b),
|
||||||
|
active: Color::Rgb(0xbf, 0xc3, 0x28),
|
||||||
|
idle: Color::Rgb(0xc7, 0xbb, 0xa4),
|
||||||
|
awaiting: Color::Rgb(0xfa, 0xc3, 0x43),
|
||||||
|
error: Color::Rgb(0xfd, 0xa5, 0x9a),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x32, 0x30, 0x2f),
|
||||||
|
border_focused: Color::Rgb(0xfe, 0x80, 0x19),
|
||||||
|
border_unfocused: Color::Rgb(0x7e, 0x71, 0x67),
|
||||||
|
focus_accent: Color::Rgb(0xfe, 0x80, 0x19),
|
||||||
|
group_row: Color::Rgb(0xd5, 0xc4, 0xa1),
|
||||||
|
mode_normal_bg: Color::Rgb(0xfe, 0x80, 0x19),
|
||||||
|
mode_normal_fg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
mode_insert_bg: Color::Rgb(0xfa, 0xbd, 0x2f),
|
||||||
|
mode_insert_fg: Color::Rgb(0x28, 0x28, 0x28),
|
||||||
|
error_text: Color::Rgb(0xfb, 0x58, 0x44),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Nord — arctic, bluish and calm; the red lifted to stay legible.
|
||||||
|
pub const NORD: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
||||||
|
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
dim: Color::Rgb(0x9a, 0xa5, 0xb5),
|
||||||
|
active: Color::Rgb(0xa3, 0xbe, 0x8c),
|
||||||
|
idle: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
awaiting: Color::Rgb(0xeb, 0xcb, 0x8b),
|
||||||
|
error: Color::Rgb(0xd0, 0x8b, 0x91),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x4c, 0x56, 0x6a),
|
||||||
|
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
||||||
|
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
dim: Color::Rgb(0x9f, 0xaa, 0xb9),
|
||||||
|
active: Color::Rgb(0xc0, 0xd3, 0xb0),
|
||||||
|
idle: Color::Rgb(0xe5, 0xe9, 0xf0),
|
||||||
|
awaiting: Color::Rgb(0xee, 0xd3, 0x9c),
|
||||||
|
error: Color::Rgb(0xe7, 0xc5, 0xc8),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x35, 0x3b, 0x49),
|
||||||
|
border_focused: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||||
|
border_unfocused: Color::Rgb(0x72, 0x80, 0x9a),
|
||||||
|
focus_accent: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||||
|
group_row: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||||
|
mode_normal_bg: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||||
|
mode_normal_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
mode_insert_bg: Color::Rgb(0xeb, 0xcb, 0x8b),
|
||||||
|
mode_insert_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||||
|
error_text: Color::Rgb(0xd0, 0x8b, 0x91),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Dracula — high-saturation accents on a dark violet-grey base.
|
||||||
|
pub const DRACULA: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
||||||
|
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
dim: Color::Rgb(0x65, 0x74, 0xa6),
|
||||||
|
active: Color::Rgb(0x50, 0xfa, 0x7b),
|
||||||
|
idle: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
awaiting: Color::Rgb(0xf1, 0xfa, 0x8c),
|
||||||
|
error: Color::Rgb(0xff, 0x59, 0x59),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x4a, 0x4e, 0x62),
|
||||||
|
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
||||||
|
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
dim: Color::Rgb(0x94, 0x9f, 0xc1),
|
||||||
|
active: Color::Rgb(0x64, 0xfb, 0x8a),
|
||||||
|
idle: Color::Rgb(0xcc, 0xcf, 0xe1),
|
||||||
|
awaiting: Color::Rgb(0xf3, 0xfb, 0xa0),
|
||||||
|
error: Color::Rgb(0xff, 0xad, 0xad),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
sidebar_focus_bg: Color::Rgb(0x31, 0x33, 0x42),
|
||||||
|
border_focused: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||||
|
border_unfocused: Color::Rgb(0x65, 0x74, 0xa6),
|
||||||
|
focus_accent: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||||
|
group_row: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||||
|
mode_normal_bg: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||||
|
mode_normal_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
mode_insert_bg: Color::Rgb(0xf1, 0xfa, 0x8c),
|
||||||
|
mode_insert_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||||
|
error_text: Color::Rgb(0xff, 0x59, 0x59),
|
||||||
|
};
|
||||||
|
|
||||||
/// The default preset, retained as a named compatibility constant.
|
/// The default preset, retained as a named compatibility constant.
|
||||||
pub const DEFAULT: Palette = Self::DARK;
|
pub const DEFAULT: Palette = Self::DARK;
|
||||||
|
|
||||||
/// All preset names and their complete palettes.
|
/// All preset names and their complete palettes.
|
||||||
pub fn presets() -> [(&'static str, Palette); 3] {
|
pub fn presets() -> [(&'static str, Palette); 12] {
|
||||||
[
|
[
|
||||||
("dark", Self::DARK),
|
("dark", Self::DARK),
|
||||||
("light", Self::LIGHT),
|
("light", Self::LIGHT),
|
||||||
("ansi", Self::ANSI),
|
("ansi", Self::ANSI),
|
||||||
|
("solarized-dark", Self::SOLARIZED_DARK),
|
||||||
|
("solarized-light", Self::SOLARIZED_LIGHT),
|
||||||
|
("catppuccin", Self::CATPPUCCIN),
|
||||||
|
("github-dark", Self::GITHUB_DARK),
|
||||||
|
("github-light", Self::GITHUB_LIGHT),
|
||||||
|
("tokyo-night", Self::TOKYO_NIGHT),
|
||||||
|
("gruvbox", Self::GRUVBOX),
|
||||||
|
("nord", Self::NORD),
|
||||||
|
("dracula", Self::DRACULA),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a config name to a palette.
|
/// Resolve a config name to a palette.
|
||||||
pub fn named(name: &str) -> Option<Palette> {
|
pub fn named(name: &str) -> Option<Palette> {
|
||||||
match name {
|
Self::presets()
|
||||||
"dark" => Some(Self::DARK),
|
.into_iter()
|
||||||
"light" => Some(Self::LIGHT),
|
.find(|(n, _)| *n == name)
|
||||||
"ansi" => Some(Self::ANSI),
|
.map(|(_, p)| p)
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every role, including the backgrounds it is painted against.
|
/// Every role, including the backgrounds it is painted against.
|
||||||
pub fn roles(&self) -> [(&'static str, Color); 26] {
|
pub fn roles(&self) -> [(&'static str, Color); 27] {
|
||||||
[
|
[
|
||||||
("card.bg", self.card.bg),
|
("card.bg", self.card.bg),
|
||||||
("card.fg", self.card.fg),
|
("card.fg", self.card.fg),
|
||||||
@@ -273,6 +605,7 @@ impl Palette {
|
|||||||
("selection.awaiting", self.selection.awaiting),
|
("selection.awaiting", self.selection.awaiting),
|
||||||
("selection.error", self.selection.error),
|
("selection.error", self.selection.error),
|
||||||
("chrome_bg", self.chrome_bg),
|
("chrome_bg", self.chrome_bg),
|
||||||
|
("sidebar_focus_bg", self.sidebar_focus_bg),
|
||||||
("border_focused", self.border_focused),
|
("border_focused", self.border_focused),
|
||||||
("border_unfocused", self.border_unfocused),
|
("border_unfocused", self.border_unfocused),
|
||||||
("focus_accent", self.focus_accent),
|
("focus_accent", self.focus_accent),
|
||||||
@@ -392,11 +725,19 @@ mod tests {
|
|||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Every preset except `ansi`, whose colours the terminal owns and which
|
||||||
|
/// therefore has no fixed luminance to assert against.
|
||||||
|
fn rgb_presets() -> impl Iterator<Item = (&'static str, Palette)> {
|
||||||
|
Palette::presets()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, p)| relative_luminance(p.card.bg).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
||||||
/// decoration and only has to clear the 3:1 non-text threshold.
|
/// decoration and only has to clear the 3:1 non-text threshold.
|
||||||
#[test]
|
#[test]
|
||||||
fn every_role_is_legible_on_its_own_surface() {
|
fn every_role_is_legible_on_its_own_surface() {
|
||||||
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
for (preset, p) in rgb_presets() {
|
||||||
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
||||||
for (role, color) in surface.roles() {
|
for (role, color) in surface.roles() {
|
||||||
let ratio = contrast_ratio(surface.bg, color)
|
let ratio = contrast_ratio(surface.bg, color)
|
||||||
@@ -414,11 +755,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn every_preset_supplies_every_role() {
|
fn every_preset_supplies_every_role() {
|
||||||
for (name, p) in Palette::presets() {
|
for (name, p) in Palette::presets() {
|
||||||
assert_eq!(p.roles().len(), 26, "{name} role set is incomplete");
|
assert_eq!(p.roles().len(), 27, "{name} role set is incomplete");
|
||||||
|
assert_eq!(Palette::named(name), Some(p));
|
||||||
}
|
}
|
||||||
assert_eq!(Palette::named("dark"), Some(Palette::DARK));
|
|
||||||
assert_eq!(Palette::named("light"), Some(Palette::LIGHT));
|
|
||||||
assert_eq!(Palette::named("ansi"), Some(Palette::ANSI));
|
|
||||||
assert_eq!(Palette::named("unknown"), None);
|
assert_eq!(Palette::named("unknown"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +765,7 @@ mod tests {
|
|||||||
/// merely a slightly different shade of the card.
|
/// merely a slightly different shade of the card.
|
||||||
#[test]
|
#[test]
|
||||||
fn selection_background_is_distinguishable_from_the_card_background() {
|
fn selection_background_is_distinguishable_from_the_card_background() {
|
||||||
for (name, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
for (name, p) in rgb_presets() {
|
||||||
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
ratio >= 1.6,
|
ratio >= 1.6,
|
||||||
@@ -456,7 +795,7 @@ mod tests {
|
|||||||
/// actually painted on; they must never rely on the terminal default.
|
/// actually painted on; they must never rely on the terminal default.
|
||||||
#[test]
|
#[test]
|
||||||
fn chrome_roles_are_rgb_values() {
|
fn chrome_roles_are_rgb_values() {
|
||||||
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
for (preset, p) in rgb_presets() {
|
||||||
for (name, color) in p.roles().into_iter().skip(16) {
|
for (name, color) in p.roles().into_iter().skip(16) {
|
||||||
assert!(
|
assert!(
|
||||||
relative_luminance(color).is_some(),
|
relative_luminance(color).is_some(),
|
||||||
@@ -487,6 +826,17 @@ mod tests {
|
|||||||
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (name, fg) in [
|
||||||
|
("card.fg", p.card.fg),
|
||||||
|
("card.muted", p.card.muted),
|
||||||
|
("group_row", p.group_row),
|
||||||
|
] {
|
||||||
|
let ratio = contrast_ratio(fg, p.sidebar_focus_bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= 4.5,
|
||||||
|
"{preset}.{name} contrast {ratio:.2} is below 4.5 on sidebar_focus_bg"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ impl Pane {
|
|||||||
|
|
||||||
// Place the terminal cursor at vt100's cursor position when we own
|
// Place the terminal cursor at vt100's cursor position when we own
|
||||||
// focus and the cursor is on-screen. Terminals blink it on their own.
|
// focus and the cursor is on-screen. Terminals blink it on their own.
|
||||||
if self.focused && !screen.hide_cursor() {
|
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
|
||||||
let (cr, cc) = screen.cursor_position();
|
let (cr, cc) = screen.cursor_position();
|
||||||
if cr < rows && cc < cols {
|
if cr < rows && cc < cols {
|
||||||
f.set_cursor_position((area.x + cc, area.y + cr));
|
f.set_cursor_position((area.x + cc, area.y + cr));
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
//! Top-level draw orchestration. Composes sidebar / tab bar / pane / overlays
|
//! Top-level draw orchestration. Composes sidebar / pane / overlays
|
||||||
//! into one frame, and returns a [`RegionMap`] that the input router uses to
|
//! into one frame, and returns a [`RegionMap`] that the input router uses to
|
||||||
//! hit-test mouse clicks.
|
//! hit-test mouse clicks.
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Modifier;
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
|
||||||
use crate::palette;
|
use crate::palette;
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
use crate::tabs::TabBar;
|
|
||||||
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
|
||||||
|
|
||||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||||
@@ -24,7 +22,6 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
|||||||
pub struct RegionMap {
|
pub struct RegionMap {
|
||||||
pub sidebar: Option<Rect>,
|
pub sidebar: Option<Rect>,
|
||||||
pub sidebar_items: Vec<SidebarItem>,
|
pub sidebar_items: Vec<SidebarItem>,
|
||||||
pub tab_bar: Option<Rect>,
|
|
||||||
pub pane: Option<Rect>,
|
pub pane: Option<Rect>,
|
||||||
pub modal: Option<Rect>,
|
pub modal: Option<Rect>,
|
||||||
}
|
}
|
||||||
@@ -36,11 +33,6 @@ impl RegionMap {
|
|||||||
return Region::Modal;
|
return Region::Modal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(r) = self.tab_bar {
|
|
||||||
if inside(r, col, row) {
|
|
||||||
return Region::Tab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(r) = self.sidebar {
|
if let Some(r) = self.sidebar {
|
||||||
if inside(r, col, row) {
|
if inside(r, col, row) {
|
||||||
return Region::SidebarRow;
|
return Region::SidebarRow;
|
||||||
@@ -58,7 +50,7 @@ impl RegionMap {
|
|||||||
/// occupies one inner row; groups occupy one row and visible cards two.
|
/// occupies one inner row; groups occupy one row and visible cards two.
|
||||||
pub fn sidebar_item_at(&self, row: u16) -> Option<SidebarItem> {
|
pub fn sidebar_item_at(&self, row: u16) -> Option<SidebarItem> {
|
||||||
let area = self.sidebar?;
|
let area = self.sidebar?;
|
||||||
let mut cursor = area.y.saturating_add(2);
|
let mut cursor = area.y.saturating_add(1);
|
||||||
for item in &self.sidebar_items {
|
for item in &self.sidebar_items {
|
||||||
let height = match item {
|
let height = match item {
|
||||||
SidebarItem::Group(_) => 1,
|
SidebarItem::Group(_) => 1,
|
||||||
@@ -83,14 +75,14 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
|
|||||||
pub struct RenderInputs<'a> {
|
pub struct RenderInputs<'a> {
|
||||||
pub sidebar: &'a Sidebar,
|
pub sidebar: &'a Sidebar,
|
||||||
pub home_dir: &'a str,
|
pub home_dir: &'a str,
|
||||||
pub tab_bar: &'a TabBar,
|
pub pane_title: &'a str,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
/// Inline spawn form when the pane is a draft (issue #63).
|
||||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Retained-worktree entries for the worktrees view (#85).
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
pub worktrees: Option<&'a WorktreesView>,
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
||||||
@@ -135,33 +127,24 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
.rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups);
|
.rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The pane always owns its full border, so focus changes only recolour
|
|
||||||
// chrome and never change the rectangles handed to the input router.
|
|
||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||||
.split(right);
|
.split(right);
|
||||||
let tab_area = rows[0];
|
let title_area = rows[0];
|
||||||
let pane_area = rows[1];
|
let pane_area = rows[1];
|
||||||
|
|
||||||
inputs.tab_bar.render(f, tab_area);
|
|
||||||
map.tab_bar = Some(tab_area);
|
|
||||||
|
|
||||||
let pane_focused = inputs.state.focus == Focus::Pane;
|
let pane_focused = inputs.state.focus == Focus::Pane;
|
||||||
let pane_block = Block::default()
|
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(focus_style(pane_focused));
|
|
||||||
let pane_inner = pane_block.inner(pane_area);
|
|
||||||
f.render_widget(pane_block, pane_area);
|
|
||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_inner);
|
p.render(f, pane_area);
|
||||||
}
|
}
|
||||||
if let Some(d) = inputs.draft {
|
if let Some(d) = inputs.draft {
|
||||||
d.render(f, pane_inner, pane_focused);
|
d.render(f, pane_area, pane_focused);
|
||||||
}
|
}
|
||||||
// `RegionMap::pane` is also the PTY resize source. Keep it aligned with
|
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||||
// the cells the emulator actually owns, excluding the persistent border.
|
// chrome; every remaining cell belongs to the emulator.
|
||||||
map.pane = Some(pane_inner);
|
map.pane = Some(pane_area);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
// always see the current mode + key hints.
|
// always see the current mode + key hints.
|
||||||
@@ -186,24 +169,25 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
kc.render(f, area);
|
kc.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Rename(id)) => {
|
||||||
inputs.help.render(f, area);
|
let current = inputs
|
||||||
|
.sidebar
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| &s.id == id)
|
||||||
|
.map(crate::sidebar::name_for)
|
||||||
|
.unwrap_or_else(|| id.to_string());
|
||||||
|
let prompt = RenamePrompt {
|
||||||
|
current: ¤t,
|
||||||
|
input: inputs.rename_input,
|
||||||
|
};
|
||||||
|
if let Some((x, y)) = prompt.render(f, area) {
|
||||||
|
f.set_cursor_position((x, y));
|
||||||
|
}
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
Some(OverlayKind::Worktrees) => {
|
Some(OverlayKind::Help) => {
|
||||||
if let Some(view) = inputs.worktrees {
|
inputs.help.render(f, area);
|
||||||
view.render(f, area);
|
|
||||||
map.modal = Some(area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(OverlayKind::ReclaimConfirm {
|
|
||||||
branch, blockers, ..
|
|
||||||
}) => {
|
|
||||||
let rc = ReclaimConfirm {
|
|
||||||
branch: branch.clone(),
|
|
||||||
blockers: blockers.clone(),
|
|
||||||
};
|
|
||||||
rc.render(f, area);
|
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
Some(OverlayKind::Toast(text)) => {
|
Some(OverlayKind::Toast(text)) => {
|
||||||
@@ -216,10 +200,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Border style for a panel based on whether it currently has focus. Both
|
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||||
/// forms are palette roles (`border_focused` / `border_unfocused`).
|
let palette = palette::active();
|
||||||
fn focus_style(focused: bool) -> Style {
|
let style = if focused {
|
||||||
palette::active().border_style(focused)
|
palette.mode_style(false)
|
||||||
|
} else {
|
||||||
|
palette.card.text_style()
|
||||||
|
}
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
f.render_widget(Paragraph::new(format!(" {title}")).style(style), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||||
@@ -236,7 +225,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
};
|
};
|
||||||
let hint = match state.mode {
|
let hint = match state.mode {
|
||||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||||
Mode::Insert => "^a: back to normal",
|
Mode::Insert => "^a: return to sidebar",
|
||||||
};
|
};
|
||||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||||
let line = ratatui::text::Line::from(vec![
|
let line = ratatui::text::Line::from(vec![
|
||||||
@@ -261,22 +250,15 @@ mod tests {
|
|||||||
height: 20,
|
height: 20,
|
||||||
}),
|
}),
|
||||||
sidebar_items: Vec::new(),
|
sidebar_items: Vec::new(),
|
||||||
tab_bar: Some(Rect {
|
pane: Some(Rect {
|
||||||
x: 10,
|
x: 10,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 3,
|
height: 20,
|
||||||
}),
|
|
||||||
pane: Some(Rect {
|
|
||||||
x: 10,
|
|
||||||
y: 3,
|
|
||||||
width: 30,
|
|
||||||
height: 17,
|
|
||||||
}),
|
}),
|
||||||
modal: None,
|
modal: None,
|
||||||
};
|
};
|
||||||
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
|
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
|
||||||
assert_eq!(map.region_at(15, 1), Region::Tab);
|
|
||||||
assert_eq!(map.region_at(15, 5), Region::Pane);
|
assert_eq!(map.region_at(15, 5), Region::Pane);
|
||||||
assert_eq!(map.region_at(50, 50), Region::Unknown);
|
assert_eq!(map.region_at(50, 50), Region::Unknown);
|
||||||
}
|
}
|
||||||
@@ -291,17 +273,11 @@ mod tests {
|
|||||||
height: 20,
|
height: 20,
|
||||||
}),
|
}),
|
||||||
sidebar_items: Vec::new(),
|
sidebar_items: Vec::new(),
|
||||||
tab_bar: Some(Rect {
|
pane: Some(Rect {
|
||||||
x: 10,
|
x: 10,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 3,
|
height: 20,
|
||||||
}),
|
|
||||||
pane: Some(Rect {
|
|
||||||
x: 10,
|
|
||||||
y: 3,
|
|
||||||
width: 30,
|
|
||||||
height: 17,
|
|
||||||
}),
|
}),
|
||||||
modal: Some(Rect {
|
modal: Some(Rect {
|
||||||
x: 5,
|
x: 5,
|
||||||
@@ -329,10 +305,10 @@ mod tests {
|
|||||||
sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b],
|
sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b],
|
||||||
..RegionMap::default()
|
..RegionMap::default()
|
||||||
};
|
};
|
||||||
assert_eq!(map.sidebar_item_at(2), Some(group_a));
|
assert_eq!(map.sidebar_item_at(1), Some(group_a));
|
||||||
assert_eq!(map.sidebar_item_at(3), Some(card_a.clone()));
|
assert_eq!(map.sidebar_item_at(2), Some(card_a.clone()));
|
||||||
assert_eq!(map.sidebar_item_at(4), Some(card_a));
|
assert_eq!(map.sidebar_item_at(3), Some(card_a));
|
||||||
assert_eq!(map.sidebar_item_at(5), Some(group_b));
|
assert_eq!(map.sidebar_item_at(4), Some(group_b));
|
||||||
|
|
||||||
let collapsed = RegionMap {
|
let collapsed = RegionMap {
|
||||||
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||||
@@ -344,7 +320,7 @@ mod tests {
|
|||||||
..RegionMap::default()
|
..RegionMap::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
collapsed.sidebar_item_at(3),
|
collapsed.sidebar_item_at(2),
|
||||||
Some(SidebarItem::Group(PathBuf::from("/b")))
|
Some(SidebarItem::Group(PathBuf::from("/b")))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -368,20 +344,16 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
};
|
};
|
||||||
let tab_bar = TabBar {
|
|
||||||
tab_names: vec!["1".into()],
|
|
||||||
active_idx: 0,
|
|
||||||
};
|
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
home_dir: "",
|
home_dir: "",
|
||||||
tab_bar: &tab_bar,
|
pane_title: "new session",
|
||||||
pane: None,
|
pane: None,
|
||||||
draft: Some(draft),
|
draft: Some(draft),
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -400,7 +372,7 @@ mod tests {
|
|||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
for s in [
|
for s in [
|
||||||
"draft",
|
"new session",
|
||||||
"cmd:",
|
"cmd:",
|
||||||
"dir:",
|
"dir:",
|
||||||
"name:",
|
"name:",
|
||||||
@@ -422,10 +394,6 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
};
|
};
|
||||||
let tab_bar = TabBar {
|
|
||||||
tab_names: vec!["1".into()],
|
|
||||||
active_idx: 0,
|
|
||||||
};
|
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let draw_map = |focus| {
|
let draw_map = |focus| {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
@@ -433,12 +401,12 @@ mod tests {
|
|||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
home_dir: "",
|
home_dir: "",
|
||||||
tab_bar: &tab_bar,
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
@@ -450,13 +418,12 @@ mod tests {
|
|||||||
let sidebar_focus = draw_map(Focus::Sidebar);
|
let sidebar_focus = draw_map(Focus::Sidebar);
|
||||||
let pane_focus = draw_map(Focus::Pane);
|
let pane_focus = draw_map(Focus::Pane);
|
||||||
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
|
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
|
||||||
assert_eq!(sidebar_focus.tab_bar, pane_focus.tab_bar);
|
|
||||||
assert_eq!(sidebar_focus.pane, pane_focus.pane);
|
assert_eq!(sidebar_focus.pane, pane_focus.pane);
|
||||||
assert_eq!(sidebar_focus.pane, Some(Rect::new(29, 2, 50, 20)));
|
assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_border_colours_follow_focus_without_restyling_the_pane() {
|
fn panel_focus_uses_backgrounds_without_borders() {
|
||||||
use crate::overlays::HelpOverlay;
|
use crate::overlays::HelpOverlay;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -466,10 +433,6 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: HashSet::new(),
|
attached: HashSet::new(),
|
||||||
};
|
};
|
||||||
let tab_bar = TabBar {
|
|
||||||
tab_names: vec!["1".into()],
|
|
||||||
active_idx: 0,
|
|
||||||
};
|
|
||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let render = |focus| {
|
let render = |focus| {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
@@ -477,12 +440,12 @@ mod tests {
|
|||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
home_dir: "",
|
home_dir: "",
|
||||||
tab_bar: &tab_bar,
|
pane_title: "session",
|
||||||
pane: None,
|
pane: None,
|
||||||
draft: None,
|
draft: None,
|
||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
@@ -497,12 +460,11 @@ mod tests {
|
|||||||
let sidebar_focused = render(Focus::Sidebar);
|
let sidebar_focused = render(Focus::Sidebar);
|
||||||
let pane_focused = render(Focus::Pane);
|
let pane_focused = render(Focus::Pane);
|
||||||
let p = palette::active();
|
let p = palette::active();
|
||||||
assert_eq!(sidebar_focused[(0, 0)].style().fg, Some(p.border_focused));
|
assert_eq!(sidebar_focused[(0, 0)].style().bg, Some(p.sidebar_focus_bg));
|
||||||
assert_eq!(pane_focused[(0, 0)].style().fg, Some(p.border_unfocused));
|
assert_eq!(pane_focused[(0, 0)].style().bg, Some(p.chrome_bg));
|
||||||
assert_eq!(
|
assert_eq!(sidebar_focused[(28, 0)].style().bg, Some(p.card.bg));
|
||||||
sidebar_focused[(28, 1)].style().fg,
|
assert_eq!(pane_focused[(28, 0)].style().bg, Some(p.mode_normal_bg));
|
||||||
Some(p.border_unfocused)
|
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
|
||||||
);
|
assert!(title.contains("session"), "missing title: {title:?}");
|
||||||
assert_eq!(pane_focused[(28, 1)].style().fg, Some(p.border_focused));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
/// Match the browser terminal's retained history without letting long-running
|
||||||
|
/// sessions grow the client without bound.
|
||||||
|
pub const SCROLLBACK_ROWS: usize = 5_000;
|
||||||
|
|
||||||
/// Input the dispatcher (or the resize tracker) pushes to a session task.
|
/// Input the dispatcher (or the resize tracker) pushes to a session task.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum SessionInput {
|
pub enum SessionInput {
|
||||||
@@ -103,7 +107,7 @@ impl SessionTask {
|
|||||||
/// up with `SessionInput::Resize` on every frame whose allocation changes.
|
/// up with `SessionInput::Resize` on every frame whose allocation changes.
|
||||||
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
|
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
|
||||||
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
||||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
|
||||||
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||||
|
|
||||||
let parser_bg = parser.clone();
|
let parser_bg = parser.clone();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use maestro_protocol::{ActivityState, Session, SessionStatus};
|
|||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -31,16 +31,17 @@ const META_LINE_AFTER_SECS: i64 = 30;
|
|||||||
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
||||||
const INDENT: &str = " ";
|
const INDENT: &str = " ";
|
||||||
const META_INDENT: &str = " ";
|
const META_INDENT: &str = " ";
|
||||||
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
|
||||||
const ACTIONS: &str = "⧉ ✕";
|
|
||||||
|
|
||||||
/// Cards use the selection surface only while the sidebar owns focus. An
|
/// Cards use the selection surface only while the sidebar owns focus. An
|
||||||
/// unfocused highlight remains an edge-marked cursor without claiming the
|
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||||
/// keyboard via a filled selection background.
|
/// keyboard via a filled selection background.
|
||||||
fn card_base(highlighted: bool, focused: bool) -> Style {
|
fn card_base(highlighted: bool, focused: bool) -> Style {
|
||||||
palette::active()
|
let palette = palette::active();
|
||||||
.surface(highlighted && focused)
|
let mut style = palette.surface(highlighted && focused).text_style();
|
||||||
.text_style()
|
if focused && !highlighted {
|
||||||
|
style = style.bg(palette.sidebar_focus_bg);
|
||||||
|
}
|
||||||
|
style
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renderable sidebar state.
|
/// Renderable sidebar state.
|
||||||
@@ -63,9 +64,8 @@ impl Sidebar {
|
|||||||
self.render_with_focus(f, area, false);
|
self.render_with_focus(f, area, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the panel with a persistent border. Focus changes its colour
|
/// Render the panel without a border. Focus is the panel background, so
|
||||||
/// and whether the highlighted card claims the selection surface, never
|
/// it remains obvious without spending rows or columns on chrome.
|
||||||
/// its geometry.
|
|
||||||
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
||||||
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
|
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
|
||||||
}
|
}
|
||||||
@@ -88,17 +88,21 @@ impl Sidebar {
|
|||||||
collapsed: &HashSet<PathBuf>,
|
collapsed: &HashSet<PathBuf>,
|
||||||
home: &str,
|
home: &str,
|
||||||
) {
|
) {
|
||||||
let border_style = palette::active().border_style(focused);
|
let palette = palette::active();
|
||||||
let block = Block::default()
|
let panel_bg = if focused {
|
||||||
.borders(Borders::ALL)
|
palette.sidebar_focus_bg
|
||||||
.border_style(border_style);
|
} else {
|
||||||
let inner = block.inner(area);
|
palette.chrome_bg
|
||||||
f.render_widget(block, area);
|
};
|
||||||
|
f.render_widget(Block::default().style(Style::default().bg(panel_bg)), area);
|
||||||
|
|
||||||
let width = inner.width as usize;
|
let width = area.width as usize;
|
||||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
||||||
"sessions",
|
"sessions",
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.bg(panel_bg)
|
||||||
|
.fg(palette.card.fg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
))];
|
))];
|
||||||
for group in self.groups(collapsed) {
|
for group in self.groups(collapsed) {
|
||||||
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
||||||
@@ -112,7 +116,7 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.render_widget(Paragraph::new(lines), inner);
|
f.render_widget(Paragraph::new(lines), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Items in exactly the order and height they occupy on screen.
|
/// Items in exactly the order and height they occupy on screen.
|
||||||
@@ -169,7 +173,14 @@ impl Sidebar {
|
|||||||
let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone()));
|
let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone()));
|
||||||
let palette = palette::active();
|
let palette = palette::active();
|
||||||
let surface = palette.surface(highlighted && focused);
|
let surface = palette.surface(highlighted && focused);
|
||||||
let base = Style::default().bg(surface.bg).fg(if highlighted {
|
let row_bg = if highlighted {
|
||||||
|
surface.bg
|
||||||
|
} else if focused {
|
||||||
|
palette.sidebar_focus_bg
|
||||||
|
} else {
|
||||||
|
surface.bg
|
||||||
|
};
|
||||||
|
let base = Style::default().bg(row_bg).fg(if highlighted {
|
||||||
surface.fg
|
surface.fg
|
||||||
} else {
|
} else {
|
||||||
palette.group_row
|
palette.group_row
|
||||||
@@ -189,11 +200,14 @@ impl Sidebar {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let chevron = Span::styled(chevron, base);
|
let chevron = Span::styled(chevron, base);
|
||||||
let count = Span::styled(count, surface.muted_style());
|
let count = Span::styled(count, surface.muted_style().bg(row_bg));
|
||||||
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
|
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
|
||||||
let mut label = Vec::new();
|
let mut label = Vec::new();
|
||||||
if let Some(stem) = name.stem {
|
if let Some(stem) = name.stem {
|
||||||
label.push(Span::styled(format!("{stem}/"), surface.dim_style()));
|
label.push(Span::styled(
|
||||||
|
format!("{stem}/"),
|
||||||
|
surface.dim_style().bg(row_bg),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
label.push(Span::styled(name.last, base));
|
label.push(Span::styled(name.last, base));
|
||||||
|
|
||||||
@@ -211,20 +225,8 @@ impl Sidebar {
|
|||||||
};
|
};
|
||||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
let base = card_base(highlighted, focused);
|
let base = card_base(highlighted, focused);
|
||||||
// Line-1 actions are keyboard affordances shown on the highlighted
|
|
||||||
// row only; they reserve their width so the name truncates with `…`
|
|
||||||
// before it can ever reach them (docs/SIDEBAR.md).
|
|
||||||
let actions_width = if highlighted {
|
|
||||||
ACTIONS.chars().count()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let usable = width.saturating_sub(actions_width);
|
|
||||||
let prefix = INDENT.chars().count() + 2;
|
let prefix = INDENT.chars().count() + 2;
|
||||||
// Keep one column of separation between a truncated name and the
|
let name_budget = width.saturating_sub(prefix);
|
||||||
// actions so the ellipsis never touches them.
|
|
||||||
let sep = if highlighted { 1 } else { 0 };
|
|
||||||
let name_budget = usable.saturating_sub(prefix + sep);
|
|
||||||
|
|
||||||
let edge = highlighted && !focused;
|
let edge = highlighted && !focused;
|
||||||
let mut spans: Vec<Span<'static>> = vec![
|
let mut spans: Vec<Span<'static>> = vec![
|
||||||
@@ -243,14 +245,7 @@ impl Sidebar {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
|
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
|
||||||
);
|
);
|
||||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
pad_to_width(spans, width, base)
|
||||||
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
|
||||||
spans.push(Span::styled(" ".repeat(fill), base));
|
|
||||||
}
|
|
||||||
if highlighted {
|
|
||||||
spans.push(Span::styled(ACTIONS, base));
|
|
||||||
}
|
|
||||||
spans
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
||||||
@@ -268,7 +263,8 @@ impl Sidebar {
|
|||||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||||
let muted = palette::active()
|
let muted = palette::active()
|
||||||
.surface(highlighted && focused)
|
.surface(highlighted && focused)
|
||||||
.muted_style();
|
.muted_style()
|
||||||
|
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||||
let edge = highlighted && !focused;
|
let edge = highlighted && !focused;
|
||||||
let mut spans = vec![
|
let mut spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
@@ -385,11 +381,16 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `metadata.name` if present, else the command line.
|
/// `metadata.name` if present, else the command line.
|
||||||
fn name_for(s: &Session) -> String {
|
pub(crate) fn name_for(s: &Session) -> String {
|
||||||
if let Some(serde_json::Value::String(name)) = s.metadata.get("name") {
|
custom_name_for(s).unwrap_or_else(|| s.command.clone())
|
||||||
name.clone()
|
}
|
||||||
} else {
|
|
||||||
s.command.clone()
|
/// The session's own name, without the command fallback (#125): `None` for a
|
||||||
|
/// session that was never given one.
|
||||||
|
pub(crate) fn custom_name_for(s: &Session) -> Option<String> {
|
||||||
|
match s.metadata.get("name") {
|
||||||
|
Some(serde_json::Value::String(name)) => Some(name.clone()),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,10 +588,10 @@ mod tests {
|
|||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sidebar, 40, 8));
|
let all = rows(&draw(&sidebar, 40, 8));
|
||||||
assert!(all[2].contains("/tmp"));
|
assert!(all[1].contains("/tmp"));
|
||||||
assert!(all[3].contains("claude"));
|
assert!(all[2].contains("claude"));
|
||||||
assert!(all[4].contains("bash"));
|
assert!(all[3].contains("bash"));
|
||||||
assert!(!all[5].contains("bash"));
|
assert!(!all[4].contains("bash"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -680,20 +681,20 @@ mod tests {
|
|||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
assert_eq!(content(&all[1]), "sessions");
|
assert_eq!(content(&all[0]), "sessions");
|
||||||
assert!(row_containing(&all, "claude").contains('○'));
|
assert!(row_containing(&all, "claude").contains('○'));
|
||||||
assert!(
|
assert!(
|
||||||
all[4].contains('●'),
|
all[3].contains('●'),
|
||||||
"line 2 must carry the dot: {:?}",
|
"line 2 must carry the dot: {:?}",
|
||||||
all[3]
|
all[3]
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
all[4].contains("bash"),
|
all[3].contains("bash"),
|
||||||
"line 2 must carry the command: {:?}",
|
"line 2 must carry the command: {:?}",
|
||||||
all[3]
|
all[3]
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!all[4].contains("10s"),
|
!all[3].contains("10s"),
|
||||||
"fresh cards have no duration: {:?}",
|
"fresh cards have no duration: {:?}",
|
||||||
all[3]
|
all[3]
|
||||||
);
|
);
|
||||||
@@ -1015,12 +1016,12 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 40, 6));
|
let all = rows(&draw(&sb, 40, 6));
|
||||||
assert!(
|
assert!(
|
||||||
all[3].contains("friendly name"),
|
all[2].contains("friendly name"),
|
||||||
"name must stay on line 1: {:?}",
|
"name must stay on line 1: {:?}",
|
||||||
all[2]
|
all[2]
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
all[4].contains("bash"),
|
all[3].contains("bash"),
|
||||||
"command must be on line 2: {:?}",
|
"command must be on line 2: {:?}",
|
||||||
all[3]
|
all[3]
|
||||||
);
|
);
|
||||||
@@ -1034,14 +1035,14 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 10, 6));
|
let all = rows(&draw(&sb, 9, 6));
|
||||||
assert!(
|
assert!(
|
||||||
all[4].contains('●'),
|
all[3].contains('●'),
|
||||||
"dot must survive narrow cards: {:?}",
|
"dot must survive narrow cards: {:?}",
|
||||||
all[3]
|
all[3]
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
all[4].contains('…'),
|
all[3].contains('…'),
|
||||||
"command should truncate: {:?}",
|
"command should truncate: {:?}",
|
||||||
all[3]
|
all[3]
|
||||||
);
|
);
|
||||||
@@ -1061,8 +1062,8 @@ mod tests {
|
|||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 29, 6));
|
let all = rows(&draw(&sb, 29, 6));
|
||||||
assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
|
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||||
let meta = content(&all[4]);
|
let meta = content(&all[3]);
|
||||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,13 +1081,13 @@ mod tests {
|
|||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 40, 6));
|
let all = rows(&draw(&sb, 40, 6));
|
||||||
assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
|
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||||
assert!(
|
assert!(
|
||||||
all[4].contains("bash"),
|
all[3].contains("bash"),
|
||||||
"command must survive: {:?}",
|
"command must survive: {:?}",
|
||||||
all[3]
|
all[3]
|
||||||
);
|
);
|
||||||
assert!(all[4].contains('…'), "detail must truncate: {:?}", all[4]);
|
assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reversed video makes a card unreadable against a background; cards
|
/// Reversed video makes a card unreadable against a background; cards
|
||||||
@@ -1114,8 +1115,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The highlighted card gets the focus background; every other card gets
|
/// The highlighted card gets the selection background; the rest of the
|
||||||
/// a card background distinct from the panel's default.
|
/// focused sidebar shares its subtle panel background.
|
||||||
#[test]
|
#[test]
|
||||||
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
|
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
|
||||||
let one = session_named(1, "claude");
|
let one = session_named(1, "claude");
|
||||||
@@ -1143,13 +1144,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf[(1, bash_y as u16)].style().bg,
|
buf[(1, bash_y as u16)].style().bg,
|
||||||
Some(p.card.bg),
|
Some(p.sidebar_focus_bg),
|
||||||
"unfocused cards must still sit on the card surface"
|
"ordinary cards must share the focused panel surface"
|
||||||
);
|
);
|
||||||
// The panel itself (header row) stays on the terminal default.
|
assert_eq!(
|
||||||
assert!(
|
buf[(1, 0)].style().bg,
|
||||||
matches!(buf[(1, 1)].style().bg, None | Some(Color::Reset)),
|
Some(p.sidebar_focus_bg),
|
||||||
"panel must keep the terminal default background"
|
"the focus background must cover the whole sidebar"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,15 +1205,14 @@ mod tests {
|
|||||||
for x in 1..27 {
|
for x in 1..27 {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf[(x, y)].style().bg,
|
buf[(x, y)].style().bg,
|
||||||
Some(palette::active().card.bg),
|
Some(palette::active().sidebar_focus_bg),
|
||||||
"gap in card background at col {x}"
|
"gap in card background at col {x}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Line 1's actions live on the highlighted row's top-right corner.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn highlighted_row_anchors_the_actions_top_right() {
|
fn highlighted_rows_do_not_show_action_glyphs() {
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![session_named(1, "claude")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
@@ -1221,8 +1221,8 @@ mod tests {
|
|||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let line = content(row_containing(&all, "claude"));
|
let line = content(row_containing(&all, "claude"));
|
||||||
assert!(
|
assert!(
|
||||||
line.ends_with("⧉ ✕"),
|
!line.contains('⧉') && !line.contains('✕'),
|
||||||
"actions must sit at the right edge: {line:?}"
|
"keyboard-only actions should not consume card space: {line:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1243,10 +1243,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name truncates with `…` before it can ever reach the reserved
|
|
||||||
/// action width (docs/SIDEBAR.md — overlap is a defect).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn name_truncates_before_reaching_the_actions() {
|
fn long_name_uses_the_full_card_width() {
|
||||||
let mut s = session_named(1, "claude");
|
let mut s = session_named(1, "claude");
|
||||||
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
|
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
|
||||||
*name = "an-unreasonably-long-session-name".into();
|
*name = "an-unreasonably-long-session-name".into();
|
||||||
@@ -1258,12 +1256,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
let line = row_containing(&all, "an-unreasonably");
|
let line = row_containing(&all, "an-unreasonably");
|
||||||
let name_end = line.find('…').expect("name must truncate with …");
|
assert!(line.contains('…'), "name must truncate with …: {line:?}");
|
||||||
let actions_start = line.find('⧉').expect("actions must still render");
|
|
||||||
assert!(name_end < actions_start, "name overlaps actions: {line:?}");
|
|
||||||
assert!(
|
assert!(
|
||||||
line.split_at(actions_start).0.ends_with(' '),
|
!line.contains('⧉') && !line.contains('✕'),
|
||||||
"no gap before the actions: {line:?}"
|
"actions leaked in: {line:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-182
@@ -1,8 +1,7 @@
|
|||||||
//! `AppState` — the top-level mutable state of the running TUI.
|
//! `AppState` — the top-level mutable state of the running TUI.
|
||||||
//!
|
//!
|
||||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
//! v1 keeps it small on purpose: one pane containing a session or draft form,
|
||||||
//! session or a draft form), an active tab index, focus split between sidebar
|
//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm,
|
||||||
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
|
||||||
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
||||||
//! pane is a draft pane (issue #63).
|
//! pane is a draft pane (issue #63).
|
||||||
//!
|
//!
|
||||||
@@ -33,7 +32,7 @@ pub enum SidebarItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||||
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough
|
||||||
// that boxing every access costs more than the copy does.
|
// that boxing every access costs more than the copy does.
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -61,19 +60,11 @@ impl Tab {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OverlayKind {
|
pub enum OverlayKind {
|
||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
|
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
|
||||||
|
/// so this stays comparable.
|
||||||
|
Rename(Uuid),
|
||||||
Help,
|
Help,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
/// The retained-worktrees view (#85); its entries live on `App` because
|
|
||||||
/// they are daemon data, not UI state.
|
|
||||||
Worktrees,
|
|
||||||
/// Reclaim confirmation for the worktree with this id (#85), guarding
|
|
||||||
/// the destructive key like [`OverlayKind::KillConfirm`] does.
|
|
||||||
ReclaimConfirm {
|
|
||||||
id: Uuid,
|
|
||||||
branch: String,
|
|
||||||
/// [`maestro_protocol::InTheWay`] as of when the confirm was opened.
|
|
||||||
blockers: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -143,57 +134,6 @@ impl AppState {
|
|||||||
&mut self.tabs[self.active_tab_idx]
|
&mut self.tabs[self.active_tab_idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a new tab and switch to it. Sidebar regains focus so the user
|
|
||||||
/// can pick something to attach.
|
|
||||||
pub fn new_tab(&mut self) {
|
|
||||||
let name = format!("{}", self.tabs.len() + 1);
|
|
||||||
self.tabs.push(Tab { name, pane: None });
|
|
||||||
self.active_tab_idx = self.tabs.len() - 1;
|
|
||||||
self.focus = Focus::Sidebar;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop the active tab. No-op if it's the only one — there must always
|
|
||||||
/// be at least one tab.
|
|
||||||
///
|
|
||||||
/// Returns the session that tab was showing, if any, so the caller can
|
|
||||||
/// release its emulator. Without that the tab's WebSocket task and vt100
|
|
||||||
/// parser outlive the tab forever. A draft pane holds no session and
|
|
||||||
/// releases nothing.
|
|
||||||
pub fn close_tab(&mut self) -> Option<Uuid> {
|
|
||||||
if self.tabs.len() <= 1 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let closed = self.tabs.remove(self.active_tab_idx);
|
|
||||||
if self.active_tab_idx >= self.tabs.len() {
|
|
||||||
self.active_tab_idx = self.tabs.len() - 1;
|
|
||||||
}
|
|
||||||
let sid = closed.session()?;
|
|
||||||
// Only release it if no other tab still shows the same session.
|
|
||||||
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
|
||||||
(!still_shown).then_some(sid)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_tab(&mut self) {
|
|
||||||
if self.tabs.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_tab(&mut self) {
|
|
||||||
if self.tabs.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn goto_tab(&mut self, n: u8) {
|
|
||||||
let idx = (n as usize)
|
|
||||||
.saturating_sub(1)
|
|
||||||
.min(self.tabs.len().saturating_sub(1));
|
|
||||||
self.active_tab_idx = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind a session to the active tab, move focus into the pane, and
|
/// Bind a session to the active tab, move focus into the pane, and
|
||||||
/// drop straight into Insert mode so keystrokes go to the session
|
/// drop straight into Insert mode so keystrokes go to the session
|
||||||
/// without an extra `i` press. The leader returns to Normal.
|
/// without an extra `i` press. The leader returns to Normal.
|
||||||
@@ -203,14 +143,10 @@ impl AppState {
|
|||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
/// Replace the pane with a draft form. Focus lands in the pane, Insert
|
||||||
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
/// mode on, so typing starts immediately, same flow as
|
||||||
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
/// [`Self::attach_to_focused`].
|
||||||
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
|
||||||
pub fn open_draft_pane(&mut self) {
|
pub fn open_draft_pane(&mut self) {
|
||||||
if self.active_tab().pane.is_some() {
|
|
||||||
self.new_tab();
|
|
||||||
}
|
|
||||||
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||||
self.focus = Focus::Pane;
|
self.focus = Focus::Pane;
|
||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
@@ -282,66 +218,6 @@ mod tests {
|
|||||||
assert!(state.overlay.is_none());
|
assert!(state.overlay.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_tab_extends_and_focuses() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 2);
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
assert_eq!(state.focus, Focus::Sidebar);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_tab_keeps_at_least_one() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.close_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 1);
|
|
||||||
state.new_tab();
|
|
||||||
state.close_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_tab_advances_when_last_is_closed() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
state.new_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 2);
|
|
||||||
state.close_tab();
|
|
||||||
assert_eq!(state.tabs.len(), 2);
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn next_prev_tab_wraps() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_idx = 0;
|
|
||||||
state.next_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
state.next_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 2);
|
|
||||||
state.next_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 0);
|
|
||||||
state.prev_tab();
|
|
||||||
assert_eq!(state.active_tab_idx, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn goto_tab_uses_one_based_clamp() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.new_tab();
|
|
||||||
state.new_tab();
|
|
||||||
state.goto_tab(1);
|
|
||||||
assert_eq!(state.active_tab_idx, 0);
|
|
||||||
state.goto_tab(2);
|
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
|
||||||
state.goto_tab(99);
|
|
||||||
// Clamped to last available tab.
|
|
||||||
assert_eq!(state.active_tab_idx, state.tabs.len() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_sidebar_highlight_walks_order_and_wraps() {
|
fn move_sidebar_highlight_walks_order_and_wraps() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
@@ -421,38 +297,6 @@ mod tests {
|
|||||||
assert_eq!(state.focus, Focus::Pane);
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The emulator leak: a closed tab's session must come back so the caller
|
|
||||||
/// can abort its WebSocket task.
|
|
||||||
#[test]
|
|
||||||
fn close_tab_reports_the_session_it_dropped() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
let sid = Uuid::from_u128(3);
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
||||||
|
|
||||||
assert_eq!(state.close_tab(), Some(sid));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ...but not while another tab still shows it.
|
|
||||||
#[test]
|
|
||||||
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
let sid = Uuid::from_u128(4);
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
||||||
|
|
||||||
assert_eq!(state.close_tab(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn close_tab_is_a_no_op_on_the_last_tab() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
|
||||||
assert_eq!(state.close_tab(), None);
|
|
||||||
assert_eq!(state.tabs.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dismiss_overlay_reports_whether_one_was_showing() {
|
fn dismiss_overlay_reports_whether_one_was_showing() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
@@ -474,18 +318,14 @@ mod tests {
|
|||||||
assert_eq!(state.tabs.len(), 1);
|
assert_eq!(state.tabs.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tab already showing a session gets a fresh tab for the draft; v1
|
|
||||||
/// has no split tree, so a new tab is the "split".
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_draft_over_a_session_opens_a_new_tab() {
|
fn open_draft_over_a_session_reuses_the_pane() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
state.attach_to_focused(Uuid::from_u128(9));
|
state.attach_to_focused(Uuid::from_u128(9));
|
||||||
state.open_draft_pane();
|
state.open_draft_pane();
|
||||||
assert_eq!(state.tabs.len(), 2);
|
assert_eq!(state.tabs.len(), 1);
|
||||||
assert_eq!(state.active_tab_idx, 1);
|
assert_eq!(state.active_tab_idx, 0);
|
||||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||||
// The session tab is untouched.
|
|
||||||
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web parity (#57): submitting fills the pane in place.
|
/// Web parity (#57): submitting fills the pane in place.
|
||||||
@@ -499,14 +339,4 @@ mod tests {
|
|||||||
assert_eq!(state.focus, Focus::Pane);
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
assert_eq!(state.mode, Mode::Insert);
|
assert_eq!(state.mode, Mode::Insert);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A closed draft tab holds no session, so nothing is released.
|
|
||||||
#[test]
|
|
||||||
fn close_tab_reports_nothing_for_a_draft_pane() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.open_draft_pane();
|
|
||||||
state.new_tab();
|
|
||||||
state.active_tab_idx = 0;
|
|
||||||
assert_eq!(state.close_tab(), None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
//! Tab-bar widget — top row of the main area.
|
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::style::{Modifier, Style};
|
|
||||||
use ratatui::text::Line;
|
|
||||||
use ratatui::widgets::Tabs;
|
|
||||||
use ratatui::Frame;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TabBar {
|
|
||||||
pub tab_names: Vec<String>,
|
|
||||||
pub active_idx: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TabBar {
|
|
||||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
|
||||||
let titles: Vec<Line> = self
|
|
||||||
.tab_names
|
|
||||||
.iter()
|
|
||||||
.map(|n| Line::from(n.as_str()))
|
|
||||||
.collect();
|
|
||||||
let tabs = Tabs::new(titles)
|
|
||||||
.select(self.active_idx)
|
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
|
||||||
f.render_widget(tabs, area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use ratatui::backend::TestBackend;
|
|
||||||
use ratatui::Terminal;
|
|
||||||
|
|
||||||
fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String {
|
|
||||||
let backend = TestBackend::new(w, h);
|
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
|
||||||
terminal.draw(|f| bar.render(f, f.area())).unwrap();
|
|
||||||
let buf = terminal.backend().buffer().clone();
|
|
||||||
let mut out = String::new();
|
|
||||||
for y in 0..buf.area.height {
|
|
||||||
for x in 0..buf.area.width {
|
|
||||||
out.push_str(buf[(x, y)].symbol());
|
|
||||||
}
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_three_tab_names() {
|
|
||||||
let bar = TabBar {
|
|
||||||
tab_names: vec!["1".into(), "2".into(), "3".into()],
|
|
||||||
active_idx: 1,
|
|
||||||
};
|
|
||||||
let rendered = render_to_string(&bar, 40, 1);
|
|
||||||
assert!(rendered.contains("1"));
|
|
||||||
assert!(rendered.contains("2"));
|
|
||||||
assert!(rendered.contains("3"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
//! Retained-worktrees view (#85): an overlay listing every retained worktree
|
|
||||||
//! — branch, abbreviated path, reason, what is in the way — with j/k to move,
|
|
||||||
//! `r` to reclaim behind a confirmation, esc to close.
|
|
||||||
//!
|
|
||||||
//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl,
|
|
||||||
//! the same source the kill refusal and the web client use, so no two clients
|
|
||||||
//! can describe the same tree differently.
|
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::text::Line;
|
|
||||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
|
||||||
use ratatui::Frame;
|
|
||||||
|
|
||||||
use maestro_protocol::{RetainedReason, RetainedWorktree};
|
|
||||||
|
|
||||||
use crate::draft::abbreviate_path;
|
|
||||||
|
|
||||||
/// The open worktrees overlay: its entries and which one is highlighted.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct WorktreesView {
|
|
||||||
pub items: Vec<RetainedWorktree>,
|
|
||||||
pub highlight: usize,
|
|
||||||
/// Daemon-reported home dir, for `~` abbreviation of paths.
|
|
||||||
pub home_dir: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorktreesView {
|
|
||||||
/// Replace the listing and keep the highlight pointing at something real.
|
|
||||||
pub fn apply(&mut self, items: Vec<RetainedWorktree>) {
|
|
||||||
self.items = items;
|
|
||||||
if !self.items.is_empty() {
|
|
||||||
self.highlight = self.highlight.min(self.items.len() - 1);
|
|
||||||
} else {
|
|
||||||
self.highlight = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, id: uuid::Uuid) {
|
|
||||||
let idx = self.items.iter().position(|w| w.id == id);
|
|
||||||
self.items.retain(|w| w.id != id);
|
|
||||||
if let Some(i) = idx {
|
|
||||||
self.highlight = self.highlight.min(self.items.len().saturating_sub(1));
|
|
||||||
let _ = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the highlight by `delta` (positive = down). Wraps. No-op on empty.
|
|
||||||
pub fn move_highlight(&mut self, delta: i32) {
|
|
||||||
if self.items.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let len = self.items.len() as i32;
|
|
||||||
let next = (self.highlight as i32 + delta).rem_euclid(len);
|
|
||||||
self.highlight = next as usize;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selected(&self) -> Option<&RetainedWorktree> {
|
|
||||||
self.items.get(self.highlight)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
|
||||||
let h = (self.items.len() as u16 + 6).clamp(4, parent.height);
|
|
||||||
let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h);
|
|
||||||
f.render_widget(Clear, area);
|
|
||||||
let block = Block::default()
|
|
||||||
.title(" retained worktrees ")
|
|
||||||
.borders(Borders::ALL);
|
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
if self.items.is_empty() {
|
|
||||||
// A drained store is the normal state — say so plainly, not like
|
|
||||||
// something went wrong.
|
|
||||||
lines.push(Line::from("no retained worktrees"));
|
|
||||||
} else {
|
|
||||||
lines.push(Line::from(format!(
|
|
||||||
" {:<26} {:<34} {:<24} IN THE WAY",
|
|
||||||
"BRANCH", "PATH", "REASON"
|
|
||||||
)));
|
|
||||||
for (i, w) in self.items.iter().enumerate() {
|
|
||||||
let marker = if i == self.highlight { ">" } else { " " };
|
|
||||||
lines.push(Line::from(format!(
|
|
||||||
"{marker} {:<25} {:<34} {:<24} {}",
|
|
||||||
w.branch,
|
|
||||||
abbreviate_path(&w.path, &self.home_dir),
|
|
||||||
reason_label(w.reason),
|
|
||||||
w.in_the_way,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
lines.push(Line::from("j/k: select r: reclaim esc: close"));
|
|
||||||
f.render_widget(Paragraph::new(lines), inner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`].
|
|
||||||
/// It names the branch and says out loud what reclaim does not take with it —
|
|
||||||
/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ReclaimConfirm {
|
|
||||||
pub branch: String,
|
|
||||||
/// [`maestro_protocol::InTheWay`] rendered via its `Display` impl.
|
|
||||||
pub blockers: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReclaimConfirm {
|
|
||||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
|
||||||
let area = super::overlays::centered(parent, 70, 7);
|
|
||||||
f.render_widget(Clear, area);
|
|
||||||
let block = Block::default().title(" reclaim ").borders(Borders::ALL);
|
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
f.render_widget(
|
|
||||||
ratatui::widgets::Paragraph::new(vec![
|
|
||||||
Line::from(format!("Reclaim worktree {}?", self.branch)),
|
|
||||||
Line::from(format!("Currently in the way: {}", self.blockers)),
|
|
||||||
Line::from("The directory is removed; the branch is never deleted."),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("[y] yes [n] no"),
|
|
||||||
]),
|
|
||||||
inner,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reason_label(reason: RetainedReason) -> &'static str {
|
|
||||||
match reason {
|
|
||||||
RetainedReason::ForceKilled => "force-killed",
|
|
||||||
RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::{TimeZone, Utc};
|
|
||||||
use maestro_protocol::{InTheWay, RetainedReason};
|
|
||||||
use ratatui::backend::TestBackend;
|
|
||||||
use ratatui::Terminal;
|
|
||||||
|
|
||||||
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut Frame),
|
|
||||||
{
|
|
||||||
let backend = TestBackend::new(w, h);
|
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
|
||||||
terminal.draw(draw).unwrap();
|
|
||||||
let buf = terminal.backend().buffer().clone();
|
|
||||||
let mut out = String::new();
|
|
||||||
for y in 0..buf.area.height {
|
|
||||||
for x in 0..buf.area.width {
|
|
||||||
out.push_str(buf[(x, y)].symbol());
|
|
||||||
}
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
|
||||||
RetainedWorktree {
|
|
||||||
id: uuid::Uuid::from_u128(id),
|
|
||||||
path: path.into(),
|
|
||||||
base_repo: "/home/naps/tea/maestro".into(),
|
|
||||||
branch: branch.into(),
|
|
||||||
reason: RetainedReason::ForceKilled,
|
|
||||||
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
|
||||||
in_the_way: InTheWay::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn view_lists_branch_path_reason_and_what_is_in_the_way() {
|
|
||||||
let mut view = WorktreesView {
|
|
||||||
home_dir: "/home/naps".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut wt = worktree(
|
|
||||||
"feat/login",
|
|
||||||
"/home/naps/tea/maestro/worktrees/feat/login",
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
wt.in_the_way = InTheWay {
|
|
||||||
uncommitted_files: 2,
|
|
||||||
untracked_files: 1,
|
|
||||||
unpushed_commits: 3,
|
|
||||||
};
|
|
||||||
view.apply(vec![wt]);
|
|
||||||
|
|
||||||
let rendered = render_to_string(160, 20, |f| view.render(f, f.area()));
|
|
||||||
assert!(rendered.contains("feat/login"), "{rendered}");
|
|
||||||
// Path abbreviated against the daemon's home dir, same helper as the
|
|
||||||
// draft pane's project list.
|
|
||||||
assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}");
|
|
||||||
assert!(rendered.contains("force-killed"), "{rendered}");
|
|
||||||
// Phrasing comes from InTheWay's Display impl — shared with the kill
|
|
||||||
// refusal and the web client.
|
|
||||||
assert!(
|
|
||||||
rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"),
|
|
||||||
"{rendered}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_store_looks_like_a_normal_state_not_an_error() {
|
|
||||||
let view = WorktreesView::default();
|
|
||||||
let rendered = render_to_string(80, 12, |f| view.render(f, f.area()));
|
|
||||||
assert!(rendered.contains("no retained worktrees"), "{rendered}");
|
|
||||||
assert!(!rendered.contains("error"), "{rendered}");
|
|
||||||
assert!(!rendered.contains("failed"), "{rendered}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlight_moves_and_wraps() {
|
|
||||||
let mut view = WorktreesView::default();
|
|
||||||
view.apply(vec![
|
|
||||||
worktree("a", "/r/a", 1),
|
|
||||||
worktree("b", "/r/b", 2),
|
|
||||||
worktree("c", "/r/c", 3),
|
|
||||||
]);
|
|
||||||
assert_eq!(view.selected().unwrap().branch, "a");
|
|
||||||
view.move_highlight(1);
|
|
||||||
assert_eq!(view.selected().unwrap().branch, "b");
|
|
||||||
view.move_highlight(-1);
|
|
||||||
assert_eq!(view.selected().unwrap().branch, "a");
|
|
||||||
view.move_highlight(-1);
|
|
||||||
// Wraps backward to the last entry.
|
|
||||||
assert_eq!(view.selected().unwrap().branch, "c");
|
|
||||||
view.move_highlight(1);
|
|
||||||
assert_eq!(view.selected().unwrap().branch, "a");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_clamps_a_stale_highlight() {
|
|
||||||
let mut view = WorktreesView::default();
|
|
||||||
view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]);
|
|
||||||
view.move_highlight(1);
|
|
||||||
view.apply(vec![worktree("c", "/r/c", 3)]);
|
|
||||||
assert_eq!(view.selected().unwrap().branch, "c");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reclaim_confirm_names_the_branch_and_says_it_survives() {
|
|
||||||
let confirm = ReclaimConfirm {
|
|
||||||
branch: "feat/login".into(),
|
|
||||||
blockers: "2 uncommitted files".into(),
|
|
||||||
};
|
|
||||||
let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area()));
|
|
||||||
assert!(
|
|
||||||
rendered.contains("Reclaim worktree feat/login?"),
|
|
||||||
"{rendered}"
|
|
||||||
);
|
|
||||||
assert!(rendered.contains("2 uncommitted files"), "{rendered}");
|
|
||||||
assert!(rendered.contains("never deleted"), "{rendered}");
|
|
||||||
assert!(rendered.contains("yes"), "{rendered}");
|
|
||||||
assert!(rendered.contains("no"), "{rendered}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+22
-5
@@ -134,19 +134,20 @@ cover every v1 action — only override what you want to change:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[tui]
|
[tui]
|
||||||
theme = "dark" # dark (default), light, or ansi
|
theme = "dark" # see the theme list below
|
||||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
leader = "ctrl-a" # one byte that escapes Insert mode
|
||||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||||
|
|
||||||
[tui.keymap.normal]
|
[tui.keymap.normal]
|
||||||
"i" = "EnterInsert"
|
"i" = "EnterInsert"
|
||||||
"s" = "OpenSpawnModal"
|
"s" = "NewDraftPane"
|
||||||
"x" = "KillHighlighted"
|
"x" = "KillHighlighted"
|
||||||
|
"r" = "RenameHighlighted"
|
||||||
"?" = "ShowHelp"
|
"?" = "ShowHelp"
|
||||||
"ctrl-t" = "NewTab"
|
|
||||||
"g t" = "NextTab"
|
|
||||||
"ctrl-w h" = "FocusSidebar"
|
"ctrl-w h" = "FocusSidebar"
|
||||||
"enter" = "AttachHighlighted"
|
"enter" = "AttachHighlighted"
|
||||||
|
"pageup" = "ScrollPanePageUp"
|
||||||
|
"pagedown" = "ScrollPanePageDown"
|
||||||
|
|
||||||
[tui.keymap.insert]
|
[tui.keymap.insert]
|
||||||
# (leader is added automatically as LeaveInsert; only override here when you
|
# (leader is added automatically as LeaveInsert; only override here when you
|
||||||
@@ -156,6 +157,22 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
|||||||
`theme` selects the complete TUI palette. `dark` is the default and follows
|
`theme` selects the complete TUI palette. `dark` is the default and follows
|
||||||
Maestro's terminal-native dark product palette; `light` is its light peer;
|
Maestro's terminal-native dark product palette; `light` is its light peer;
|
||||||
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
|
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
|
||||||
|
The remaining presets adapt well-known editor themes, with individual colours
|
||||||
|
lifted or darkened where the originals fall short of the WCAG AA contrast
|
||||||
|
floors the palette tests assert:
|
||||||
|
|
||||||
|
| name | based on |
|
||||||
|
|-------------------|------------------|
|
||||||
|
| `solarized-dark` | Solarized Dark |
|
||||||
|
| `solarized-light` | Solarized Light |
|
||||||
|
| `catppuccin` | Catppuccin Mocha |
|
||||||
|
| `github-dark` | GitHub Dark |
|
||||||
|
| `github-light` | GitHub Light |
|
||||||
|
| `tokyo-night` | Tokyo Night |
|
||||||
|
| `gruvbox` | Gruvbox Dark |
|
||||||
|
| `nord` | Nord |
|
||||||
|
| `dracula` | Dracula |
|
||||||
|
|
||||||
An unknown name is rejected as an `[tui].theme` configuration error.
|
An unknown name is rejected as an `[tui].theme` configuration error.
|
||||||
|
|
||||||
The full action vocabulary is the `Action` enum in
|
The full action vocabulary is the `Action` enum in
|
||||||
|
|||||||
+13
-8
@@ -7,21 +7,26 @@ written here; if it needs to change, change it here first.
|
|||||||
|
|
||||||
A card is always two lines: **line 1** and **line 2**.
|
A card is always two lines: **line 1** and **line 2**.
|
||||||
|
|
||||||
## Line 1 — name + actions
|
## Line 1 — name
|
||||||
|
|
||||||
- Left: the session name — `metadata.name` if present, else the command line
|
- Left: the session name — `metadata.name` if present, else the command line
|
||||||
(same fallback both clients already use).
|
(same fallback both clients already use).
|
||||||
- Right: two actions anchored to the top-right corner: **spawn similar**
|
- In the web client, **spawn similar** (`⧉`) and **kill** (`✕`) remain buttons
|
||||||
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
|
anchored on the right. The TUI omits those glyphs because its actions are
|
||||||
the highlighted row; in the web they are buttons. The glyphs are shared so
|
keyboard-driven and the icons do not identify their keys.
|
||||||
muscle memory transfers.
|
- The name truncates with `…` at the available width. In the web client it
|
||||||
- Actions reserve their fixed width; the name truncates with `…` before it can
|
must stop before the action buttons; in the TUI it may use the full row.
|
||||||
ever reach them. **Overlap between the name and the actions is a defect** —
|
|
||||||
that overlap is what started this milestone.
|
|
||||||
|
|
||||||
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
|
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
|
||||||
badge move off this row into line 2.
|
badge move off this row into line 2.
|
||||||
|
|
||||||
|
The name is editable in place (#125). In the web client, right-clicking a card
|
||||||
|
opens a context menu whose **rename** entry swaps the name for a text field —
|
||||||
|
Enter commits, Escape cancels. The TUI opens the same edit as a prompt, bound
|
||||||
|
to `r` in the sidebar by default. Both send `PATCH /api/sessions/:id`; both
|
||||||
|
start from the session's own name, never from the command it falls back to,
|
||||||
|
and an empty field clears the name so the fallback resumes.
|
||||||
|
|
||||||
## Line 2 — meta line (adaptive)
|
## Line 2 — meta line (adaptive)
|
||||||
|
|
||||||
Line 2 always renders — never an empty placeholder row either, since its
|
Line 2 always renders — never an empty placeholder row either, since its
|
||||||
|
|||||||
+28
-55
@@ -12,9 +12,9 @@ have to reconstruct it.
|
|||||||
|
|
||||||
## Nothing is ever adopted
|
## Nothing is ever adopted
|
||||||
|
|
||||||
The daemon only ever removes a worktree it created in this session's lifetime or
|
The daemon only ever removes a worktree it created in this session's lifetime.
|
||||||
recorded in its retained store. It never takes ownership of a directory it finds
|
It never takes ownership of a directory it finds on disk, whatever that
|
||||||
on disk, whatever that directory looks like.
|
directory looks like.
|
||||||
|
|
||||||
There is no reliable way to ask "is this worktree mine?". A git worktree carries
|
There is no reliable way to ask "is this worktree mine?". A git worktree carries
|
||||||
no marker distinguishing one a daemon made from one a human made ten minutes
|
no marker distinguishing one a daemon made from one a human made ten minutes
|
||||||
@@ -69,75 +69,48 @@ its worktree still matters leaves a directory nobody is looking at.
|
|||||||
|
|
||||||
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
||||||
commits, so `--force` is the ordinary way to end a session, not an exception.
|
commits, so `--force` is the ordinary way to end a session, not an exception.
|
||||||
That is only safe because force retains.
|
That is only safe because force never removes what blocked.
|
||||||
|
|
||||||
### `--force`
|
### `--force`
|
||||||
|
|
||||||
`--force` overrides the refusal and nothing else. It ends the session and
|
`--force` overrides the refusal and nothing else. It ends the session and
|
||||||
**retains** the worktree, which enters the retained store with reason
|
**leaves the worktree on disk**, untouched. Force never removes a tree that
|
||||||
`force-killed`. Force never removes a tree that would have blocked. A tree
|
would have blocked. A tree whose guard is clear never needed the override, so a
|
||||||
whose guard is clear never needed the override, so a forced kill removes it as
|
forced kill removes it as usual — force changes what happens to blocked work,
|
||||||
usual — force changes what happens to blocked work, not ordinary cleanup.
|
not ordinary cleanup.
|
||||||
|
|
||||||
Ending a session and destroying its work are two separate destructive acts.
|
Ending a session and destroying its work are two separate acts. Maestro only
|
||||||
Each is chosen on its own, and the second one — reclaim — is chosen while
|
performs the first one. Removing a tree it left behind is the operator's own
|
||||||
looking at exactly what would be lost.
|
`git worktree remove`, chosen while looking at exactly what would be lost.
|
||||||
|
|
||||||
A forced kill answers with what was retained and why, so a client can say where
|
|
||||||
the work went rather than leaving the operator to wonder.
|
|
||||||
|
|
||||||
### Exiting on its own
|
### Exiting on its own
|
||||||
|
|
||||||
A session that ends by itself — the command finished, the operator typed `exit` —
|
A session that ends by itself — the command finished, the operator typed `exit` —
|
||||||
never reaches the kill path. When the watcher notices the tmux session is gone it
|
never reaches the kill path. When the watcher notices the tmux session is gone it
|
||||||
applies the same rule: a pristine worktree is removed, anything else is retained
|
applies the same rule: a pristine worktree is removed, anything else is left on
|
||||||
with reason `exited-with-unsaved-work`.
|
disk.
|
||||||
|
|
||||||
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
||||||
only between removing and retaining. It uses the same predicate as the kill, from
|
only between removing and leaving. It uses the same predicate as the kill, from
|
||||||
the same place, so the two paths cannot drift apart.
|
the same place, so the two paths cannot drift apart.
|
||||||
|
|
||||||
The session itself stays in the catalog marked dead until it is dismissed — the
|
The session itself stays in the catalog marked dead until it is dismissed — the
|
||||||
replay buffer of a crashed agent outlives it — but its worktree is settled at
|
replay buffer of a crashed agent outlives it — but its worktree is settled at
|
||||||
once. Once settled, the session drops its worktree reference, so dismissing it
|
once. A removed tree drops off the record, so dismissing the session afterwards
|
||||||
afterwards neither refuses (there is no guard left to trip) nor records a second
|
does not refuse: there is no guard left to trip.
|
||||||
store entry.
|
|
||||||
|
|
||||||
If settling is impossible — the probe fails, or the store cannot take the entry —
|
A tree that was left alone stays attached to the dead record, where dismissing it
|
||||||
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
|
later goes through `--force`, which leaves it on disk too. The same holds when
|
||||||
record, where dismissing it later still goes through `--force`'s
|
the probe itself fails: nothing is destroyed.
|
||||||
retain-before-teardown path. Losing the store must never mean losing the work.
|
|
||||||
|
|
||||||
## The retained store
|
## Nothing is removed on the operator's behalf
|
||||||
|
|
||||||
A `state_dir`-backed record of worktrees the daemon created that no longer have a
|
There is no store of worktrees the daemon is holding, no list endpoint, and no
|
||||||
session. For each one: the path, the base repository, the branch, why it was
|
reclaim command. A tree left behind by a forced kill or a dirty exit is an
|
||||||
retained, and when.
|
ordinary git worktree on disk; `git worktree list` finds it and
|
||||||
|
`git worktree remove` removes it.
|
||||||
|
|
||||||
It survives a daemon restart. It is the recovery path for everything above — a
|
That is deliberate. A tracked list of trees pending deletion is a second place
|
||||||
store that is lost on restart is worse than no store, because the worktrees are
|
the truth can live, and it goes stale the moment somebody removes a directory by
|
||||||
still on disk with nothing pointing at them.
|
hand. Git already knows which worktrees exist, and removing one is not a
|
||||||
|
decision Maestro is better placed to make.
|
||||||
Entries leave when the worktree is reclaimed, and when the daemon notices the
|
|
||||||
directory is gone because somebody removed it by hand.
|
|
||||||
|
|
||||||
**It is not an ownership record.** It must never be consulted to decide whether a
|
|
||||||
path may be adopted — see the first rule. Its only job is to remember what the
|
|
||||||
daemon is holding so that it can be listed and reclaimed.
|
|
||||||
|
|
||||||
## Reclaim
|
|
||||||
|
|
||||||
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
|
|
||||||
and what is currently in the way. The in-the-way breakdown is recomputed on read,
|
|
||||||
not stored, so a tree whose work has since been pushed says so.
|
|
||||||
|
|
||||||
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
|
|
||||||
store entry. It does **not** consult the guard — reclaim is the deliberate
|
|
||||||
destructive act, and the operator chose it while looking at what was in the way.
|
|
||||||
|
|
||||||
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
|
|
||||||
a tree must not throw away commits on it. This is worth saying out loud in every
|
|
||||||
client, because "reclaim" otherwise sounds like it takes the commits too.
|
|
||||||
|
|
||||||
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
|
|
||||||
reclaim.
|
|
||||||
|
|||||||
+14
-19
@@ -9,7 +9,6 @@ import type {
|
|||||||
NameBatch,
|
NameBatch,
|
||||||
Projects,
|
Projects,
|
||||||
RecentDirs,
|
RecentDirs,
|
||||||
RetainedWorktree,
|
|
||||||
Session,
|
Session,
|
||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -44,6 +43,20 @@ export async function createSession(req: CreateSessionRequest): Promise<Session>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rename a session (#125). An empty name clears the custom one, so the card
|
||||||
|
* falls back to the command. The daemon trims and caps what it stores and
|
||||||
|
* broadcasts the update, so callers need not patch the store themselves. */
|
||||||
|
export async function renameSession(id: Uuid, name: string): Promise<Session> {
|
||||||
|
return jsonOrError(
|
||||||
|
await fetch(routes.session(id), {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function killSession(id: Uuid): Promise<void> {
|
export async function killSession(id: Uuid): Promise<void> {
|
||||||
await jsonOrError(
|
await jsonOrError(
|
||||||
await fetch(routes.session(id), {
|
await fetch(routes.session(id), {
|
||||||
@@ -98,22 +111,4 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
|||||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The daemon's retained worktrees (#80): trees kept after a session ended
|
|
||||||
* with unsaved work, with what is currently in the way. Drives the
|
|
||||||
* retained-worktrees view (#84). */
|
|
||||||
export async function listWorktrees(): Promise<RetainedWorktree[]> {
|
|
||||||
return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reclaim a retained worktree: removes its directory and drops the store
|
|
||||||
* entry. Never deletes the branch (docs/WORKTREES.md). */
|
|
||||||
export async function reclaimWorktree(id: Uuid): Promise<void> {
|
|
||||||
await jsonOrError(
|
|
||||||
await fetch(routes.worktree(id), {
|
|
||||||
method: "DELETE",
|
|
||||||
credentials: "same-origin",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { eventsWsUrl } from "./routes.js";
|
export { eventsWsUrl } from "./routes.js";
|
||||||
|
|||||||
+2
-1
@@ -123,7 +123,8 @@ export function createAttachConnection(
|
|||||||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
||||||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
// scrolling *its* content (the only scroll that exists on the alt screen).
|
||||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||||
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
|
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||||
|
// gesture stream; only the terminal surface opts out of native page panning.
|
||||||
{
|
{
|
||||||
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||||
|
|||||||
+105
-44
@@ -1,9 +1,17 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { getDaemonInfo, killSession } from "./api.js";
|
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
import {
|
||||||
|
abbreviateFolderName,
|
||||||
|
customSessionName,
|
||||||
|
formatInTheWay,
|
||||||
|
MAX_SESSION_NAME_LEN,
|
||||||
|
normalizeSessionName,
|
||||||
|
sessionBlockers,
|
||||||
|
sessionName,
|
||||||
|
} from "./types.js";
|
||||||
import {
|
import {
|
||||||
activityColorSlug,
|
activityColorSlug,
|
||||||
activityDurationLabel,
|
activityDurationLabel,
|
||||||
@@ -23,6 +31,11 @@ export function requestOpenSpawn(): void {
|
|||||||
|
|
||||||
const collapsed = new Set<string>();
|
const collapsed = new Set<string>();
|
||||||
|
|
||||||
|
/** Session whose card is currently being renamed inline (#125). The tree
|
||||||
|
* re-renders on every catalog delta, and an activity update arriving mid-edit
|
||||||
|
* would otherwise replace the field the user is typing into. */
|
||||||
|
let renamingId: string | null = null;
|
||||||
|
|
||||||
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
||||||
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||||
const STATE_WORDS: Record<string, string> = {
|
const STATE_WORDS: Record<string, string> = {
|
||||||
@@ -124,24 +137,96 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
|
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
|
||||||
});
|
});
|
||||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||||
killBtn.addEventListener("click", async (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
// #83: the confirm restates the warning at the moment of decision.
|
void confirmAndKill(s, name);
|
||||||
const blockers = sessionBlockers(s);
|
});
|
||||||
const question = blockers
|
row.addEventListener("contextmenu", (ev) => {
|
||||||
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
ev.preventDefault();
|
||||||
: `Kill session "${name}"?`;
|
ev.stopPropagation();
|
||||||
if (!confirm(question)) return;
|
openContextMenu(ev.clientX, ev.clientY, [
|
||||||
try {
|
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
||||||
await killSession(s.id);
|
{
|
||||||
} catch (e) {
|
label: "Spawn similar",
|
||||||
alert(`Failed to kill: ${(e as Error).message}`);
|
onSelect: () =>
|
||||||
}
|
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
|
||||||
|
},
|
||||||
|
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #83: the confirm restates the warning at the moment of decision. */
|
||||||
|
async function confirmAndKill(s: Session, name: string): Promise<void> {
|
||||||
|
const blockers = sessionBlockers(s);
|
||||||
|
const question = blockers
|
||||||
|
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
||||||
|
: `Kill session "${name}"?`;
|
||||||
|
if (!confirm(question)) return;
|
||||||
|
try {
|
||||||
|
await killSession(s.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Failed to kill: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap the card's name for a text field (#125). Enter commits, Escape and
|
||||||
|
* blur cancel. An emptied field clears the name, so the card falls back to
|
||||||
|
* the command — the same rule the spawn form's blank name field follows.
|
||||||
|
*/
|
||||||
|
function beginRename(row: HTMLElement, s: Session): void {
|
||||||
|
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
|
||||||
|
if (!nameEl) return;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.className = "leaf-name leaf-rename";
|
||||||
|
input.value = customSessionName(s) ?? "";
|
||||||
|
input.maxLength = MAX_SESSION_NAME_LEN;
|
||||||
|
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
|
||||||
|
// The card is an <a>, and the tree starts a drag on pointerdown: without
|
||||||
|
// these, clicking into the field navigates away or begins dragging the row.
|
||||||
|
for (const type of ["click", "pointerdown", "dblclick"]) {
|
||||||
|
input.addEventListener(type, (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renamingId = s.id;
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return false;
|
||||||
|
settled = true;
|
||||||
|
renamingId = null;
|
||||||
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
ev.preventDefault();
|
||||||
|
// Normalised here so what is sent is what the daemon will store.
|
||||||
|
const name = normalizeSessionName(input.value);
|
||||||
|
if (!finish()) return;
|
||||||
|
void renameSession(s.id, name).catch((e: Error) => {
|
||||||
|
alert(`Failed to rename: ${e.message}`);
|
||||||
|
});
|
||||||
|
} else if (ev.key === "Escape") {
|
||||||
|
ev.preventDefault();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", () => finish());
|
||||||
|
|
||||||
|
nameEl.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
function renderStatusbar(sessions: Session[]): HTMLElement {
|
function renderStatusbar(sessions: Session[]): HTMLElement {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "statusbar";
|
wrap.className = "statusbar";
|
||||||
@@ -183,25 +268,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
|||||||
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
||||||
* stay put and reachable regardless of which pane is showing.
|
* stay put and reachable regardless of which pane is showing.
|
||||||
*/
|
*/
|
||||||
/** Navbar toggle for the debug HUD (#20), next to the design-kit shortcut.
|
|
||||||
* Reflects the current state and stays in sync with the Ctrl+Shift+D keybind. */
|
|
||||||
function createDebugToggle(): HTMLButtonElement {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "btn btn-ghost debug-toggle";
|
|
||||||
btn.textContent = "debug";
|
|
||||||
btn.title = "Toggle the debug HUD (Ctrl+Shift+D)";
|
|
||||||
const sync = () => {
|
|
||||||
const on = isHudEnabled();
|
|
||||||
btn.setAttribute("aria-pressed", String(on));
|
|
||||||
btn.classList.toggle("is-active", on);
|
|
||||||
};
|
|
||||||
btn.addEventListener("click", () => toggleHud());
|
|
||||||
window.addEventListener(HUD_TOGGLE_EVENT, sync);
|
|
||||||
sync();
|
|
||||||
return btn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
|
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
|
||||||
const header = document.createElement("header");
|
const header = document.createElement("header");
|
||||||
header.className = "app-header";
|
header.className = "app-header";
|
||||||
@@ -229,15 +295,6 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
renderStatus();
|
renderStatus();
|
||||||
|
|
||||||
const actions = header.querySelector(".app-actions") as HTMLElement;
|
const actions = header.querySelector(".app-actions") as HTMLElement;
|
||||||
// Retained worktrees (#84): a section reachable from the header, not a
|
|
||||||
// sidebar fixture — PRODUCT.md's scan is the product, so this must not
|
|
||||||
// compete with the session catalog for attention.
|
|
||||||
const worktreesLink = document.createElement("a");
|
|
||||||
worktreesLink.className = "btn btn-ghost";
|
|
||||||
worktreesLink.href = "#/worktrees";
|
|
||||||
worktreesLink.textContent = "worktrees";
|
|
||||||
worktreesLink.title = "Retained worktrees";
|
|
||||||
actions.appendChild(worktreesLink);
|
|
||||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
@@ -248,7 +305,6 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
kit.title = "Open the design kit (DEV)";
|
kit.title = "Open the design kit (DEV)";
|
||||||
actions.appendChild(kit);
|
actions.appendChild(kit);
|
||||||
}
|
}
|
||||||
actions.appendChild(createDebugToggle());
|
|
||||||
actions.appendChild(createThemeToggle());
|
actions.appendChild(createThemeToggle());
|
||||||
const spawnBtn = document.createElement("button");
|
const spawnBtn = document.createElement("button");
|
||||||
spawnBtn.type = "button";
|
spawnBtn.type = "button";
|
||||||
@@ -402,6 +458,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
|
// Rebuilding the tree now would blow away the field being typed into.
|
||||||
|
if (renamingId !== null) return;
|
||||||
const state = store.snapshot();
|
const state = store.snapshot();
|
||||||
const sessions = [...state.sessions.values()];
|
const sessions = [...state.sessions.values()];
|
||||||
const tree = buildTree(sessions, homeDir, filter);
|
const tree = buildTree(sessions, homeDir, filter);
|
||||||
@@ -458,6 +516,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
clearInterval(clock);
|
clearInterval(clock);
|
||||||
|
// An unfinished rename would otherwise leave every later sidebar
|
||||||
|
// refusing to refresh.
|
||||||
|
renamingId = null;
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { closeContextMenu, openContextMenu } from "./contextMenu.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeContextMenu();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
function labels(): string[] {
|
||||||
|
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("context menu (#125)", () => {
|
||||||
|
it("renders one item per entry, in order", () => {
|
||||||
|
openContextMenu(10, 20, [
|
||||||
|
{ label: "Rename…", onSelect: () => {} },
|
||||||
|
{ label: "Kill session", onSelect: () => {}, danger: true },
|
||||||
|
]);
|
||||||
|
expect(labels()).toEqual(["Rename…", "Kill session"]);
|
||||||
|
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe(
|
||||||
|
"Kill session",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the selected entry and closes", () => {
|
||||||
|
const chosen = vi.fn();
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
|
||||||
|
(document.querySelector(".context-menu-item") as HTMLButtonElement).click();
|
||||||
|
expect(chosen).toHaveBeenCalledOnce();
|
||||||
|
expect(document.querySelector(".context-menu")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening a second menu replaces the first", () => {
|
||||||
|
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
|
||||||
|
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
|
||||||
|
expect(document.querySelectorAll(".context-menu")).toHaveLength(1);
|
||||||
|
expect(labels()).toEqual(["second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape and on a press outside it", () => {
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||||
|
expect(document.querySelector(".context-menu")).toBeNull();
|
||||||
|
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
|
||||||
|
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(document.querySelector(".context-menu")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays open for a press inside itself", () => {
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
|
||||||
|
const menu = document.querySelector(".context-menu") as HTMLElement;
|
||||||
|
menu.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(document.querySelector(".context-menu")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** Custom right-click menu (#125).
|
||||||
|
*
|
||||||
|
* One menu exists at a time: opening a second closes the first. The menu is
|
||||||
|
* appended to `<body>` rather than to the row it belongs to, so an overflowing
|
||||||
|
* sidebar cannot clip it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
/** Renders the entry in the destructive style. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
|
/** Close the menu that is currently up, if any. */
|
||||||
|
export function closeContextMenu(): void {
|
||||||
|
open?.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a menu at viewport coordinates `x`/`y`. The menu closes on selection,
|
||||||
|
* on Escape, on a pointer press anywhere outside it, and on scroll or resize —
|
||||||
|
* a menu anchored to coordinates goes stale the moment the page moves under it.
|
||||||
|
*/
|
||||||
|
export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement {
|
||||||
|
closeContextMenu();
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "context-menu";
|
||||||
|
el.setAttribute("role", "menu");
|
||||||
|
for (const item of items) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item";
|
||||||
|
btn.setAttribute("role", "menuitem");
|
||||||
|
btn.textContent = item.label;
|
||||||
|
btn.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeContextMenu();
|
||||||
|
item.onSelect();
|
||||||
|
});
|
||||||
|
el.appendChild(btn);
|
||||||
|
}
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
// Nudge back inside the viewport when the click was near an edge. happy-dom
|
||||||
|
// reports 0 for every measurement, which lands the menu at the click point —
|
||||||
|
// the same place it would sit with room to spare.
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const maxX = window.innerWidth - rect.width;
|
||||||
|
const maxY = window.innerHeight - rect.height;
|
||||||
|
el.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
|
||||||
|
el.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
|
||||||
|
|
||||||
|
const onPointerDown = (ev: Event) => {
|
||||||
|
if (!el.contains(ev.target as Node)) closeContextMenu();
|
||||||
|
};
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === "Escape") closeContextMenu();
|
||||||
|
};
|
||||||
|
const dispose = () => {
|
||||||
|
if (open?.el !== el) return;
|
||||||
|
open = null;
|
||||||
|
el.remove();
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
window.removeEventListener("scroll", closeContextMenu, true);
|
||||||
|
window.removeEventListener("resize", closeContextMenu);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.addEventListener("keydown", onKeyDown, true);
|
||||||
|
window.addEventListener("scroll", closeContextMenu, true);
|
||||||
|
window.addEventListener("resize", closeContextMenu);
|
||||||
|
|
||||||
|
open = { el, dispose };
|
||||||
|
(el.firstElementChild as HTMLElement | null)?.focus();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
fetchNames,
|
fetchNames,
|
||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
listWorktrees,
|
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
reclaimWorktree,
|
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
buildFastForwardRequest,
|
buildFastForwardRequest,
|
||||||
@@ -24,7 +22,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
|
|||||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||||
import { initTheme } from "./theme.js";
|
import { initTheme } from "./theme.js";
|
||||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||||
import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js";
|
|
||||||
import { installViewportSizing } from "./viewport.js";
|
import { installViewportSizing } from "./viewport.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
@@ -352,47 +349,16 @@ document.addEventListener("keydown", (e) => {
|
|||||||
type Route =
|
type Route =
|
||||||
| { kind: "catalog" }
|
| { kind: "catalog" }
|
||||||
| { kind: "attach"; sessionId: string }
|
| { kind: "attach"; sessionId: string }
|
||||||
| { kind: "worktrees" }
|
|
||||||
| { kind: "not-found"; raw: string };
|
| { kind: "not-found"; raw: string };
|
||||||
|
|
||||||
function parseRoute(): Route {
|
function parseRoute(): Route {
|
||||||
const hash = location.hash.replace(/^#/, "");
|
const hash = location.hash.replace(/^#/, "");
|
||||||
if (hash === "" || hash === "/") return { kind: "catalog" };
|
if (hash === "" || hash === "/") return { kind: "catalog" };
|
||||||
if (hash === "/worktrees") return { kind: "worktrees" };
|
|
||||||
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
||||||
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
||||||
return { kind: "not-found", raw: hash };
|
return { kind: "not-found", raw: hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retained worktrees (#84): built lazily on first visit, then kept so a
|
|
||||||
// revisit re-enters instantly. The daemon's home dir abbreviates paths once
|
|
||||||
// `/api/info` answers.
|
|
||||||
let worktreesView: WorktreesViewHandle | undefined;
|
|
||||||
let homeDir: string | undefined;
|
|
||||||
void getDaemonInfo()
|
|
||||||
.then((info) => {
|
|
||||||
homeDir = info.home_dir;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* absolute paths are a fine fallback */
|
|
||||||
});
|
|
||||||
|
|
||||||
function showWorktrees(): void {
|
|
||||||
pool.setVisible([]);
|
|
||||||
pool.setFocus(null);
|
|
||||||
contentShowsMv = false;
|
|
||||||
if (!worktreesView) {
|
|
||||||
worktreesView = createWorktreesView({
|
|
||||||
load: listWorktrees,
|
|
||||||
reclaim: reclaimWorktree,
|
|
||||||
homeDir: () => homeDir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
content.replaceChildren(worktreesView.el);
|
|
||||||
shellEl.dataset.route = "worktrees";
|
|
||||||
void worktreesView.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatch(): void {
|
function dispatch(): void {
|
||||||
const route = parseRoute();
|
const route = parseRoute();
|
||||||
switch (route.kind) {
|
switch (route.kind) {
|
||||||
@@ -414,9 +380,6 @@ function dispatch(): void {
|
|||||||
// tears panes down.
|
// tears panes down.
|
||||||
showMultiView();
|
showMultiView();
|
||||||
return;
|
return;
|
||||||
case "worktrees":
|
|
||||||
showWorktrees();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { emptyLayout, leaves, type Layout } from "./layout.js";
|
import { addTab, emptyLayout, leaves, type Layout } from "./layout.js";
|
||||||
import { splitLeaf } from "./layout.js";
|
import { splitLeaf } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
import type { ConnectionPool } from "./connections.js";
|
import type { ConnectionPool } from "./connections.js";
|
||||||
@@ -333,3 +333,98 @@ describe("openDraft newTab (#100)", () => {
|
|||||||
expect(leaves(tab.root)).toHaveLength(2);
|
expect(leaves(tab.root)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("multiview closeSession tab cleanup (#137)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the tab when its last session goes away", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const first = layout.tabs[0];
|
||||||
|
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
||||||
|
const second = addTab(layout);
|
||||||
|
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.closeSession("s2");
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
expect(layout.tabs[0].id).toBe(first.id);
|
||||||
|
expect(layout.activeTabId).toBe(first.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a background tab without stealing focus from the active one", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const background = layout.tabs[0];
|
||||||
|
background.focusedLeafId = splitLeaf(background, "", "s1", "row", true);
|
||||||
|
const active = addTab(layout);
|
||||||
|
active.focusedLeafId = splitLeaf(active, "", "s2", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.closeSession("s1");
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
expect(layout.tabs[0].id).toBe(active.id);
|
||||||
|
expect(layout.activeTabId).toBe(active.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a tab that still holds another pane", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
|
||||||
|
addTab(layout);
|
||||||
|
layout.activeTabId = tab.id;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.closeSession("s2");
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(2);
|
||||||
|
expect(leaves(tab.root)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a tab holding a draft pane", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
splitLeaf(tab, s1, null, "row", true);
|
||||||
|
addTab(layout);
|
||||||
|
layout.activeTabId = tab.id;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
mv.closeSession("s1");
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(2);
|
||||||
|
expect(leaves(tab.root)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never closes the only tab", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.closeSession("s1");
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
expect(layout.tabs[0].root).toBeNull();
|
||||||
|
expect(layout.activeTabId).toBe(tab.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -553,6 +553,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (loc.tab.focusedLeafId === loc.leaf.id) {
|
if (loc.tab.focusedLeafId === loc.leaf.id) {
|
||||||
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
|
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
// A tab that held nothing but the dead session goes with it (#137): a killed
|
||||||
|
// session should not leave an empty tab behind for the operator to close by
|
||||||
|
// hand. `closeTab` keeps the last tab (emptied) so there is always one.
|
||||||
|
if (!loc.tab.root) closeTab(layout, loc.tab.id);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { renderSidebar } from "./catalog.js";
|
||||||
|
import { closeContextMenu } from "./contextMenu.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
import { normalizeSessionName } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeContextMenu();
|
||||||
|
mounted?.dispose();
|
||||||
|
mounted = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function session(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
|
||||||
|
cwd: "/home/naps/tea/maestro",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: { name: "amber-falcon" },
|
||||||
|
status: "Running",
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWith(sessions: Session[]): CatalogStore {
|
||||||
|
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||||
|
return {
|
||||||
|
snapshot: () => state,
|
||||||
|
subscribe: (fn: CatalogListener) => {
|
||||||
|
fn(state);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
} as unknown as CatalogStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount a sidebar with one session; the handle is disposed after each test. */
|
||||||
|
function mount(s: Session): HTMLElement {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() => Promise.reject(new Error("offline"))),
|
||||||
|
);
|
||||||
|
const handle = renderSidebar(storeWith([s]));
|
||||||
|
mounted = handle;
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardIn(el: HTMLElement, s: Session): HTMLElement {
|
||||||
|
return el.querySelector<HTMLElement>(`[data-session-id="${s.id}"]`) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount a sidebar with one session and right-click its card. */
|
||||||
|
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
|
||||||
|
const el = mount(s);
|
||||||
|
const row = cardIn(el, s);
|
||||||
|
row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true }));
|
||||||
|
return { el, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItem(label: string): HTMLButtonElement {
|
||||||
|
const found = [...document.querySelectorAll<HTMLButtonElement>(".context-menu-item")].find(
|
||||||
|
(b) => b.textContent === label,
|
||||||
|
);
|
||||||
|
if (!found) throw new Error(`no "${label}" entry in the context menu`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session name normalisation (#125)", () => {
|
||||||
|
it("trims, and treats a blank name as a clear", () => {
|
||||||
|
expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon");
|
||||||
|
expect(normalizeSessionName(" ")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at the daemon's limit, counting code points", () => {
|
||||||
|
expect(normalizeSessionName("x".repeat(80))).toHaveLength(64);
|
||||||
|
expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sidebar card context menu (#125)", () => {
|
||||||
|
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
|
||||||
|
const s = session();
|
||||||
|
const row = cardIn(mount(s), s);
|
||||||
|
const ev = new Event("contextmenu", { bubbles: true, cancelable: true });
|
||||||
|
row.dispatchEvent(ev);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([
|
||||||
|
"Rename…",
|
||||||
|
"Spawn similar",
|
||||||
|
"Kill session",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawn similar dispatches the same event the ⧉ button does", () => {
|
||||||
|
const s = session();
|
||||||
|
openCardMenu(s);
|
||||||
|
const seen = vi.fn();
|
||||||
|
document.addEventListener("catalog-spawn-similar", seen);
|
||||||
|
menuItem("Spawn similar").click();
|
||||||
|
document.removeEventListener("catalog-spawn-similar", seen);
|
||||||
|
expect(seen).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline rename (#125)", () => {
|
||||||
|
it("pre-fills the field with the session's own name", () => {
|
||||||
|
const { row } = openCardMenu(session());
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
|
||||||
|
expect(input?.value).toBe("amber-falcon");
|
||||||
|
expect(row.querySelector("span.leaf-name")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts empty for a session that has no name of its own", () => {
|
||||||
|
const { row } = openCardMenu(session({ metadata: {} }));
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
expect(row.querySelector<HTMLInputElement>("input.leaf-rename")?.value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the tree still while the field is open", () => {
|
||||||
|
const s = session();
|
||||||
|
const { el, row } = openCardMenu(s);
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
|
||||||
|
// A catalog delta mid-edit must not rebuild the row out from under it.
|
||||||
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||||
|
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter PATCHes the session with the typed name", () => {
|
||||||
|
const s = session();
|
||||||
|
const { row } = openCardMenu(s);
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
|
||||||
|
input.value = "crimson-otter";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url).toBe(`/api/sessions/${s.id}`);
|
||||||
|
expect(init.method).toBe("PATCH");
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape cancels without a request and restores the name", () => {
|
||||||
|
const s = session();
|
||||||
|
const { el, row } = openCardMenu(s);
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
|
||||||
|
input.value = "discarded";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
// The row is rebuilt from the store, so the old name comes back.
|
||||||
|
expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon");
|
||||||
|
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,6 @@ export const PROJECTS = "/api/projects";
|
|||||||
export const GITROOT = "/api/gitroot";
|
export const GITROOT = "/api/gitroot";
|
||||||
export const NAMES = "/api/names";
|
export const NAMES = "/api/names";
|
||||||
export const BRANCHES = "/api/branches";
|
export const BRANCHES = "/api/branches";
|
||||||
export const WORKTREES = "/api/worktrees";
|
|
||||||
export const EVENTS = "/api/events";
|
export const EVENTS = "/api/events";
|
||||||
|
|
||||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||||
@@ -30,11 +29,6 @@ export function attach(id: Uuid): string {
|
|||||||
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */
|
|
||||||
export function worktree(id: Uuid): string {
|
|
||||||
return `${WORKTREES}/${encodeURIComponent(id)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||||
export function withPathQuery(route: string, value: string): string {
|
export function withPathQuery(route: string, value: string): string {
|
||||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||||
|
|||||||
@@ -140,10 +140,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field wt-step" hidden>
|
|
||||||
<span class="field-label">Worktree</span>
|
|
||||||
<div class="picker wt-choices"></div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Name</span>
|
<span class="field-label">Name</span>
|
||||||
<div class="name-row">
|
<div class="name-row">
|
||||||
@@ -158,6 +154,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
<span class="wt-summary"></span>
|
<span class="wt-summary"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="wt-collapsible" hidden>
|
<div class="wt-collapsible" hidden>
|
||||||
|
<div class="field wt-step" hidden>
|
||||||
|
<span class="field-label">Worktree</span>
|
||||||
|
<div class="picker wt-choices"></div>
|
||||||
|
</div>
|
||||||
<label class="row-inline">
|
<label class="row-inline">
|
||||||
<input type="checkbox" name="worktree_enabled" />
|
<input type="checkbox" name="worktree_enabled" />
|
||||||
Create git worktree
|
Create git worktree
|
||||||
|
|||||||
+67
-154
@@ -814,6 +814,68 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline rename field (#125): sits exactly where the name it replaces did, so
|
||||||
|
the card does not jump when the edit starts. */
|
||||||
|
input.leaf-rename {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--accent-ring);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
padding: 0 var(--sp-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.leaf-rename:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right-click menu on a session card (#125). Positioned from the click point,
|
||||||
|
so it is fixed to the viewport rather than to the scrolling sidebar. */
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 168px;
|
||||||
|
padding: var(--sp-1);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-3);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover,
|
||||||
|
.context-menu-item:focus-visible {
|
||||||
|
background: var(--surface-3);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.is-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.is-danger:hover,
|
||||||
|
.context-menu-item.is-danger:focus-visible {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
}
|
||||||
|
|
||||||
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||||
state word itself is never spelled out here; the colour carries it. */
|
state word itself is never spelled out here; the colour carries it. */
|
||||||
.card-line2 {
|
.card-line2 {
|
||||||
@@ -1142,9 +1204,11 @@ textarea {
|
|||||||
.attach-body .xterm .xterm-viewport {
|
.attach-body .xterm .xterm-viewport {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
|
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
|
||||||
the browser doesn't fight the handler or trigger gestures mid-scroll. */
|
the gesture to the browser, which may cancel the event stream before the
|
||||||
touch-action: pan-y;
|
terminal sees it; `none` keeps a drag inside the terminal under our
|
||||||
|
control while the rest of the page retains native scrolling. */
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
|
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
|
||||||
@@ -1951,12 +2015,6 @@ textarea {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* debug-HUD navbar toggle: lit when the HUD is on. */
|
|
||||||
.debug-toggle.is-active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
multi-view (#30 splits, #31 tabs, #33 highlights)
|
multi-view (#30 splits, #31 tabs, #33 highlights)
|
||||||
Tabs of nested split trees; each session leaf hosts a live .attach. The
|
Tabs of nested split trees; each session leaf hosts a live .attach. The
|
||||||
@@ -2375,148 +2433,3 @@ textarea {
|
|||||||
background: oklch(0 0 0 / 0.5);
|
background: oklch(0 0 0 / 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- retained worktrees (#84) ---------- */
|
|
||||||
|
|
||||||
.worktrees {
|
|
||||||
padding: var(--sp-4);
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-head {
|
|
||||||
margin-bottom: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-title {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-15);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ink);
|
|
||||||
margin: 0 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-sub {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: var(--fs-13);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-2) var(--sp-2);
|
|
||||||
border-bottom: 1px solid var(--surface-2);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-13);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-branch {
|
|
||||||
color: var(--ink);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-path {
|
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-reason {
|
|
||||||
color: var(--faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status never rides on color alone: the label itself says what blocks. */
|
|
||||||
.wt-way[data-clear] {
|
|
||||||
color: var(--faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-way:not([data-clear]) {
|
|
||||||
color: var(--warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-row .wt-reclaim-btn {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-row .wt-reclaim-btn::before {
|
|
||||||
content: "[";
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-row .wt-reclaim-btn::after {
|
|
||||||
content: "]";
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-row .wt-reclaim-btn:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
background: var(--danger-tint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
color: var(--danger);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: var(--danger-tint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The destructive confirmation, centered over the section. */
|
|
||||||
.wt-confirm {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: var(--z-backdrop);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: oklch(0 0 0 / 0.5);
|
|
||||||
padding: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-confirm[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-confirm-panel {
|
|
||||||
pointer-events: auto;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--surface-2);
|
|
||||||
padding: var(--sp-4);
|
|
||||||
max-width: 480px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-confirm-q {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-confirm-way {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-13);
|
|
||||||
color: var(--warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-confirm-note {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--fs-13);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wt-confirm-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|||||||
+20
-24
@@ -157,10 +157,6 @@ export interface BranchInfo {
|
|||||||
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
||||||
export type Branches = BranchInfo[];
|
export type Branches = BranchInfo[];
|
||||||
|
|
||||||
/** Why a retained worktree outlived its session (#80). New values are
|
|
||||||
* additive; unknown reasons render generically. */
|
|
||||||
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
|
|
||||||
|
|
||||||
/** What currently blocks an unforced kill of a worktree's session (#80),
|
/** What currently blocks an unforced kill of a worktree's session (#80),
|
||||||
* recomputed by the daemon on every read. All counts default to 0 on the
|
* recomputed by the daemon on every read. All counts default to 0 on the
|
||||||
* wire for older daemons. */
|
* wire for older daemons. */
|
||||||
@@ -170,19 +166,6 @@ export interface InTheWay {
|
|||||||
unpushed_commits?: number;
|
unpushed_commits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
|
||||||
* in-the-way state as of this read. Reclaiming it removes the directory but
|
|
||||||
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
|
|
||||||
export interface RetainedWorktree {
|
|
||||||
id: Uuid;
|
|
||||||
path: string;
|
|
||||||
base_repo: string;
|
|
||||||
branch: string;
|
|
||||||
reason: RetainedReason;
|
|
||||||
retained_at: IsoTimestamp;
|
|
||||||
in_the_way?: InTheWay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
|
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
|
||||||
* the kill refusal, the TUI and this client, so no two clients can describe
|
* the kill refusal, the TUI and this client, so no two clients can describe
|
||||||
* the same tree differently. Zero parts are omitted; all-zero renders
|
* the same tree differently. Zero parts are omitted; all-zero renders
|
||||||
@@ -199,12 +182,6 @@ export function formatInTheWay(w: InTheWay): string {
|
|||||||
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
|
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same reason labels the CLI table and the TUI overlay use; unknown future
|
|
||||||
* reasons render their raw wire value. */
|
|
||||||
export function reasonLabel(reason: RetainedReason): string {
|
|
||||||
return reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #83: this session's blocked-work breakdown when something actually blocks
|
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||||
* an unforced kill; `null` while clear or not yet probed. Render it with
|
* an unforced kill; `null` while clear or not yet probed. Render it with
|
||||||
* {@link formatInTheWay} so every client phrases it identically. */
|
* {@link formatInTheWay} so every client phrases it identically. */
|
||||||
@@ -244,10 +221,29 @@ export interface ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sessionName(s: Session): string {
|
export function sessionName(s: Session): string {
|
||||||
|
return customSessionName(s) ?? s.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own name, without the command fallback (#125): `undefined`
|
||||||
|
* for a session that was never given one. */
|
||||||
|
export function customSessionName(s: Session): string | undefined {
|
||||||
const meta = s.metadata as Record<string, unknown>;
|
const meta = s.metadata as Record<string, unknown>;
|
||||||
const name = meta?.name;
|
const name = meta?.name;
|
||||||
if (typeof name === "string" && name.length > 0) return name;
|
if (typeof name === "string" && name.length > 0) return name;
|
||||||
return s.command;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest session name the daemon stores (#125), in characters. Mirrors
|
||||||
|
* `maestro_protocol::MAX_SESSION_NAME_LEN`. */
|
||||||
|
export const MAX_SESSION_NAME_LEN = 64;
|
||||||
|
|
||||||
|
/** Trim a requested session name and cap it at {@link MAX_SESSION_NAME_LEN}
|
||||||
|
* characters. Empty means "clear the name", so the card falls back to the
|
||||||
|
* command. Mirrors `maestro_protocol::normalize_session_name` — the daemon
|
||||||
|
* applies the same rule, this only keeps the UI honest about what it will
|
||||||
|
* get back. Uses the spread so the cap counts code points, not UTF-16 units. */
|
||||||
|
export function normalizeSessionName(name: string): string {
|
||||||
|
return [...name.trim()].slice(0, MAX_SESSION_NAME_LEN).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ describe("spawnForm worktree step (#95)", () => {
|
|||||||
expect(step(form).hidden).toBe(false);
|
expect(step(form).hidden).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the worktree step inside the collapsed Advanced options section", async () => {
|
||||||
|
const form = await mount([MAESTRO, WT_A]);
|
||||||
|
box(form, MAESTRO.path).click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const collapsible = form.querySelector<HTMLElement>(".wt-collapsible")!;
|
||||||
|
expect(collapsible.contains(step(form))).toBe(true);
|
||||||
|
expect(collapsible.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
|
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
|
||||||
const form = await mount([MAESTRO, WT_A, WT_B]);
|
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||||
box(form, MAESTRO.path).click();
|
box(form, MAESTRO.path).click();
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { createWorktreesView } from "./worktreesView.js";
|
|
||||||
import type { RetainedWorktree } from "./types.js";
|
|
||||||
|
|
||||||
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
|
|
||||||
return {
|
|
||||||
id: "11111111-2222-3333-4444-555555555555",
|
|
||||||
path: "/home/naps/tea/maestro/worktrees/feat/login",
|
|
||||||
base_repo: "/home/naps/tea/maestro",
|
|
||||||
branch: "feat/login",
|
|
||||||
reason: "force-killed",
|
|
||||||
retained_at: "2026-08-24T10:00:00Z",
|
|
||||||
in_the_way: {
|
|
||||||
uncommitted_files: 2,
|
|
||||||
untracked_files: 1,
|
|
||||||
unpushed_commits: 3,
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
|
|
||||||
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
|
|
||||||
const reclaimed: string[] = [];
|
|
||||||
const view = createWorktreesView({
|
|
||||||
load: async () => items,
|
|
||||||
reclaim: async (id) => {
|
|
||||||
reclaimed.push(id);
|
|
||||||
items.splice(
|
|
||||||
items.findIndex((w) => w.id === id),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
homeDir: () => homeDir,
|
|
||||||
});
|
|
||||||
document.body.appendChild(view.el);
|
|
||||||
return { view, reclaimed };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("retained-worktrees view (#84)", () => {
|
|
||||||
it("lists branch, abbreviated path, reason and what is in the way", async () => {
|
|
||||||
const { view } = mount([
|
|
||||||
worktree({
|
|
||||||
path: "/home/naps/tea/maestro/worktrees/feat/login",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
await view.refresh();
|
|
||||||
const text = view.el.textContent ?? "";
|
|
||||||
expect(text).toContain("feat/login");
|
|
||||||
// Sidebar-style abbreviation of the path against the daemon's home dir,
|
|
||||||
// full path kept in the tooltip.
|
|
||||||
expect(text).toContain("t/m/w/f/login");
|
|
||||||
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
|
|
||||||
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
|
|
||||||
// Same reason labels as `maestro worktrees list` and the TUI overlay.
|
|
||||||
expect(text).toContain("force-killed");
|
|
||||||
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
|
|
||||||
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says a tree whose work has since been pushed is clear", async () => {
|
|
||||||
const { view } = mount([worktree({ in_the_way: {} })]);
|
|
||||||
await view.refresh();
|
|
||||||
expect(view.el.textContent).toContain("no unsaved work");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders an empty store as a normal state, not an error", async () => {
|
|
||||||
const { view } = mount([]);
|
|
||||||
await view.refresh();
|
|
||||||
const text = view.el.textContent ?? "";
|
|
||||||
expect(text).toContain("no retained worktrees");
|
|
||||||
expect(text.toLowerCase()).not.toContain("error");
|
|
||||||
expect(text.toLowerCase()).not.toContain("failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
|
|
||||||
const wt = worktree({ branch: "feat/login" });
|
|
||||||
const { view, reclaimed } = mount([wt]);
|
|
||||||
await view.refresh();
|
|
||||||
|
|
||||||
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
|
||||||
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
|
|
||||||
expect(confirmText).toContain("Reclaim worktree feat/login?");
|
|
||||||
// Names what will be lost before it happens…
|
|
||||||
expect(confirmText).toContain(
|
|
||||||
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
|
|
||||||
);
|
|
||||||
// …and says reclaim does not take the commits too.
|
|
||||||
expect(confirmText).toContain("never deleted");
|
|
||||||
|
|
||||||
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
|
|
||||||
expect(reclaimed).toEqual([]);
|
|
||||||
|
|
||||||
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
|
||||||
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
|
|
||||||
// The row is gone once the refresh lands.
|
|
||||||
expect(view.el.textContent).toContain("no retained worktrees");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts oldest-retained first, like every other client", async () => {
|
|
||||||
const { view } = mount([
|
|
||||||
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
|
|
||||||
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
|
|
||||||
]);
|
|
||||||
await view.refresh();
|
|
||||||
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
|
|
||||||
expect(branches).toEqual(["older", "newer"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
// Retained-worktrees view (#84): every worktree the daemon is holding that no
|
|
||||||
// session uses, listed from `GET /api/worktrees` with a reclaim action.
|
|
||||||
//
|
|
||||||
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
|
|
||||||
// overlay: same reason labels, same in-the-way phrasing via
|
|
||||||
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
|
|
||||||
// as what it is — destructive — naming what will be lost before it happens
|
|
||||||
// and saying out loud that the branch survives (docs/WORKTREES.md).
|
|
||||||
|
|
||||||
import { escapeHtml } from "./html.js";
|
|
||||||
import {
|
|
||||||
abbreviateCwd,
|
|
||||||
abbreviateFolderName,
|
|
||||||
formatInTheWay,
|
|
||||||
reasonLabel,
|
|
||||||
type RetainedWorktree,
|
|
||||||
type Uuid,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
export interface WorktreesDeps {
|
|
||||||
/** `GET /api/worktrees`. */
|
|
||||||
load(): Promise<RetainedWorktree[]>;
|
|
||||||
/** `DELETE /api/worktrees/:id`. */
|
|
||||||
reclaim(id: Uuid): Promise<void>;
|
|
||||||
/** The daemon's home dir, for `~` abbreviation of paths. */
|
|
||||||
homeDir(): string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorktreesViewHandle {
|
|
||||||
el: HTMLElement;
|
|
||||||
/** Re-fetch the store and re-render. Safe to call repeatedly. */
|
|
||||||
refresh(): Promise<void>;
|
|
||||||
dispose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
|
|
||||||
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
|
|
||||||
return [...items].sort(
|
|
||||||
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
|
|
||||||
const el = document.createElement("section");
|
|
||||||
el.className = "worktrees";
|
|
||||||
el.innerHTML = `
|
|
||||||
<header class="wt-head">
|
|
||||||
<h2 class="wt-title">retained worktrees</h2>
|
|
||||||
<p class="wt-sub">
|
|
||||||
Kept when a session ended with unsaved work. Reclaiming removes the
|
|
||||||
directory only; branches are never deleted.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<div class="wt-list"></div>
|
|
||||||
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
|
|
||||||
`;
|
|
||||||
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
|
|
||||||
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
|
|
||||||
|
|
||||||
let pendingId: string | null = null;
|
|
||||||
|
|
||||||
const render = (items: RetainedWorktree[]) => {
|
|
||||||
listHost.replaceChildren();
|
|
||||||
if (items.length === 0) {
|
|
||||||
// A drained store is the normal state — say so plainly, not like
|
|
||||||
// something went wrong.
|
|
||||||
const empty = document.createElement("div");
|
|
||||||
empty.className = "empty";
|
|
||||||
empty.innerHTML = `
|
|
||||||
<div class="empty-glyph" aria-hidden="true">⌂</div>
|
|
||||||
<div class="empty-title">no retained worktrees</div>
|
|
||||||
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
|
|
||||||
listHost.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const home = deps.homeDir();
|
|
||||||
for (const w of sorted(items)) {
|
|
||||||
listHost.appendChild(renderRow(w, home));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "wt-row";
|
|
||||||
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
|
|
||||||
row.innerHTML = `
|
|
||||||
<span class="wt-branch">${escapeHtml(w.branch)}</span>
|
|
||||||
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
|
|
||||||
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
|
|
||||||
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
|
|
||||||
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
|
|
||||||
`;
|
|
||||||
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
|
|
||||||
showConfirm(w);
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showConfirm = (w: RetainedWorktree) => {
|
|
||||||
pendingId = w.id;
|
|
||||||
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
|
|
||||||
// in the way right now, and that the branch survives reclaim.
|
|
||||||
confirmEl.innerHTML = `
|
|
||||||
<div class="wt-confirm-panel">
|
|
||||||
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
|
|
||||||
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
|
|
||||||
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
|
|
||||||
<div class="wt-confirm-actions">
|
|
||||||
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
|
|
||||||
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
confirmEl.hidden = false;
|
|
||||||
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideConfirm = () => {
|
|
||||||
pendingId = null;
|
|
||||||
confirmEl.hidden = true;
|
|
||||||
confirmEl.replaceChildren();
|
|
||||||
};
|
|
||||||
|
|
||||||
confirmEl.addEventListener("click", async (e) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest(".wt-confirm-cancel")) {
|
|
||||||
hideConfirm();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!target.closest(".wt-confirm-go") || !pendingId) return;
|
|
||||||
const id = pendingId;
|
|
||||||
hideConfirm();
|
|
||||||
try {
|
|
||||||
await deps.reclaim(id);
|
|
||||||
} catch (err) {
|
|
||||||
alert(`Failed to reclaim: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
try {
|
|
||||||
render(await deps.load());
|
|
||||||
} catch (err) {
|
|
||||||
listHost.replaceChildren();
|
|
||||||
const fail = document.createElement("div");
|
|
||||||
fail.className = "empty";
|
|
||||||
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
|
|
||||||
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
|
|
||||||
listHost.appendChild(fail);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
el,
|
|
||||||
refresh,
|
|
||||||
dispose() {
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
el.remove();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user