661 lines
24 KiB
Rust
661 lines
24 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`, `OpenSpawnModal` submit
|
|
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
|
//! `dispatch` / `handle_overlay_input`.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::io::Stdout;
|
|
use std::time::Duration;
|
|
|
|
use crossterm::event::{Event as CtEvent, EventStream};
|
|
use futures_util::StreamExt;
|
|
use maestro_protocol::{CreateSessionRequest, DaemonClient};
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::Terminal;
|
|
use uuid::Uuid;
|
|
|
|
use crate::action::Action;
|
|
use crate::catalog::CatalogStore;
|
|
use crate::config::TuiConfig;
|
|
use crate::emulators::SessionEmulators;
|
|
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
|
use crate::keymap::Mode;
|
|
use crate::overlays::{HelpOverlay, SpawnModal};
|
|
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};
|
|
use crate::tabs::TabBar;
|
|
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,
|
|
spawn: SpawnModal,
|
|
help: HelpOverlay,
|
|
chord: ChordState,
|
|
daemon: DaemonClient,
|
|
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> },
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
|
|
let chord_timeout = cfg.chord_timeout;
|
|
let help = HelpOverlay {
|
|
lines: render_help_lines(&cfg),
|
|
};
|
|
Self {
|
|
cfg,
|
|
catalog,
|
|
emulators: SessionEmulators::default(),
|
|
state: AppState::new(),
|
|
spawn: SpawnModal::default(),
|
|
help,
|
|
chord: ChordState::new(chord_timeout),
|
|
daemon,
|
|
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(());
|
|
}
|
|
}
|
|
_ = self.catalog.changed() => {
|
|
// Render on next loop iteration.
|
|
}
|
|
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
|
// 30 Hz heartbeat.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
{
|
|
if let Some(effect) = self.forward_to_session(&key) {
|
|
self.perform(vec![effect]).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::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>) {
|
|
use crate::overlays::SpawnInputResult;
|
|
match self.state.overlay().cloned() {
|
|
Some(OverlayKind::Spawn) => {
|
|
let mut effects = Vec::new();
|
|
match self.spawn.on_input(key) {
|
|
SpawnInputResult::Consumed => {}
|
|
SpawnInputResult::Cancel => {
|
|
self.state.dismiss_overlay();
|
|
}
|
|
SpawnInputResult::Submit => {
|
|
effects.push(Effect::Spawn(CreateSessionRequest {
|
|
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
|
|
command: self.spawn.command.value().to_string(),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
}));
|
|
}
|
|
}
|
|
(OverlayDecision::Consumed, effects)
|
|
}
|
|
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::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::OpenSpawnModal => {
|
|
self.spawn = SpawnModal {
|
|
cwd: Default::default(),
|
|
command: Default::default(),
|
|
focused_field: Default::default(),
|
|
};
|
|
self.state.show_overlay(OverlayKind::Spawn);
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
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_tab().pane?;
|
|
self.emulators.get(sid)?;
|
|
let bytes = 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());
|
|
}
|
|
out
|
|
}
|
|
InputKey::Enter => b"\r".to_vec(),
|
|
InputKey::Tab => b"\t".to_vec(),
|
|
InputKey::BackTab => b"\x1b[Z".to_vec(),
|
|
InputKey::Backspace => vec![0x7f],
|
|
InputKey::Esc => vec![0x1b],
|
|
InputKey::Up => b"\x1b[A".to_vec(),
|
|
InputKey::Down => b"\x1b[B".to_vec(),
|
|
InputKey::Right => b"\x1b[C".to_vec(),
|
|
InputKey::Left => b"\x1b[D".to_vec(),
|
|
InputKey::Home => b"\x1b[H".to_vec(),
|
|
InputKey::End => b"\x1b[F".to_vec(),
|
|
InputKey::Delete => b"\x1b[3~".to_vec(),
|
|
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
|
|
};
|
|
Some(Effect::Forward { id: sid, bytes })
|
|
}
|
|
|
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
|
match self.daemon.create_session(&req).await {
|
|
Ok(_) => {
|
|
self.state.dismiss_overlay();
|
|
}
|
|
Err(e) => self.state.toast(format!("spawn failed: {e}")),
|
|
}
|
|
}
|
|
|
|
async fn submit_kill(&mut self, sid: Uuid) {
|
|
match self.daemon.kill_session(sid).await {
|
|
Ok(()) => self.release_session(sid),
|
|
Err(e) => self.state.toast(format!("kill failed: {e}")),
|
|
}
|
|
}
|
|
|
|
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(|t| t.pane).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_tab().pane;
|
|
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 inputs = RenderInputs {
|
|
sidebar: &sidebar,
|
|
tab_bar: &tab_bar,
|
|
pane: pane.as_ref(),
|
|
state: &self.state,
|
|
spawn: &self.spawn,
|
|
help: &self.help,
|
|
};
|
|
|
|
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()
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::action::Action;
|
|
use crate::keymap::Mode;
|
|
use maestro_config::TuiSection;
|
|
|
|
fn make_app() -> App {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
App::new(
|
|
cfg,
|
|
CatalogStore::default(),
|
|
DaemonClient::new("http://127.0.0.1:7070"),
|
|
)
|
|
}
|
|
|
|
#[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 open_spawn_modal_sets_overlay() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
|
|
}
|
|
|
|
/// 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(),
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
last_activity_at: Utc::now(),
|
|
current_cwd: 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().pane, 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 submitting_the_spawn_modal_carries_the_typed_fields() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
|
|
app.spawn.command = crate::text_input::TextInput::with_value("claude");
|
|
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
|
|
|
|
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
|
match effects.as_slice() {
|
|
[Effect::Spawn(req)] => {
|
|
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
|
assert_eq!(req.command, "claude");
|
|
}
|
|
other => panic!("expected one Spawn effect, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
|
|
#[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(),
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
last_activity_at: chrono::Utc::now(),
|
|
|
|
current_cwd: None,
|
|
}],
|
|
});
|
|
app.dispatch(Action::KillHighlighted);
|
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
|
}
|
|
}
|