w opens the view; r confirms reclaim and says the branch survives. The in-the-way summary uses InTheWay's Display impl so the TUI cannot drift from the CLI's wording.
459 lines
15 KiB
Rust
459 lines
15 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 or a draft form), an active tab index, focus split between sidebar
|
|
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
|
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
|
//! pane is a draft pane (issue #63).
|
|
//!
|
|
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
|
|
|
use uuid::Uuid;
|
|
|
|
use crate::draft::DraftPane;
|
|
use crate::keymap::Mode;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Focus {
|
|
Sidebar,
|
|
Pane,
|
|
}
|
|
|
|
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
|
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
|
// that boxing every access costs more than the copy does.
|
|
#[allow(clippy::large_enum_variant)]
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum PaneRef {
|
|
Session(Uuid),
|
|
Draft(DraftPane),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Tab {
|
|
pub name: String,
|
|
pub pane: Option<PaneRef>,
|
|
}
|
|
|
|
impl Tab {
|
|
/// The session this tab shows, if it shows one. Draft panes have none.
|
|
pub fn session(&self) -> Option<Uuid> {
|
|
match &self.pane {
|
|
Some(PaneRef::Session(sid)) => Some(*sid),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum OverlayKind {
|
|
KillConfirm(Uuid),
|
|
Help,
|
|
Toast(String),
|
|
/// The retained-worktrees view (#85); its entries live on `App` because
|
|
/// they are daemon data, not UI state.
|
|
Worktrees,
|
|
/// Reclaim confirmation for the worktree with this id (#85), guarding
|
|
/// the destructive key like [`OverlayKind::KillConfirm`] does.
|
|
ReclaimConfirm {
|
|
id: Uuid,
|
|
branch: String,
|
|
/// [`maestro_protocol::InTheWay`] as of when the confirm was opened.
|
|
blockers: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
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. A draft pane holds no session and
|
|
/// releases nothing.
|
|
pub fn close_tab(&mut self) -> Option<Uuid> {
|
|
if self.tabs.len() <= 1 {
|
|
return None;
|
|
}
|
|
let closed = self.tabs.remove(self.active_tab_idx);
|
|
if self.active_tab_idx >= self.tabs.len() {
|
|
self.active_tab_idx = self.tabs.len() - 1;
|
|
}
|
|
let sid = closed.session()?;
|
|
// Only release it if no other tab still shows the same session.
|
|
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
|
(!still_shown).then_some(sid)
|
|
}
|
|
|
|
pub fn next_tab(&mut self) {
|
|
if self.tabs.is_empty() {
|
|
return;
|
|
}
|
|
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
|
|
}
|
|
|
|
pub fn prev_tab(&mut self) {
|
|
if self.tabs.is_empty() {
|
|
return;
|
|
}
|
|
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
|
|
}
|
|
|
|
pub fn goto_tab(&mut self, n: u8) {
|
|
let idx = (n as usize)
|
|
.saturating_sub(1)
|
|
.min(self.tabs.len().saturating_sub(1));
|
|
self.active_tab_idx = idx;
|
|
}
|
|
|
|
/// Bind a session to the active tab, move focus into the pane, and
|
|
/// 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(PaneRef::Session(session_id));
|
|
self.focus = Focus::Pane;
|
|
self.mode = Mode::Insert;
|
|
}
|
|
|
|
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
|
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
|
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
|
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
|
pub fn open_draft_pane(&mut self) {
|
|
if self.active_tab().pane.is_some() {
|
|
self.new_tab();
|
|
}
|
|
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
|
self.focus = Focus::Pane;
|
|
self.mode = Mode::Insert;
|
|
}
|
|
|
|
/// A draft submit succeeded: replace whatever the active tab showed with
|
|
/// the new session in place, matching the web client's behaviour (#57).
|
|
pub fn complete_spawn(&mut self, sid: Uuid) {
|
|
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
self.focus = Focus::Pane;
|
|
self.mode = Mode::Insert;
|
|
}
|
|
|
|
/// The session shown by the active tab's pane, if any.
|
|
pub fn active_session(&self) -> Option<Uuid> {
|
|
self.active_tab().session()
|
|
}
|
|
|
|
/// 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].session(), 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(PaneRef::Session(sid));
|
|
|
|
assert_eq!(state.close_tab(), Some(sid));
|
|
}
|
|
|
|
/// ...but not while another tab still shows it.
|
|
#[test]
|
|
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
|
let mut state = AppState::new();
|
|
let sid = Uuid::from_u128(4);
|
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
state.new_tab();
|
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
|
|
|
assert_eq!(state.close_tab(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn close_tab_is_a_no_op_on_the_last_tab() {
|
|
let mut state = AppState::new();
|
|
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
|
assert_eq!(state.close_tab(), None);
|
|
assert_eq!(state.tabs.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
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());
|
|
}
|
|
|
|
/// Issue #63: `s` over an empty pane turns that pane into a draft —
|
|
/// focused, Insert mode on, typing starts immediately.
|
|
#[test]
|
|
fn open_draft_reuses_the_empty_active_pane() {
|
|
let mut state = AppState::new();
|
|
state.open_draft_pane();
|
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
|
assert_eq!(state.focus, Focus::Pane);
|
|
assert_eq!(state.mode, Mode::Insert);
|
|
assert_eq!(state.tabs.len(), 1);
|
|
}
|
|
|
|
/// A tab already showing a session gets a fresh tab for the draft; v1
|
|
/// has no split tree, so a new tab is the "split".
|
|
#[test]
|
|
fn open_draft_over_a_session_opens_a_new_tab() {
|
|
let mut state = AppState::new();
|
|
state.attach_to_focused(Uuid::from_u128(9));
|
|
state.open_draft_pane();
|
|
assert_eq!(state.tabs.len(), 2);
|
|
assert_eq!(state.active_tab_idx, 1);
|
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
|
// The session tab is untouched.
|
|
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
|
}
|
|
|
|
/// Web parity (#57): submitting fills the pane in place.
|
|
#[test]
|
|
fn complete_spawn_replaces_the_draft_with_the_session() {
|
|
let mut state = AppState::new();
|
|
state.open_draft_pane();
|
|
let sid = Uuid::from_u128(21);
|
|
state.complete_spawn(sid);
|
|
assert_eq!(state.active_tab().session(), Some(sid));
|
|
assert_eq!(state.focus, Focus::Pane);
|
|
assert_eq!(state.mode, Mode::Insert);
|
|
}
|
|
|
|
/// A closed draft tab holds no session, so nothing is released.
|
|
#[test]
|
|
fn close_tab_reports_nothing_for_a_draft_pane() {
|
|
let mut state = AppState::new();
|
|
state.open_draft_pane();
|
|
state.new_tab();
|
|
state.active_tab_idx = 0;
|
|
assert_eq!(state.close_tab(), None);
|
|
}
|
|
}
|