`s` (OpenSpawnModal, was NewDraftPane) puts the form up as a centered overlay instead of taking over the pane. The attached session stays attached behind it and comes back untouched on Escape; the modal owns every key while it is up, so `x` and `j` typed into a field no longer reach the keymap. Tab::pane is a session id again and PaneRef is gone. The form lives on App next to the rename buffer, so its loads no longer address a tab index that could not identify them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
400 lines
14 KiB
Rust
400 lines
14 KiB
Rust
//! `AppState` — the top-level mutable state of the running TUI.
|
|
//!
|
|
//! v1 keeps it small on purpose: one pane containing a session, focus split
|
|
//! between sidebar and pane, the current input mode, and an optional overlay
|
|
//! (spawn form, kill confirm, rename, help, transient toast).
|
|
//!
|
|
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
|
|
use uuid::Uuid;
|
|
|
|
use crate::keymap::Mode;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Focus {
|
|
Sidebar,
|
|
Pane,
|
|
}
|
|
|
|
/// One selectable item in the rendered sidebar sequence.
|
|
///
|
|
/// Group paths, unlike a session's position in the catalog, survive catalog
|
|
/// redraws and make a highlighted collapsed group unambiguous.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum SidebarItem {
|
|
Group(PathBuf),
|
|
Session(Uuid),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Tab {
|
|
pub name: String,
|
|
pub pane: Option<Uuid>,
|
|
}
|
|
|
|
impl Tab {
|
|
/// The session this tab shows, if it shows one.
|
|
pub fn session(&self) -> Option<Uuid> {
|
|
self.pane
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum OverlayKind {
|
|
/// The spawn form. Its [`crate::draft::DraftPane`] lives on `App` for the
|
|
/// same reason the rename buffer does — so this stays comparable.
|
|
Spawn,
|
|
KillConfirm(Uuid),
|
|
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
|
|
/// so this stays comparable.
|
|
Rename(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<SidebarItem>,
|
|
/// Group paths whose cards are hidden. This is UI state rather than
|
|
/// catalog state, so it survives every catalog redraw.
|
|
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
|
/// 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,
|
|
collapsed_sidebar_groups: HashSet::new(),
|
|
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]
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// Put the spawn form up. Whatever the pane was showing stays behind it,
|
|
/// so dismissing gives that back untouched — the modal owns the keyboard
|
|
/// while it is up, so focus and mode are left alone.
|
|
pub fn open_spawn_modal(&mut self) {
|
|
self.show_overlay(OverlayKind::Spawn);
|
|
}
|
|
|
|
/// A spawn submit succeeded: the modal closes and the new session takes
|
|
/// the pane, attached and ready to type into.
|
|
pub fn complete_spawn(&mut self, sid: Uuid) {
|
|
self.dismiss_overlay();
|
|
self.attach_to_focused(sid);
|
|
}
|
|
|
|
/// 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 one step through the rendered item
|
|
/// sequence, in the direction of `delta` (positive = down), landing only
|
|
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
|
|
/// No-op when `order` holds no stop.
|
|
///
|
|
/// Anchoring walks the *full* rendered order, not just the stops, so a
|
|
/// highlight parked on a skipped row — an expanded group header, which a
|
|
/// mouse click can still select — steps to its neighbour instead of
|
|
/// jumping to the far end of the list.
|
|
pub fn move_sidebar_highlight(
|
|
&mut self,
|
|
order: &[SidebarItem],
|
|
delta: i32,
|
|
is_stop: impl Fn(&SidebarItem) -> bool,
|
|
) {
|
|
if order.is_empty() || delta == 0 {
|
|
return;
|
|
}
|
|
let len = order.len() as i32;
|
|
let step = delta.signum();
|
|
let cur = self
|
|
.sidebar_highlight
|
|
.as_ref()
|
|
.and_then(|id| order.iter().position(|x| x == id))
|
|
.map(|i| i as i32)
|
|
// Nothing highlighted: start one off the end we walk from, so the
|
|
// first step lands on the first (or last) item.
|
|
.unwrap_or(if step > 0 { -1 } else { 0 });
|
|
let mut idx = cur;
|
|
for _ in 0..len {
|
|
idx = (idx + step).rem_euclid(len);
|
|
if is_stop(&order[idx as usize]) {
|
|
self.sidebar_highlight = Some(order[idx as usize].clone());
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggle a group. When collapsing a group containing the current card,
|
|
/// leave the highlight on the group row that remains visible.
|
|
pub fn toggle_sidebar_group(&mut self, key: PathBuf, contains_highlight: bool) {
|
|
if self.collapsed_sidebar_groups.remove(&key) {
|
|
return;
|
|
}
|
|
self.collapsed_sidebar_groups.insert(key.clone());
|
|
if contains_highlight {
|
|
self.sidebar_highlight = Some(SidebarItem::Group(key));
|
|
}
|
|
}
|
|
|
|
/// 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 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![
|
|
SidebarItem::Session(a),
|
|
SidebarItem::Group("repo".into()),
|
|
SidebarItem::Session(b),
|
|
SidebarItem::Session(c),
|
|
];
|
|
|
|
// No prior highlight + delta +1 lands on first item.
|
|
state.move_sidebar_highlight(&order, 1, |_| true);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
|
|
state.move_sidebar_highlight(&order, 1, |_| true);
|
|
assert_eq!(
|
|
state.sidebar_highlight,
|
|
Some(SidebarItem::Group("repo".into()))
|
|
);
|
|
state.move_sidebar_highlight(&order, 1, |_| true);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
|
state.move_sidebar_highlight(&order, 1, |_| true);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
|
// Wraps forward.
|
|
state.move_sidebar_highlight(&order, 1, |_| true);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
// Wraps backward.
|
|
state.move_sidebar_highlight(&order, -1, |_| true);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
|
}
|
|
|
|
#[test]
|
|
fn move_sidebar_highlight_skips_rows_the_filter_rejects() {
|
|
let mut state = AppState::new();
|
|
let a = Uuid::from_u128(1);
|
|
let b = Uuid::from_u128(2);
|
|
let order = vec![
|
|
SidebarItem::Group("repo".into()),
|
|
SidebarItem::Session(a),
|
|
SidebarItem::Group("other".into()),
|
|
SidebarItem::Session(b),
|
|
];
|
|
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
|
|
|
|
state.move_sidebar_highlight(&order, 1, sessions_only);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
state.move_sidebar_highlight(&order, 1, sessions_only);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
|
state.move_sidebar_highlight(&order, -1, sessions_only);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
|
|
// Backwards from nothing lands on the last stop, not the last row.
|
|
state.sidebar_highlight = None;
|
|
state.move_sidebar_highlight(&order, -1, sessions_only);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
|
}
|
|
|
|
/// A highlight sitting on a skipped row still moves to its neighbour.
|
|
#[test]
|
|
fn move_sidebar_highlight_anchors_on_a_skipped_row() {
|
|
let mut state = AppState::new();
|
|
let a = Uuid::from_u128(1);
|
|
let b = Uuid::from_u128(2);
|
|
let order = vec![
|
|
SidebarItem::Session(a),
|
|
SidebarItem::Group("repo".into()),
|
|
SidebarItem::Session(b),
|
|
];
|
|
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
|
|
|
|
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
|
|
state.move_sidebar_highlight(&order, 1, sessions_only);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
|
|
|
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
|
|
state.move_sidebar_highlight(&order, -1, sessions_only);
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
}
|
|
|
|
#[test]
|
|
fn move_sidebar_highlight_is_noop_when_nothing_is_a_stop() {
|
|
let mut state = AppState::new();
|
|
let order = vec![SidebarItem::Group("repo".into())];
|
|
state.move_sidebar_highlight(&order, 1, |_| false);
|
|
assert_eq!(state.sidebar_highlight, None);
|
|
}
|
|
|
|
#[test]
|
|
fn move_sidebar_highlight_is_noop_on_empty_order() {
|
|
let mut state = AppState::new();
|
|
state.move_sidebar_highlight(&[], 1, |_| true);
|
|
assert_eq!(state.sidebar_highlight, None);
|
|
}
|
|
|
|
#[test]
|
|
fn collapsing_highlighted_group_moves_a_card_highlight_to_the_group() {
|
|
let key = PathBuf::from("/repo");
|
|
let mut state = AppState::new();
|
|
state.sidebar_highlight = Some(SidebarItem::Session(Uuid::from_u128(9)));
|
|
state.toggle_sidebar_group(key.clone(), true);
|
|
assert_eq!(
|
|
state.sidebar_highlight,
|
|
Some(SidebarItem::Group(key.clone()))
|
|
);
|
|
assert!(state.collapsed_sidebar_groups.contains(&key));
|
|
state.toggle_sidebar_group(key.clone(), false);
|
|
assert!(!state.collapsed_sidebar_groups.contains(&key));
|
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Group(key)));
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
/// The modal covers the pane rather than replacing it: an attached
|
|
/// session is still there once the form is dismissed.
|
|
#[test]
|
|
fn open_spawn_modal_leaves_the_pane_alone() {
|
|
let mut state = AppState::new();
|
|
let sid = Uuid::from_u128(9);
|
|
state.attach_to_focused(sid);
|
|
state.open_spawn_modal();
|
|
assert_eq!(state.overlay(), Some(&OverlayKind::Spawn));
|
|
assert_eq!(state.active_tab().session(), Some(sid));
|
|
|
|
state.dismiss_overlay();
|
|
assert_eq!(state.active_tab().session(), Some(sid));
|
|
}
|
|
|
|
#[test]
|
|
fn complete_spawn_dismisses_the_modal_and_attaches() {
|
|
let mut state = AppState::new();
|
|
state.open_spawn_modal();
|
|
let sid = Uuid::from_u128(21);
|
|
state.complete_spawn(sid);
|
|
assert!(state.overlay().is_none());
|
|
assert_eq!(state.active_tab().session(), Some(sid));
|
|
assert_eq!(state.focus, Focus::Pane);
|
|
assert_eq!(state.mode, Mode::Insert);
|
|
}
|
|
}
|