This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/state.rs
T
Miguel PalhasandClaude Opus 5 f01771568b
ci / rust (pull_request) Successful in 1m57s
ci / rust (push) Successful in 1m54s
refactor(maestro): make the TUI dispatch a reducer over effects
dispatch was `async fn -> ()` with reqwest calls and task spawns inlined,
so it could not report intent and the spawn, kill and attach arms had no
test. It now returns Vec<Effect> and App::run performs them.

Two bugs the old shape produced, both fixed and covered:

- The toast overlay could not be dismissed. handle_overlay_input returned
  Pass for Toast(_) and nothing cleared it, so "spawn failed: ..." stayed
  until another overlay replaced it. It now clears and still returns Pass,
  so the keystroke does its normal job instead of being eaten.
- Closing a tab leaked its emulator. Only submit_kill released one, so a
  closed tab's WebSocket task and vt100 parser outlived it forever.
  close_tab now reports the session it dropped — and holds onto it when
  another tab still shows the same one.

AppState::overlay is private behind show_overlay/dismiss_overlay/toast.
Four sites poked the field directly; that is why the toast bug existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:08:08 +01:00

351 lines
11 KiB
Rust

//! `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
//! session), an active tab index, focus split between sidebar and pane, the
//! current input mode, and an optional overlay (spawn modal, kill confirm,
//! help, transient toast).
//!
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
use uuid::Uuid;
use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Sidebar,
Pane,
}
#[derive(Debug, Clone)]
pub struct Tab {
pub name: String,
pub pane: Option<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
Spawn,
KillConfirm(Uuid),
Help,
Toast(String),
}
#[derive(Debug)]
pub struct AppState {
pub tabs: Vec<Tab>,
pub active_tab_idx: usize,
pub focus: Focus,
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<Uuid>,
/// Private: every transition goes through [`AppState::show_overlay`],
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
/// from four call sites is what left a toast on screen with no way to
/// clear it.
overlay: Option<OverlayKind>,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
Self {
tabs: vec![Tab {
name: "1".into(),
pane: None,
}],
active_tab_idx: 0,
focus: Focus::Sidebar,
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
overlay: None,
}
}
pub fn overlay(&self) -> Option<&OverlayKind> {
self.overlay.as_ref()
}
pub fn show_overlay(&mut self, kind: OverlayKind) {
self.overlay = Some(kind);
}
/// Clear whatever overlay is up. Returns true if one was showing.
pub fn dismiss_overlay(&mut self) -> bool {
self.overlay.take().is_some()
}
/// Surface a transient message. Replaces any overlay currently up.
pub fn toast(&mut self, message: impl Into<String>) {
self.overlay = Some(OverlayKind::Toast(message.into()));
}
pub fn active_tab(&self) -> &Tab {
&self.tabs[self.active_tab_idx]
}
pub fn active_tab_mut(&mut self) -> &mut Tab {
&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.
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;
}
// Only release it if no other tab still shows the same session.
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
closed.pane.filter(|_| !still_shown)
}
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
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// given ordered list of selectable session ids. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
if order.is_empty() {
return;
}
let len = order.len() as i32;
let cur = self
.sidebar_highlight
.and_then(|id| order.iter().position(|x| *x == id))
.map(|i| i as i32)
.unwrap_or(-1);
let next = (cur + delta).rem_euclid(len) as usize;
self.sidebar_highlight = Some(order[next]);
}
/// Toggle sidebar visibility.
pub fn toggle_sidebar(&mut self) {
self.sidebar_visible = !self.sidebar_visible;
if !self.sidebar_visible {
self.focus = Focus::Pane;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_app_starts_with_one_empty_tab_focused_on_sidebar() {
let state = AppState::new();
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.tabs[0].pane, None);
assert_eq!(state.focus, Focus::Sidebar);
assert_eq!(state.mode, Mode::Normal);
assert!(state.sidebar_visible);
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]
fn move_sidebar_highlight_walks_order_and_wraps() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let c = Uuid::from_u128(3);
let order = vec![a, b, c];
// No prior highlight + delta +1 lands on first item.
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(a));
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(b));
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(c));
// Wraps forward.
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(a));
// Wraps backward.
state.move_sidebar_highlight(&order, -1);
assert_eq!(state.sidebar_highlight, Some(c));
}
#[test]
fn move_sidebar_highlight_is_noop_on_empty_order() {
let mut state = AppState::new();
state.move_sidebar_highlight(&[], 1);
assert_eq!(state.sidebar_highlight, None);
}
#[test]
fn attach_binds_focuses_pane_and_enters_insert_mode() {
let mut state = AppState::new();
let sid = Uuid::from_u128(7);
state.attach_to_focused(sid);
assert_eq!(state.tabs[0].pane, Some(sid));
assert_eq!(state.focus, Focus::Pane);
// The user expects to type into the session right away; the leader
// (default ctrl-a) is the way back.
assert_eq!(state.mode, Mode::Insert);
}
#[test]
fn toggle_sidebar_moves_focus_to_pane_when_hiding() {
let mut state = AppState::new();
state.toggle_sidebar();
assert!(!state.sidebar_visible);
assert_eq!(state.focus, Focus::Pane);
state.toggle_sidebar();
assert!(state.sidebar_visible);
// Doesn't force focus back; sidebar shown but focus stays where it was.
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(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(sid);
state.new_tab();
state.active_tab_mut().pane = Some(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(Uuid::from_u128(5));
assert_eq!(state.close_tab(), None);
assert_eq!(state.tabs.len(), 1);
}
#[test]
fn dismiss_overlay_reports_whether_one_was_showing() {
let mut state = AppState::new();
assert!(!state.dismiss_overlay());
state.toast("boom");
assert!(state.dismiss_overlay());
assert!(state.overlay().is_none());
}
}