1816 lines
69 KiB
Rust
1816 lines
69 KiB
Rust
//! `App` — top-level event loop that stitches everything together.
|
|
//!
|
|
//! Per frame: `redraw()` reads from `CatalogStore` + `SessionEmulators` and
|
|
//! draws via [`crate::render::draw`], returning a `RegionMap` for the next
|
|
//! mouse hit-test. The event loop then races crossterm input, catalog
|
|
//! changes, and a 30Hz heartbeat.
|
|
//!
|
|
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
|
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
|
|
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
|
//! `dispatch` / the Insert-mode pane routing.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::io::Stdout;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use chrono::Utc;
|
|
use crossterm::event::{Event as CtEvent, EventStream};
|
|
use futures_util::StreamExt;
|
|
use maestro_protocol::{
|
|
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree,
|
|
};
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::Terminal;
|
|
use uuid::Uuid;
|
|
|
|
use crate::action::Action;
|
|
use crate::catalog::CatalogStore;
|
|
use crate::config::TuiConfig;
|
|
use crate::draft::DraftInputResult;
|
|
use crate::emulators::SessionEmulators;
|
|
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
|
use crate::keymap::Mode;
|
|
use crate::overlays::HelpOverlay;
|
|
use crate::pane::{Pane, PaneStatus};
|
|
use crate::render::{draw, RegionMap, RenderInputs};
|
|
use crate::session_task::{SessionInput, SessionTask};
|
|
use crate::sidebar::Sidebar;
|
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
|
use crate::tabs::TabBar;
|
|
use crate::worktrees::WorktreesView;
|
|
use crate::TuiError;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum OverlayDecision {
|
|
/// Overlay handled the event; do not run keymap dispatch.
|
|
Consumed,
|
|
/// No overlay open; the dispatcher should process the event normally.
|
|
Pass,
|
|
}
|
|
|
|
pub struct App {
|
|
cfg: TuiConfig,
|
|
catalog: CatalogStore,
|
|
emulators: SessionEmulators,
|
|
state: AppState,
|
|
help: HelpOverlay,
|
|
chord: ChordState,
|
|
daemon: DaemonClient,
|
|
/// Results of draft data loads (and the worktrees view fetch, #85),
|
|
/// delivered back to the event loop.
|
|
///
|
|
/// These are network calls — `/api/projects` on a cold cache walks all of
|
|
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
|
/// keys, no frames, attached panes stalled, for up to the request timeout
|
|
/// (#67 review). They run as detached tasks and post here instead.
|
|
draft_tx: tokio::sync::mpsc::UnboundedSender<DraftUpdate>,
|
|
draft_rx: tokio::sync::mpsc::UnboundedReceiver<DraftUpdate>,
|
|
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
|
/// `GET /api/names` (#64).
|
|
http: reqwest::Client,
|
|
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
|
/// rather than in `AppState` so the overlay kind stays payload-free.
|
|
worktrees: WorktreesView,
|
|
region: RegionMap,
|
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
|
clicks: ClickTracker,
|
|
}
|
|
|
|
impl std::fmt::Debug for App {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("App")
|
|
.field("daemon", &self.daemon.base())
|
|
.field("state", &self.state)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
/// Something a keystroke decided to do that reaches outside `AppState`.
|
|
///
|
|
/// `dispatch` returns these instead of performing them, so every arm is
|
|
/// assertable without a daemon on the other end.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Effect {
|
|
Attach(Uuid),
|
|
Spawn(CreateSessionRequest),
|
|
Kill(Uuid),
|
|
Forward {
|
|
id: Uuid,
|
|
bytes: Vec<u8>,
|
|
},
|
|
/// Fetch presets / projects / names for a freshly opened draft (#64).
|
|
LoadDraft(usize),
|
|
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
|
/// spawn with no form; unresolvable defaults open a pre-filled draft.
|
|
FastForward,
|
|
/// A reroll drained the draft's local name batch (#64).
|
|
RefillNames(usize),
|
|
/// The draft's target changed: probe `/api/gitroot` so the worktree
|
|
/// section knows its base repo (#65). `seq` guards stale answers.
|
|
ProbeGitRoot {
|
|
tab: usize,
|
|
seq: u32,
|
|
path: PathBuf,
|
|
},
|
|
/// Fetch the draft target's branches from `/api/branches` (#65).
|
|
LoadBranches(usize),
|
|
/// Fetch `/api/worktrees` for the retained-worktrees view (#85).
|
|
LoadWorktrees,
|
|
/// Reclaim one retained worktree (#85) — removes its directory; the
|
|
/// branch is never deleted.
|
|
ReclaimWorktree(Uuid),
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
|
|
let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let chord_timeout = cfg.chord_timeout;
|
|
let help = HelpOverlay {
|
|
lines: render_help_lines(&cfg),
|
|
};
|
|
let http = reqwest::Client::builder()
|
|
.connect_timeout(maestro_protocol::client::DEFAULT_CONNECT_TIMEOUT)
|
|
.timeout(maestro_protocol::client::DEFAULT_REQUEST_TIMEOUT)
|
|
.build()
|
|
.expect("build reqwest client");
|
|
Self {
|
|
cfg,
|
|
catalog,
|
|
emulators: SessionEmulators::default(),
|
|
state: AppState::new(),
|
|
help,
|
|
chord: ChordState::new(chord_timeout),
|
|
daemon,
|
|
http,
|
|
worktrees: WorktreesView::default(),
|
|
draft_tx,
|
|
draft_rx,
|
|
region: RegionMap::default(),
|
|
last_sent_size: HashMap::new(),
|
|
clicks: ClickTracker::default(),
|
|
}
|
|
}
|
|
|
|
/// Drive the loop until the user quits. Consumes self.
|
|
pub async fn run(
|
|
mut self,
|
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
|
) -> Result<(), TuiError> {
|
|
let mut events = EventStream::new();
|
|
loop {
|
|
self.redraw(terminal)?;
|
|
|
|
tokio::select! {
|
|
Some(Ok(ev)) = events.next() => {
|
|
if self.handle_event(ev).await? {
|
|
return Ok(());
|
|
}
|
|
}
|
|
Some(update) = self.draft_rx.recv() => {
|
|
self.apply_draft_update(update).await;
|
|
}
|
|
_ = self.catalog.changed() => {
|
|
// Render on next loop iteration.
|
|
}
|
|
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
|
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
|
self.perform(effects).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true when the loop should exit.
|
|
async fn handle_event(&mut self, ev: CtEvent) -> Result<bool, TuiError> {
|
|
let map = self.region.clone();
|
|
let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) else {
|
|
return Ok(false);
|
|
};
|
|
let key = self.clicks.promote(key, std::time::Instant::now());
|
|
|
|
// Overlay intercept first.
|
|
let (decision, effects) = self.handle_overlay_input(&key);
|
|
self.perform(effects).await;
|
|
if decision == OverlayDecision::Consumed {
|
|
return Ok(false);
|
|
}
|
|
|
|
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
|
match self.chord.feed(&key, &known) {
|
|
ChordResult::Pending => Ok(false),
|
|
ChordResult::Resolved(canon) => {
|
|
if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
|
|
if matches!(act, Action::Quit) {
|
|
return Ok(true);
|
|
}
|
|
let effects = self.dispatch(act);
|
|
self.perform(effects).await;
|
|
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
|
{
|
|
// Insert mode over a focused pane: a draft eats the key
|
|
// into its form, a session gets the raw bytes.
|
|
let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
|
{
|
|
self.handle_draft_input(&key)
|
|
} else {
|
|
self.forward_to_session(&key).into_iter().collect()
|
|
};
|
|
self.perform(effects).await;
|
|
}
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
|
/// the daemon.
|
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
|
for effect in effects {
|
|
match effect {
|
|
Effect::Attach(id) => self.attach(id),
|
|
Effect::Spawn(req) => self.submit_spawn(req).await,
|
|
Effect::Kill(id) => self.submit_kill(id).await,
|
|
Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx),
|
|
Effect::FastForward => self.spawn_fast_forward(),
|
|
Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx),
|
|
Effect::ProbeGitRoot { tab, seq, path } => {
|
|
self.spawn_probe_git_root(tab, seq, path)
|
|
}
|
|
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
|
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
|
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
|
Effect::Forward { id, bytes } => {
|
|
if let Some(handle) = self.emulators.get(id) {
|
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
|
match self.state.overlay().cloned() {
|
|
Some(OverlayKind::KillConfirm(id)) => match key {
|
|
InputKey::Char { c: 'y', .. } => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, vec![Effect::Kill(id)])
|
|
}
|
|
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
|
},
|
|
Some(OverlayKind::Worktrees) => match key {
|
|
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
|
self.worktrees.move_highlight(1);
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
InputKey::Char { c: 'k', .. } | InputKey::Up => {
|
|
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();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
|
},
|
|
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()),
|
|
},
|
|
Some(OverlayKind::Help) => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
// A toast is informational: clear it, but let the keystroke go on
|
|
// and do its normal job rather than being swallowed.
|
|
Some(OverlayKind::Toast(_)) => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Pass, Vec::new())
|
|
}
|
|
None => (OverlayDecision::Pass, Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Fold a resolved action into `AppState` and report anything it wants done
|
|
/// outside it. Deliberately sync and side-effect-free.
|
|
fn dispatch(&mut self, act: Action) -> Vec<Effect> {
|
|
let mut effects = Vec::new();
|
|
match act {
|
|
Action::Noop | Action::Quit => {}
|
|
Action::EnterInsert => self.state.mode = Mode::Insert,
|
|
Action::LeaveInsert => self.state.mode = Mode::Normal,
|
|
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
|
|
Action::FocusPane(_) => {
|
|
// v1 has one pane per tab; geometric focus motion lands
|
|
// either in the sidebar or back in the pane.
|
|
self.state.focus = Focus::Pane;
|
|
}
|
|
Action::SidebarNext => {
|
|
let order = visible_sidebar_order(&self.catalog.snapshot());
|
|
self.state.move_sidebar_highlight(&order, 1);
|
|
self.state.focus = Focus::Sidebar;
|
|
}
|
|
Action::SidebarPrev => {
|
|
let order = visible_sidebar_order(&self.catalog.snapshot());
|
|
self.state.move_sidebar_highlight(&order, -1);
|
|
self.state.focus = Focus::Sidebar;
|
|
}
|
|
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::ShowHelp => {
|
|
self.help = HelpOverlay {
|
|
lines: render_help_lines(&self.cfg),
|
|
};
|
|
self.state.show_overlay(OverlayKind::Help);
|
|
}
|
|
Action::NewDraftPane => {
|
|
self.state.open_draft_pane();
|
|
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
|
}
|
|
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
|
if let Some(sid) = self.pick_sidebar_target() {
|
|
effects.push(Effect::Attach(sid));
|
|
self.state.attach_to_focused(sid);
|
|
}
|
|
}
|
|
Action::KillHighlighted => {
|
|
if let Some(sid) = self.pick_sidebar_target() {
|
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
|
}
|
|
}
|
|
Action::ShowWorktrees => {
|
|
self.state.show_overlay(OverlayKind::Worktrees);
|
|
effects.push(Effect::LoadWorktrees);
|
|
}
|
|
}
|
|
effects
|
|
}
|
|
|
|
/// Open a stream for `sid` if one isn't already running.
|
|
fn attach(&mut self, sid: Uuid) {
|
|
if self.emulators.get(sid).is_some() {
|
|
return;
|
|
}
|
|
match self.daemon.attach_url(sid) {
|
|
Ok(ws_url) => {
|
|
let handle = SessionTask::spawn(ws_url, 24, 80);
|
|
self.emulators.insert(sid, handle);
|
|
}
|
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Tear down the emulator for `sid`, aborting its WebSocket task and
|
|
/// dropping its vt100 parser.
|
|
fn release_session(&mut self, sid: Uuid) {
|
|
self.emulators.remove(sid);
|
|
self.last_sent_size.remove(&sid);
|
|
}
|
|
|
|
/// Return the session under the sidebar highlight, falling back to the
|
|
/// first listed session so the user can attach without arrowing first.
|
|
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
|
if let Some(sid) = self.state.sidebar_highlight {
|
|
return Some(sid);
|
|
}
|
|
let snap = self.catalog.snapshot();
|
|
snap.sessions.first().map(|s| s.id)
|
|
}
|
|
|
|
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
|
/// attached to send it to.
|
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
|
let sid = self.state.active_session()?;
|
|
self.emulators.get(sid)?;
|
|
let bytes = encode_key(key)?;
|
|
Some(Effect::Forward { id: sid, bytes })
|
|
}
|
|
|
|
/// 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
|
|
/// missing field inside the form (#64) instead of posting.
|
|
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
|
|
let tab_idx = self.state.active_tab_idx;
|
|
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
|
return Vec::new();
|
|
};
|
|
let mut effects = Vec::new();
|
|
if draft.on_input(key) == DraftInputResult::Submit {
|
|
match draft.build_request() {
|
|
Ok(req) => {
|
|
draft.error = None;
|
|
effects.push(Effect::Spawn(req));
|
|
}
|
|
Err(e) => draft.error = Some(e.to_string()),
|
|
}
|
|
}
|
|
if draft.take_name_refill() {
|
|
effects.push(Effect::RefillNames(tab_idx));
|
|
}
|
|
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
|
effects.push(Effect::ProbeGitRoot {
|
|
tab: tab_idx,
|
|
seq,
|
|
path,
|
|
});
|
|
}
|
|
if draft.take_branch_load().is_some() {
|
|
effects.push(Effect::LoadBranches(tab_idx));
|
|
}
|
|
effects
|
|
}
|
|
|
|
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
|
|
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
|
|
/// probe even after the last keystroke.
|
|
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
|
let mut effects = Vec::new();
|
|
for tab_idx in 0..self.state.tabs.len() {
|
|
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
|
|
continue;
|
|
};
|
|
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
|
|
effects.push(Effect::ProbeGitRoot {
|
|
tab: tab_idx,
|
|
seq,
|
|
path,
|
|
});
|
|
}
|
|
}
|
|
effects
|
|
}
|
|
|
|
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
|
/// to the built-ins already in place), projects from `/api/projects`, and
|
|
/// a first name batch. Applies only if that tab still shows the draft —
|
|
/// a slow response must never clobber what replaced it.
|
|
/// Kick off the draft's initial data load without blocking the loop.
|
|
fn spawn_load_draft(&self, tab: usize) {
|
|
let Some(draft) = self.draft_id(tab) else {
|
|
return;
|
|
};
|
|
let (daemon, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
|
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
|
let _ = tx.send(DraftUpdate::Data {
|
|
draft,
|
|
info: info.ok(),
|
|
projects: projects.ok(),
|
|
names,
|
|
});
|
|
});
|
|
}
|
|
|
|
fn spawn_refill_names(&self, tab: usize) {
|
|
let Some(draft) = self.draft_id(tab) else {
|
|
return;
|
|
};
|
|
let (_, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
|
let _ = tx.send(DraftUpdate::Names { draft, names });
|
|
});
|
|
}
|
|
|
|
fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) {
|
|
let Some(draft) = self.draft_id(tab) else {
|
|
return;
|
|
};
|
|
let (daemon, _, _, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let root = daemon
|
|
.git_root(&path.to_string_lossy())
|
|
.await
|
|
.ok()
|
|
.and_then(|g| g.root);
|
|
let _ = tx.send(DraftUpdate::GitRoot { draft, seq, root });
|
|
});
|
|
}
|
|
|
|
fn spawn_load_branches(&self, tab: usize) {
|
|
let Some(draft) = self.draft_id(tab) else {
|
|
return;
|
|
};
|
|
let Some(cwd) = self
|
|
.state
|
|
.tabs
|
|
.get(tab)
|
|
.and_then(|t| t.pane.as_ref())
|
|
.and_then(|p| match p {
|
|
PaneRef::Draft(d) => d.cwd(),
|
|
_ => None,
|
|
})
|
|
else {
|
|
return;
|
|
};
|
|
let (_, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let branches = fetch_branches(&http, &base, &cwd).await;
|
|
let _ = tx.send(DraftUpdate::Branches {
|
|
draft,
|
|
cwd,
|
|
branches,
|
|
});
|
|
});
|
|
}
|
|
|
|
/// The draft pane living in `tab`, by identity.
|
|
fn draft_id(&self, tab: usize) -> Option<u64> {
|
|
match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) {
|
|
Some(PaneRef::Draft(d)) => Some(d.id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn loader_handles(
|
|
&self,
|
|
) -> (
|
|
DaemonClient,
|
|
reqwest::Client,
|
|
String,
|
|
tokio::sync::mpsc::UnboundedSender<DraftUpdate>,
|
|
) {
|
|
(
|
|
self.daemon.clone(),
|
|
self.http.clone(),
|
|
self.daemon.base().to_string(),
|
|
self.draft_tx.clone(),
|
|
)
|
|
}
|
|
|
|
/// Fold a finished background load into the draft that asked for it.
|
|
async fn apply_draft_update(&mut self, update: DraftUpdate) {
|
|
if let DraftUpdate::FastForward {
|
|
info,
|
|
projects,
|
|
names,
|
|
} = update
|
|
{
|
|
self.fast_forward_spawn(info, projects, names).await;
|
|
return;
|
|
}
|
|
if let DraftUpdate::WorktreesLoaded {
|
|
worktrees,
|
|
home_dir,
|
|
} = update
|
|
{
|
|
self.worktrees.apply(worktrees);
|
|
self.worktrees.home_dir = home_dir;
|
|
return;
|
|
}
|
|
let want = match &update {
|
|
DraftUpdate::Data { draft, .. }
|
|
| DraftUpdate::Names { draft, .. }
|
|
| DraftUpdate::GitRoot { draft, .. }
|
|
| DraftUpdate::Branches { draft, .. } => *draft,
|
|
// Handled above; it targets no particular draft.
|
|
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
|
};
|
|
// 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
|
|
// whatever pane inherited the slot (#67 review).
|
|
let Some(tab_idx) = self
|
|
.state
|
|
.tabs
|
|
.iter()
|
|
.position(|t| matches!(&t.pane, Some(PaneRef::Draft(d)) if d.id == want))
|
|
else {
|
|
// The draft went away while the request was in flight.
|
|
return;
|
|
};
|
|
let Some(PaneRef::Draft(draft)) = self
|
|
.state
|
|
.tabs
|
|
.get_mut(tab_idx)
|
|
.and_then(|t| t.pane.as_mut())
|
|
else {
|
|
return;
|
|
};
|
|
let mut follow_up_branches = false;
|
|
match update {
|
|
DraftUpdate::Data {
|
|
info,
|
|
projects,
|
|
names,
|
|
..
|
|
} => {
|
|
if let Some(info) = info {
|
|
draft.home_dir.clone_from(&info.home_dir);
|
|
if !info.presets.is_empty() {
|
|
draft.set_presets(info.presets);
|
|
}
|
|
}
|
|
if let Some(projects) = projects {
|
|
draft.set_projects(projects, Utc::now());
|
|
}
|
|
if !names.is_empty() {
|
|
draft.seed_names(names);
|
|
}
|
|
}
|
|
DraftUpdate::Names { names, .. } => {
|
|
if !names.is_empty() {
|
|
draft.extend_names(names);
|
|
}
|
|
}
|
|
DraftUpdate::GitRoot { seq, root, .. } => {
|
|
draft.resolve_gitroot(seq, root);
|
|
follow_up_branches = draft.take_branch_load().is_some();
|
|
}
|
|
DraftUpdate::Branches { cwd, branches, .. } => {
|
|
draft.set_branches(&cwd, branches);
|
|
}
|
|
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
|
|
}
|
|
if follow_up_branches {
|
|
self.spawn_load_branches(tab_idx);
|
|
}
|
|
}
|
|
|
|
/// Fast-forward spawn (#67): one keystroke, every default resolved, no
|
|
/// form. The most recent project, its last command (or the first
|
|
/// non-custom preset), a freshly generated name, worktree on with a new
|
|
/// branch named after that name. Anything unresolvable — no projects
|
|
/// known yet, no command, no name — downgrades to a draft pane seeded
|
|
/// with whatever did resolve.
|
|
/// Load what the fast path needs without blocking the loop — the same
|
|
/// reason the draft loaders were moved off it (#67 review): a cold
|
|
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
|
|
/// freeze keys, frames and attached panes until it returns.
|
|
fn spawn_fast_forward(&self) {
|
|
let (daemon, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
|
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
|
let _ = tx.send(DraftUpdate::FastForward {
|
|
info: info.ok(),
|
|
projects: projects.ok(),
|
|
names,
|
|
});
|
|
});
|
|
}
|
|
|
|
async fn fast_forward_spawn(
|
|
&mut self,
|
|
info: Option<maestro_protocol::DaemonInfo>,
|
|
projects: Option<Vec<maestro_protocol::Project>>,
|
|
names: Vec<String>,
|
|
) {
|
|
// Keep the home dir: without it the pre-filled draft renders
|
|
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
|
// home, so the two draft entry points disagreed (#67 review).
|
|
let home_dir = info
|
|
.as_ref()
|
|
.map(|i| i.home_dir.clone())
|
|
.unwrap_or_default();
|
|
let presets = match info {
|
|
Some(info) if !info.presets.is_empty() => info.presets,
|
|
_ => Vec::new(),
|
|
};
|
|
let discovered = projects.unwrap_or_default();
|
|
let resolved =
|
|
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
|
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;
|
|
return;
|
|
}
|
|
// Unresolved defaults: a draft pre-filled with what did resolve.
|
|
self.state.open_draft_pane();
|
|
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
|
|
return;
|
|
};
|
|
draft.home_dir = home_dir;
|
|
if !presets.is_empty() {
|
|
draft.set_presets(presets);
|
|
}
|
|
draft.set_projects(discovered, Utc::now());
|
|
resolved.apply_to_draft(draft);
|
|
// A missing generated name still arrives here; seeding is a no-op
|
|
// once apply_to_draft has set a touched name.
|
|
draft.seed_names(names);
|
|
}
|
|
|
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
|
match self.daemon.create_session(&req).await {
|
|
Ok(session) => {
|
|
// Fill the pane in place with the new session (web parity,
|
|
// #57) and attach to it right away.
|
|
let sid = session.id;
|
|
self.state.complete_spawn(sid);
|
|
self.attach(sid);
|
|
}
|
|
Err(e) => {
|
|
// A failed POST belongs in the form it came from (#64), not a
|
|
// toast that dismisses on the next keystroke.
|
|
let message = e
|
|
.daemon_message()
|
|
.map(String::from)
|
|
.unwrap_or_else(|| e.to_string());
|
|
if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() {
|
|
draft.error = Some(format!("spawn failed: {message}"));
|
|
} else {
|
|
self.state.toast(format!("spawn failed: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn submit_kill(&mut self, sid: Uuid) {
|
|
match self.daemon.kill_session(sid, false).await {
|
|
Ok(outcome) => {
|
|
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}")),
|
|
}
|
|
}
|
|
|
|
/// Fetch the retained-worktree store without blocking the loop (#85) —
|
|
/// same off-loop pattern as the draft loaders.
|
|
fn spawn_load_worktrees(&self) {
|
|
let (daemon, _, _, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
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}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn redraw(
|
|
&mut self,
|
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
|
) -> Result<(), TuiError> {
|
|
let snap = self.catalog.snapshot();
|
|
let attached: HashSet<Uuid> = self
|
|
.state
|
|
.tabs
|
|
.iter()
|
|
.filter_map(crate::state::Tab::session)
|
|
.collect();
|
|
let sidebar = Sidebar {
|
|
sessions: snap.sessions.clone(),
|
|
highlight: self.state.sidebar_highlight,
|
|
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 pane = active_sid.and_then(|sid| {
|
|
self.emulators.get(sid).map(|h| Pane {
|
|
screen: h.screen.clone(),
|
|
status: PaneStatus::from(h.state()),
|
|
focused: matches!(self.state.focus, Focus::Pane),
|
|
})
|
|
});
|
|
let draft = match self.state.active_tab().pane.as_ref() {
|
|
Some(PaneRef::Draft(d)) => Some(d),
|
|
_ => None,
|
|
};
|
|
|
|
let inputs = RenderInputs {
|
|
sidebar: &sidebar,
|
|
tab_bar: &tab_bar,
|
|
pane: pane.as_ref(),
|
|
state: &self.state,
|
|
draft,
|
|
help: &self.help,
|
|
worktrees: Some(&self.worktrees),
|
|
};
|
|
|
|
let mut new_region = RegionMap::default();
|
|
terminal
|
|
.draw(|f| {
|
|
new_region = draw(f, inputs);
|
|
})
|
|
.map_err(|e| TuiError::Terminal(e.to_string()))?;
|
|
self.region = new_region;
|
|
|
|
// Resize propagation: record the active pane's rect, recompute the
|
|
// per-dimension min for every attached session, send Resize when
|
|
// changed.
|
|
if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) {
|
|
self.emulators.resize_tracker_mut().set_pane_size(
|
|
sid,
|
|
"active",
|
|
(rect.width, rect.height),
|
|
);
|
|
}
|
|
let attached_sids: Vec<Uuid> = self.emulators.ids().collect();
|
|
for sid in attached_sids {
|
|
if let Some(size) = self.emulators.resize_tracker().session_size(sid) {
|
|
if self.last_sent_size.get(&sid).copied() != Some(size) {
|
|
if let Some(h) = self.emulators.get(sid) {
|
|
let _ = h.tx.try_send(SessionInput::Resize {
|
|
cols: size.0,
|
|
rows: size.1,
|
|
});
|
|
}
|
|
self.last_sent_size.insert(sid, size);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
|
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
|
/// visual sync with what the user sees.
|
|
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
|
snap.sessions.iter().map(|s| s.id).collect()
|
|
}
|
|
|
|
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
|
/// with no byte representation (mouse events). A bracketed paste is forwarded
|
|
/// verbatim — the remote readline reassembles it, since the pane's PTY also
|
|
/// has bracketed paste enabled.
|
|
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
|
match key {
|
|
InputKey::Char { c, ctrl, alt, .. } => {
|
|
let mut out = Vec::with_capacity(8);
|
|
if *alt {
|
|
out.push(0x1b);
|
|
}
|
|
if *ctrl && c.is_ascii_alphabetic() {
|
|
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
|
} else {
|
|
let mut b = [0u8; 4];
|
|
let s = c.encode_utf8(&mut b);
|
|
out.extend_from_slice(s.as_bytes());
|
|
}
|
|
Some(out)
|
|
}
|
|
InputKey::Enter => Some(b"\r".to_vec()),
|
|
InputKey::Tab => Some(b"\t".to_vec()),
|
|
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
|
|
InputKey::Backspace => Some(vec![0x7f]),
|
|
InputKey::Esc => Some(vec![0x1b]),
|
|
InputKey::Up => Some(b"\x1b[A".to_vec()),
|
|
InputKey::Down => Some(b"\x1b[B".to_vec()),
|
|
InputKey::Right => Some(b"\x1b[C".to_vec()),
|
|
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
|
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
|
InputKey::End => Some(b"\x1b[F".to_vec()),
|
|
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
|
InputKey::Paste(text) => {
|
|
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
|
// to an app that enabled bracketed paste, so a multi-line paste
|
|
// would execute line by line instead of arriving as one block.
|
|
let mut out = Vec::with_capacity(text.len() + 12);
|
|
out.extend_from_slice(b"\x1b[200~");
|
|
out.extend_from_slice(text.as_bytes());
|
|
out.extend_from_slice(b"\x1b[201~");
|
|
Some(out)
|
|
}
|
|
InputKey::Mouse { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
/// own `[tui.keymap.*]` overrides).
|
|
fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
|
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
|
|
let mut out = vec![format!("{label} mode"), String::new()];
|
|
for (input, action) in cfg.keymap.entries(mode) {
|
|
out.push(format!(" {input:<24} {action}"));
|
|
}
|
|
out.push(String::new());
|
|
out
|
|
}
|
|
let leader = cfg.leader.canonical();
|
|
let mut lines = vec![
|
|
format!("Leader: {leader} (Insert → Normal)"),
|
|
String::new(),
|
|
];
|
|
lines.extend(section(Mode::Normal, "Normal", cfg));
|
|
lines.extend(section(Mode::Insert, "Insert", cfg));
|
|
lines.push(String::from("(any key dismisses this overlay)"));
|
|
lines
|
|
}
|
|
|
|
/// A completed background load, applied on the event loop.
|
|
#[derive(Debug)]
|
|
enum DraftUpdate {
|
|
Data {
|
|
draft: u64,
|
|
info: Option<maestro_protocol::DaemonInfo>,
|
|
projects: Option<Vec<maestro_protocol::Project>>,
|
|
names: Vec<String>,
|
|
},
|
|
Names {
|
|
draft: u64,
|
|
names: Vec<String>,
|
|
},
|
|
GitRoot {
|
|
draft: u64,
|
|
seq: u32,
|
|
root: Option<PathBuf>,
|
|
},
|
|
Branches {
|
|
draft: u64,
|
|
cwd: PathBuf,
|
|
branches: Vec<BranchInfo>,
|
|
},
|
|
/// A fast-forward's data arrived; resolution and spawn happen on the loop.
|
|
FastForward {
|
|
info: Option<maestro_protocol::DaemonInfo>,
|
|
projects: Option<Vec<maestro_protocol::Project>>,
|
|
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`.
|
|
async fn fetch_names(http: &reqwest::Client, base: &str, count: usize) -> Vec<String> {
|
|
let url = format!(
|
|
"{}{}",
|
|
maestro_protocol::routes::normalize_base(base),
|
|
routes::NAMES
|
|
);
|
|
match http
|
|
.get(url)
|
|
.query(&[(routes::COUNT_QUERY, count)])
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) => resp
|
|
.json::<NameBatch>()
|
|
.await
|
|
.map(|batch| batch.names)
|
|
.unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// `GET /api/branches?path=`. On failure the picker stays empty and "new
|
|
/// branch" mode still works.
|
|
async fn fetch_branches(
|
|
http: &reqwest::Client,
|
|
base: &str,
|
|
path: &std::path::Path,
|
|
) -> Vec<BranchInfo> {
|
|
let url = format!(
|
|
"{}{}",
|
|
maestro_protocol::routes::normalize_base(base),
|
|
routes::BRANCHES
|
|
);
|
|
match http
|
|
.get(url)
|
|
.query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())])
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) => resp.json::<Vec<BranchInfo>>().await.unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::action::Action;
|
|
use crate::draft::DraftField;
|
|
use crate::keymap::Mode;
|
|
use maestro_config::TuiSection;
|
|
|
|
fn make_app() -> App {
|
|
make_app_at("http://127.0.0.1:7070")
|
|
}
|
|
|
|
fn make_app_at(base: &str) -> App {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
App::new(cfg, CatalogStore::default(), DaemonClient::new(base))
|
|
}
|
|
|
|
#[test]
|
|
fn enter_insert_then_leave() {
|
|
let mut app = make_app();
|
|
assert_eq!(app.state.mode, Mode::Normal);
|
|
app.dispatch(Action::EnterInsert);
|
|
assert_eq!(app.state.mode, Mode::Insert);
|
|
app.dispatch(Action::LeaveInsert);
|
|
assert_eq!(app.state.mode, Mode::Normal);
|
|
}
|
|
|
|
#[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]
|
|
fn show_help_opens_overlay() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::ShowHelp);
|
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
|
|
}
|
|
|
|
#[test]
|
|
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::NewDraftPane);
|
|
assert!(matches!(
|
|
app.state.active_tab().pane,
|
|
Some(PaneRef::Draft(_))
|
|
));
|
|
assert_eq!(app.state.focus, Focus::Pane);
|
|
assert_eq!(app.state.mode, Mode::Insert);
|
|
assert!(app.state.overlay().is_none(), "no overlay involved");
|
|
}
|
|
|
|
/// A tab already showing a session gets a new tab for the draft.
|
|
#[test]
|
|
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
|
let sid = Uuid::from_u128(10);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
app.dispatch(Action::AttachHighlighted);
|
|
|
|
app.dispatch(Action::NewDraftPane);
|
|
assert_eq!(app.state.tabs.len(), 2);
|
|
assert_eq!(app.state.active_tab_idx, 1);
|
|
assert!(matches!(
|
|
app.state.active_tab().pane,
|
|
Some(PaneRef::Draft(_))
|
|
));
|
|
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
|
}
|
|
|
|
/// Seed the catalog with one session and highlight it.
|
|
fn app_with_highlighted_session(sid: Uuid) -> App {
|
|
use chrono::Utc;
|
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
|
let mut app = make_app();
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![Session {
|
|
id: sid,
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/"),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
last_activity_at: Utc::now(),
|
|
activity_since: chrono::Utc::now(),
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
}],
|
|
});
|
|
app.dispatch(Action::SidebarNext);
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn attach_highlighted_asks_for_an_attach() {
|
|
let sid = Uuid::from_u128(11);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
assert_eq!(
|
|
app.dispatch(Action::AttachHighlighted),
|
|
vec![Effect::Attach(sid)]
|
|
);
|
|
assert_eq!(app.state.active_tab().session(), Some(sid));
|
|
}
|
|
|
|
#[test]
|
|
fn confirming_a_kill_asks_for_the_kill() {
|
|
let sid = Uuid::from_u128(12);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
app.dispatch(Action::KillHighlighted);
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
|
c: 'y',
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
});
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert_eq!(effects, vec![Effect::Kill(sid)]);
|
|
assert!(app.state.overlay().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn declining_a_kill_asks_for_nothing() {
|
|
let sid = Uuid::from_u128(13);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
app.dispatch(Action::KillHighlighted);
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert!(effects.is_empty());
|
|
assert!(app.state.overlay().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn new_draft_pane_schedules_a_data_load() {
|
|
let mut app = make_app();
|
|
assert_eq!(
|
|
app.dispatch(Action::NewDraftPane),
|
|
vec![Effect::LoadDraft(0)]
|
|
);
|
|
}
|
|
|
|
/// The fast-forward keystroke itself is side-effect-free until its
|
|
/// daemon fetches come back.
|
|
#[test]
|
|
fn fast_forward_spawn_dispatches_the_fast_forward_effect() {
|
|
let mut app = make_app();
|
|
assert_eq!(
|
|
app.dispatch(Action::FastForwardSpawn),
|
|
vec![Effect::FastForward]
|
|
);
|
|
}
|
|
|
|
/// Every default resolves: the POST goes straight out — most recent
|
|
/// project, its last command, generated name, worktree branch named
|
|
/// after it (#67).
|
|
#[tokio::test]
|
|
async fn fast_forward_spawns_immediately_when_every_default_resolves() {
|
|
use axum::routing::{get, post};
|
|
use axum::Json;
|
|
use serde_json::json;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
async fn info() -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
|
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
|
}))
|
|
}
|
|
async fn projects() -> Json<serde_json::Value> {
|
|
Json(json!([
|
|
{"path": "/repos/old", "name": "old",
|
|
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "zsh"},
|
|
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
|
|
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"}
|
|
]))
|
|
}
|
|
async fn names() -> Json<serde_json::Value> {
|
|
Json(json!({"names": ["crimson-otter"]}))
|
|
}
|
|
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
|
|
let seen = posted.clone();
|
|
async fn spawn_session(
|
|
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> Json<serde_json::Value> {
|
|
seen.lock().unwrap().push(body);
|
|
Json(json!({
|
|
"id": uuid::Uuid::from_u128(77).to_string(),
|
|
"cwd": "/repos/fresh", "command": "aider",
|
|
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
|
|
}))
|
|
}
|
|
|
|
let app_router = axum::Router::new()
|
|
.route(maestro_protocol::routes::INFO, get(info))
|
|
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
|
.route(maestro_protocol::routes::NAMES, get(names))
|
|
.route(
|
|
maestro_protocol::routes::SESSIONS,
|
|
post(spawn_session).with_state(seen),
|
|
);
|
|
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::FastForwardSpawn);
|
|
// The fast path loads off the event loop now; the run loop drains the
|
|
// channel, so do that by hand.
|
|
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;
|
|
|
|
let posts = posted.lock().unwrap();
|
|
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
|
|
let req = &posts[0];
|
|
assert_eq!(req["cwd"], json!("/repos/fresh"));
|
|
assert_eq!(req["command"], json!("aider"));
|
|
assert_eq!(req["metadata"]["name"], json!("crimson-otter"));
|
|
assert_eq!(req["worktree"]["base_repo"], json!("/repos/fresh"));
|
|
assert_eq!(req["worktree"]["branch"], json!("crimson-otter"));
|
|
assert_eq!(req["worktree"]["create_branch"], json!(true));
|
|
}
|
|
|
|
/// No projects known yet: nothing can resolve, so the keystroke opens a
|
|
/// draft pane (focused, Insert mode) instead of guessing (#67).
|
|
#[tokio::test]
|
|
async fn unresolvable_defaults_open_a_prefilled_draft_instead() {
|
|
use axum::routing::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/u",
|
|
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
|
}))
|
|
}
|
|
async fn projects() -> Json<serde_json::Value> {
|
|
Json(json!([]))
|
|
}
|
|
async fn names() -> Json<serde_json::Value> {
|
|
Json(json!({"names": ["amber-falcon"]}))
|
|
}
|
|
|
|
let app_router = axum::Router::new()
|
|
.route(maestro_protocol::routes::INFO, get(info))
|
|
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
|
.route(maestro_protocol::routes::NAMES, get(names));
|
|
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::FastForwardSpawn);
|
|
// The fast path loads off the event loop now; the run loop drains the
|
|
// channel, so do that by hand.
|
|
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!(
|
|
matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))),
|
|
"the fallback is a draft pane"
|
|
);
|
|
assert_eq!(app.state.focus, Focus::Pane);
|
|
assert_eq!(app.state.mode, Mode::Insert);
|
|
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
|
panic!("expected a draft pane");
|
|
};
|
|
// Whatever did resolve (nothing project-side) is seeded; the
|
|
// generated name still lands so the form is ready to go.
|
|
assert_eq!(draft.name.value(), "amber-falcon");
|
|
assert_eq!(draft.command(), "claude");
|
|
}
|
|
|
|
/// A valid form flows through: pick a project (set directly — the picker
|
|
/// itself is covered in `draft.rs`), select the custom tile, type a
|
|
/// command, then submit from the Spawn row.
|
|
#[test]
|
|
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::NewDraftPane);
|
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
|
panic!("expected a draft pane");
|
|
};
|
|
|
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.selected_preset = draft.presets.len() - 1; // custom
|
|
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
|
for c in "claude".chars() {
|
|
app.handle_draft_input(&InputKey::char(c));
|
|
}
|
|
app.handle_draft_input(&InputKey::Tab); // → dir picker
|
|
app.handle_draft_input(&InputKey::Tab); // → name
|
|
for c in "crimson-otter".chars() {
|
|
app.handle_draft_input(&InputKey::char(c));
|
|
}
|
|
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
|
|
app.handle_draft_input(&InputKey::Tab); // → submit
|
|
|
|
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
|
[Effect::Spawn(req)] => {
|
|
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
|
assert_eq!(req.command, "claude");
|
|
assert_eq!(
|
|
req.metadata.get("name").and_then(|v| v.as_str()),
|
|
Some("crimson-otter"),
|
|
"the generated name rides along as metadata.name"
|
|
);
|
|
}
|
|
other => panic!("expected a single Spawn effect, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Editing the raw path schedules a `/api/gitroot` probe only once the
|
|
/// keystroke burst settles (#74): nothing fires inside the debounce
|
|
/// window; the heartbeat picks the due probe up afterwards.
|
|
#[test]
|
|
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::NewDraftPane);
|
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
|
panic!("expected a draft pane");
|
|
};
|
|
draft.cwd_raw_visible = true;
|
|
draft.focused_field = DraftField::CwdRaw;
|
|
|
|
// A burst of keystrokes fires nothing.
|
|
for c in "/opt/tooling".chars() {
|
|
assert!(app.handle_draft_input(&InputKey::char(c)).is_empty());
|
|
assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty());
|
|
}
|
|
|
|
// Once the quiet period passes, exactly one probe is due.
|
|
let due = std::time::Instant::now()
|
|
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
|
|
match app.poll_due_probes_at(due).as_slice() {
|
|
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
|
|
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
|
|
}
|
|
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
|
|
}
|
|
assert!(
|
|
app.poll_due_probes_at(due).is_empty(),
|
|
"the probe is consumed once"
|
|
);
|
|
}
|
|
|
|
/// Switching the worktree branch source to Existing schedules one
|
|
/// `/api/branches` load for the current target (#65).
|
|
#[test]
|
|
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::NewDraftPane);
|
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
|
panic!("expected a draft pane");
|
|
};
|
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
|
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
|
draft.focused_field = DraftField::BranchMode;
|
|
|
|
let effects = app.handle_draft_input(&InputKey::Right);
|
|
assert!(
|
|
matches!(effects.as_slice(), [Effect::LoadBranches(0)]),
|
|
"{effects:?}"
|
|
);
|
|
}
|
|
|
|
/// Submitting with no directory names the missing field inside the form
|
|
/// and posts nothing (#64).
|
|
#[test]
|
|
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::NewDraftPane);
|
|
// Walk to Submit without picking anything.
|
|
for _ in 0..4 {
|
|
app.handle_draft_input(&InputKey::Tab);
|
|
}
|
|
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
|
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
|
panic!("expected a draft pane");
|
|
};
|
|
assert_eq!(
|
|
draft.error.as_deref(),
|
|
Some("Pick a working directory."),
|
|
"the refusal shows in the form"
|
|
);
|
|
|
|
// With a directory but an empty custom command, it names the command.
|
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
|
panic!();
|
|
};
|
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
|
|
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
|
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
|
panic!();
|
|
};
|
|
assert_eq!(
|
|
draft.error.as_deref(),
|
|
Some("Pick a command (or fill the custom command).")
|
|
);
|
|
}
|
|
|
|
/// A failed POST lands back in the form as an error line (#64), not a
|
|
/// toast that the next keystroke dismisses.
|
|
#[tokio::test]
|
|
async fn a_failed_spawn_shows_the_daemons_error_in_the_form() {
|
|
use std::time::Duration;
|
|
let mut app = App::new(
|
|
TuiConfig::load(&TuiSection::default()).unwrap(),
|
|
CatalogStore::default(),
|
|
DaemonClient::with_timeouts(
|
|
"http://127.0.0.1:9",
|
|
Duration::from_millis(200),
|
|
Duration::from_millis(500),
|
|
),
|
|
);
|
|
app.dispatch(Action::NewDraftPane);
|
|
let req = CreateSessionRequest {
|
|
cwd: std::path::PathBuf::from("/srv/work"),
|
|
command: "bash".into(),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
};
|
|
app.submit_spawn(req).await;
|
|
|
|
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
|
panic!("expected a draft pane");
|
|
};
|
|
let error = draft
|
|
.error
|
|
.as_deref()
|
|
.expect("spawn failure must reach the form");
|
|
assert!(error.starts_with("spawn failed"), "{error}");
|
|
assert!(
|
|
matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))),
|
|
"no kill-confirm style overlay took over"
|
|
);
|
|
}
|
|
|
|
/// The full data load against a live daemon stub: presets replace the
|
|
/// built-ins, projects land sorted, and the first generated name seeds
|
|
/// the field.
|
|
#[tokio::test]
|
|
async fn load_draft_data_fills_presets_projects_and_names() {
|
|
use axum::routing::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/u",
|
|
"presets": [
|
|
{"id": "aider", "label": "Aider", "command": "aider"},
|
|
{"id": "custom", "label": "Custom…", "command": ""}
|
|
]
|
|
}))
|
|
}
|
|
async fn projects() -> Json<serde_json::Value> {
|
|
Json(json!([
|
|
{"path": "/repos/maestro", "name": "maestro",
|
|
"last_activity": chrono::Utc::now().to_rfc3339(),
|
|
"last_command": "claude"}
|
|
]))
|
|
}
|
|
async fn names() -> Json<serde_json::Value> {
|
|
Json(json!({"names": ["crimson-otter", "amber-falcon"]}))
|
|
}
|
|
|
|
let app_router = axum::Router::new()
|
|
.route(maestro_protocol::routes::INFO, get(info))
|
|
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
|
.route(maestro_protocol::routes::NAMES, get(names));
|
|
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::NewDraftPane);
|
|
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
|
|
// `perform` only spawns the load — it must not block the event loop on
|
|
// the network (#67 review). The result arrives on the draft channel,
|
|
// which the run loop drains; do that by hand here.
|
|
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;
|
|
|
|
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
|
|
panic!("expected a draft pane");
|
|
};
|
|
assert_eq!(draft.command(), "aider");
|
|
assert_eq!(draft.groups.len(), 1);
|
|
assert_eq!(draft.groups[0].main.name, "maestro");
|
|
assert_eq!(draft.home_dir, "/home/u");
|
|
assert_eq!(draft.name.value(), "crimson-otter");
|
|
}
|
|
|
|
/// Keys fed while no draft exists must be ignored, not panic.
|
|
#[test]
|
|
fn draft_input_is_a_no_op_without_a_draft() {
|
|
let mut app = make_app();
|
|
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
|
|
}
|
|
|
|
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
|
/// failure message stayed on screen until another overlay replaced it.
|
|
#[test]
|
|
fn a_toast_is_dismissed_by_the_next_key_without_eating_it() {
|
|
let mut app = make_app();
|
|
app.state.toast("spawn failed: nope");
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
|
c: 'j',
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
});
|
|
assert_eq!(
|
|
decision,
|
|
OverlayDecision::Pass,
|
|
"the keystroke should still reach the keymap"
|
|
);
|
|
assert!(effects.is_empty());
|
|
assert!(app.state.overlay().is_none(), "the toast must be gone");
|
|
}
|
|
|
|
#[test]
|
|
fn forwarding_is_a_no_op_without_an_attached_pane() {
|
|
let mut app = make_app();
|
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
|
}
|
|
|
|
/// A draft pane is not a session: forwarding must not try to reach it.
|
|
#[test]
|
|
fn forwarding_is_a_no_op_with_a_draft_pane() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::NewDraftPane);
|
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
|
}
|
|
|
|
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
|
/// paste into an attached session vanished once bracketed paste was
|
|
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
|
/// remote shell reads the payload as typed keys and runs a multi-line
|
|
/// paste line by line.
|
|
#[test]
|
|
fn a_bracketed_paste_reaches_the_session_with_its_markers() {
|
|
assert_eq!(
|
|
encode_key(&InputKey::Paste("cd /tmp/foo\nls".into())),
|
|
Some(b"\x1b[200~cd /tmp/foo\nls\x1b[201~".to_vec())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_events_have_no_wire_encoding() {
|
|
use crate::input::{MouseKind, Region};
|
|
assert!(encode_key(&InputKey::Mouse {
|
|
kind: MouseKind::Click,
|
|
region: Region::Pane,
|
|
col: 3,
|
|
row: 4,
|
|
})
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn kill_highlighted_opens_confirm() {
|
|
use chrono::Utc;
|
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
|
let mut app = make_app();
|
|
let sid = Uuid::from_u128(7);
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![Session {
|
|
id: sid,
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/"),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
last_activity_at: chrono::Utc::now(),
|
|
activity_since: chrono::Utc::now(),
|
|
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
}],
|
|
});
|
|
app.dispatch(Action::KillHighlighted);
|
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
|
}
|
|
|
|
// -- retained worktrees (#85) -------------------------------------------
|
|
|
|
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
|
use chrono::TimeZone;
|
|
maestro_protocol::RetainedWorktree {
|
|
id: Uuid::from_u128(id),
|
|
path: "/home/naps/tea/maestro/worktrees/feat/login".into(),
|
|
base_repo: "/home/naps/tea/maestro".into(),
|
|
branch: "feat/login".into(),
|
|
reason: maestro_protocol::RetainedReason::ForceKilled,
|
|
retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
|
in_the_way: maestro_protocol::InTheWay {
|
|
uncommitted_files: 2,
|
|
untracked_files: 1,
|
|
unpushed_commits: 3,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn app_with_one_retained_worktree() -> App {
|
|
let mut app = make_app();
|
|
app.worktrees.home_dir = "/home/naps".into();
|
|
app.worktrees.apply(vec![sample_worktree(42)]);
|
|
app.state.show_overlay(OverlayKind::Worktrees);
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn show_worktrees_opens_the_view_and_fetches() {
|
|
let mut app = make_app();
|
|
assert_eq!(
|
|
app.dispatch(Action::ShowWorktrees),
|
|
vec![Effect::LoadWorktrees]
|
|
);
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn declining_a_reclaim_returns_to_the_list() {
|
|
let mut app = app_with_one_retained_worktree();
|
|
app.handle_overlay_input(&InputKey::char('r'));
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::char('n'));
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert!(effects.is_empty());
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
|
|
}
|
|
|
|
#[test]
|
|
fn j_and_k_move_the_highlight_inside_the_view() {
|
|
let mut app = make_app();
|
|
app.worktrees
|
|
.apply(vec![sample_worktree(1), sample_worktree(2)]);
|
|
// Distinct ids so movement is observable.
|
|
app.worktrees.items[1].branch = "other".into();
|
|
app.state.show_overlay(OverlayKind::Worktrees);
|
|
|
|
app.handle_overlay_input(&InputKey::char('j'));
|
|
assert_eq!(app.worktrees.selected().unwrap().branch, "other");
|
|
app.handle_overlay_input(&InputKey::char('k'));
|
|
assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login");
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|