diff options
Diffstat (limited to 'src/workspace.rs')
| -rw-r--r-- | src/workspace.rs | 588 |
1 files changed, 378 insertions, 210 deletions
diff --git a/src/workspace.rs b/src/workspace.rs index 39ee19a0..d10cdfe2 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -12,19 +13,12 @@ use crate::events::AppEvent; use crate::layout::{PaneId, TileLayout}; use crate::pane::{PaneRuntime, PaneState}; -/// A named workspace containing tiled terminal panes. -pub struct Workspace { - /// User-provided override. If set, auto-derived identity stops updating. +pub struct Tab { pub custom_name: Option<String>, - /// Identity source for this workspace. + pub number: usize, + /// Identity source for this tab's pane tree. pub root_pane: PaneId, pub layout: TileLayout, - /// Cached ahead/behind counts for the root repo's current branch upstream. - pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, - /// Stable-ish public pane numbers within this workspace. - /// New panes append at the end; closing a pane compacts higher numbers down. - pub public_pane_numbers: HashMap<PaneId, usize>, - pub(crate) next_public_pane_number: usize, /// Pane state — always present, testable without PTYs. pub panes: HashMap<PaneId, PaneState>, /// Pane runtimes — only present in production (empty in tests). @@ -35,7 +29,7 @@ pub struct Workspace { pub(crate) render_dirty: Arc<AtomicBool>, } -impl Drop for Workspace { +impl Drop for Tab { fn drop(&mut self) { let runtimes = std::mem::take(&mut self.runtimes); for (pane_id, runtime) in runtimes { @@ -44,8 +38,9 @@ impl Drop for Workspace { } } -impl Workspace { +impl Tab { pub fn new( + number: usize, initial_cwd: PathBuf, rows: u16, cols: u16, @@ -66,19 +61,14 @@ impl Workspace { let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); - let mut public_pane_numbers = HashMap::new(); - public_pane_numbers.insert(root_id, 1); let mut runtimes = HashMap::new(); runtimes.insert(root_id, runtime); - info!(root_pane = root_id.raw(), "workspace created"); Ok(Self { custom_name: None, + number, root_pane: root_id, layout, - cached_git_ahead_behind: None, - public_pane_numbers, - next_public_pane_number: 2, panes, runtimes, zoomed: false, @@ -88,7 +78,16 @@ impl Workspace { }) } - /// Split the focused pane. Returns the new pane id. + pub fn display_name(&self) -> String { + self.custom_name + .clone() + .unwrap_or_else(|| self.number.to_string()) + } + + pub fn set_custom_name(&mut self, name: String) { + self.custom_name = Some(name); + } + pub fn split_focused( &mut self, direction: Direction, @@ -109,35 +108,22 @@ impl Workspace { self.render_dirty.clone(), )?; self.panes.insert(new_id, PaneState::new()); - self.public_pane_numbers - .insert(new_id, self.next_public_pane_number); - self.next_public_pane_number += 1; self.runtimes.insert(new_id, runtime); self.zoomed = false; Ok(new_id) } - /// Close the focused pane. Returns the removed pane id, or None if last pane. - pub fn close_focused(&mut self) -> Option<PaneId> { + pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> { let pane_id = self.layout.focused(); - self.close_pane(pane_id) + self.detach_pane(pane_id) } - /// Close a specific pane and terminate everything running inside it. - /// Returns None if it's the last pane and the whole workspace should close. - pub fn close_pane(&mut self, pane_id: PaneId) -> Option<PaneId> { - let (removed, runtime) = self.detach_pane(pane_id)?; - if let Some(runtime) = runtime { - runtime.shutdown(pane_id); - } - Some(removed) + pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + self.detach_pane(pane_id) } - /// Remove a specific pane from this workspace without terminating its runtime. - /// Used when the pane process has already exited and we are only cleaning up state. - /// Returns None if it's the last pane and the whole workspace should close. - pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<PaneId> { - self.detach_pane(pane_id).map(|(removed, _)| removed) + pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + self.detach_pane(pane_id) } fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { @@ -156,14 +142,6 @@ impl Workspace { self.layout.focus_pane(prev_focus); } - if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) { - for number in self.public_pane_numbers.values_mut() { - if *number > removed_number { - *number -= 1; - } - } - self.next_public_pane_number = self.public_pane_numbers.len() + 1; - } self.panes.remove(&pane_id); let runtime = self.runtimes.remove(&pane_id); self.zoomed = false; @@ -180,52 +158,18 @@ impl Workspace { self.layout.pane_ids().into_iter().find(|id| *id != closing) } - pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> { - self.public_pane_numbers.get(&pane_id).copied() - } - - /// Get the runtime for the focused pane. pub fn focused_runtime(&self) -> Option<&PaneRuntime> { self.runtimes.get(&self.layout.focused()) } - pub fn set_custom_name(&mut self, name: String) { - self.custom_name = Some(name); - } - - pub fn display_name(&self) -> String { - if let Some(name) = &self.custom_name { - return name.clone(); - } - - self.root_cwd() - .as_deref() - .map(derive_label_from_cwd) - .unwrap_or_else(|| "shell".to_string()) - } - - pub fn root_cwd(&self) -> Option<PathBuf> { - self.runtimes.get(&self.root_pane).and_then(|rt| rt.cwd()) - } - - /// Aggregate workspace signal for sidebar triage. - /// Returns the most urgent pane's state + seen flag. - pub fn aggregate_state(&self) -> (AgentState, bool) { - self.panes - .values() - .map(|pane| (pane.state, pane.seen)) - .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen)) - .unwrap_or((AgentState::Unknown, true)) - } - pub fn has_working_pane(&self) -> bool { self.panes .values() .any(|pane| pane.state == AgentState::Working) } - /// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom). #[cfg(test)] + #[allow(dead_code)] // retained for focused layout-order assertions in tests pub fn pane_states(&self) -> Vec<(AgentState, bool)> { self.layout .pane_ids() @@ -239,7 +183,6 @@ impl Workspace { .collect() } - /// Per-pane detail for the agent detail panel, in stable layout order. pub fn pane_details(&self) -> Vec<PaneDetail> { self.layout .pane_ids() @@ -261,28 +204,336 @@ impl Workspace { }) .collect() } +} + +/// A named workspace containing tabs. +pub struct Workspace { + /// User-provided override. If set, auto-derived identity stops updating. + pub custom_name: Option<String>, + /// Fixed workspace identity source, seeded when the workspace is created. + pub identity_cwd: PathBuf, + /// Cached ahead/behind counts for the workspace repo's current branch upstream. + pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, + /// Stable-ish public pane numbers within this workspace. + /// New panes append at the end; closing a pane compacts higher numbers down. + pub public_pane_numbers: HashMap<PaneId, usize>, + pub(crate) next_public_pane_number: usize, + pub tabs: Vec<Tab>, + pub active_tab: usize, +} + +impl Deref for Workspace { + type Target = Tab; + + fn deref(&self) -> &Self::Target { + self.active_tab() + .expect("workspace must always have at least one active tab") + } +} + +impl DerefMut for Workspace { + fn deref_mut(&mut self) -> &mut Self::Target { + self.active_tab_mut() + .expect("workspace must always have at least one active tab") + } +} + +impl Workspace { + pub fn new( + initial_cwd: PathBuf, + rows: u16, + cols: u16, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + ) -> std::io::Result<Self> { + let tab = Tab::new( + 1, + initial_cwd.clone(), + rows, + cols, + events, + render_notify, + render_dirty, + )?; + let mut public_pane_numbers = HashMap::new(); + public_pane_numbers.insert(tab.root_pane, 1); + info!(root_pane = tab.root_pane.raw(), "workspace created"); + Ok(Self { + custom_name: None, + identity_cwd: initial_cwd, + cached_git_ahead_behind: None, + public_pane_numbers, + next_public_pane_number: 2, + tabs: vec![tab], + active_tab: 0, + }) + } + + pub fn active_tab(&self) -> Option<&Tab> { + self.tabs.get(self.active_tab) + } + + pub fn active_tab_mut(&mut self) -> Option<&mut Tab> { + self.tabs.get_mut(self.active_tab) + } + + pub fn active_tab_display_name(&self) -> Option<String> { + self.active_tab().map(Tab::display_name) + } + + pub fn switch_tab(&mut self, idx: usize) { + if idx < self.tabs.len() { + self.active_tab = idx; + if let Some(tab) = self.tabs.get_mut(idx) { + for pane in tab.panes.values_mut() { + pane.seen = true; + } + } + } + } + + pub fn create_tab(&mut self, rows: u16, cols: u16, cwd: PathBuf) -> std::io::Result<usize> { + let number = self.tabs.len() + 1; + let events = self + .active_tab() + .map(|tab| tab.events.clone()) + .expect("workspace must always have at least one tab"); + let render_notify = self + .active_tab() + .map(|tab| tab.render_notify.clone()) + .expect("workspace must always have at least one tab"); + let render_dirty = self + .active_tab() + .map(|tab| tab.render_dirty.clone()) + .expect("workspace must always have at least one tab"); + + let tab = Tab::new(number, cwd, rows, cols, events, render_notify, render_dirty)?; + self.register_new_pane(tab.root_pane); + self.tabs.push(tab); + self.active_tab = self.tabs.len() - 1; + Ok(self.active_tab) + } + + pub fn close_tab(&mut self, idx: usize) -> bool { + if self.tabs.len() <= 1 || idx >= self.tabs.len() { + return false; + } + let tab = self.tabs.remove(idx); + for pane_id in tab.panes.keys() { + self.unregister_pane(*pane_id); + } + self.renumber_tabs(); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } else if idx <= self.active_tab && self.active_tab > 0 { + self.active_tab -= 1; + } + true + } + + pub fn close_active_tab(&mut self) -> bool { + self.close_tab(self.active_tab) + } + + pub fn split_focused( + &mut self, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option<PathBuf>, + ) -> std::io::Result<PaneId> { + let new_id = self + .active_tab_mut() + .expect("workspace must always have at least one tab") + .split_focused(direction, rows, cols, cwd)?; + self.register_new_pane(new_id); + Ok(new_id) + } + + /// Close the focused pane. Returns true if the workspace should close. + pub fn close_focused(&mut self) -> bool { + let pane_count = self + .active_tab() + .map(|tab| tab.layout.pane_count()) + .unwrap_or(0); + let tab_count = self.tabs.len(); + if pane_count <= 1 { + return tab_count <= 1 || self.close_active_tab_and_report(); + } + + if let Some((removed, runtime)) = self.active_tab_mut().and_then(Tab::close_focused) { + self.unregister_pane(removed); + if let Some(runtime) = runtime { + runtime.shutdown(removed); + } + } + false + } + + /// Remove a specific pane from this workspace without terminating its runtime. + /// Returns true if the workspace should close. + pub fn remove_pane(&mut self, pane_id: PaneId) -> bool { + let Some(tab_idx) = self.find_tab_index_for_pane(pane_id) else { + return false; + }; + let pane_count = self.tabs[tab_idx].layout.pane_count(); + let tab_count = self.tabs.len(); + if pane_count <= 1 { + if tab_count <= 1 { + return true; + } + self.tabs.remove(tab_idx); + self.unregister_pane(pane_id); + self.renumber_tabs(); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } else if tab_idx <= self.active_tab && self.active_tab > 0 { + self.active_tab -= 1; + } + return false; + } + + if let Some((removed, _)) = self.tabs[tab_idx].remove_pane(pane_id) { + self.unregister_pane(removed); + } + false + } + + pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> { + self.public_pane_numbers.get(&pane_id).copied() + } + + pub fn set_custom_name(&mut self, name: String) { + self.custom_name = Some(name); + } + + pub fn display_name(&self) -> String { + if let Some(name) = &self.custom_name { + return name.clone(); + } + + derive_label_from_cwd(&self.identity_cwd) + } - /// Get the git branch for this workspace's root cwd. pub fn branch(&self) -> Option<String> { - self.root_cwd().and_then(|cwd| git_branch(&cwd)) + git_branch(&self.identity_cwd) } - /// Cached ahead/behind counts for this workspace's current branch upstream. pub fn git_ahead_behind(&self) -> Option<(usize, usize)> { self.cached_git_ahead_behind } - /// Refresh cached ahead/behind counts from the workspace's current cwd. pub fn refresh_git_ahead_behind(&mut self) { - self.cached_git_ahead_behind = self.root_cwd().and_then(|cwd| git_ahead_behind(&cwd)); + self.cached_git_ahead_behind = git_ahead_behind(&self.identity_cwd); + } + + pub fn aggregate_state(&self) -> (AgentState, bool) { + self.tabs + .iter() + .flat_map(|tab| tab.panes.values()) + .map(|pane| (pane.state, pane.seen)) + .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen)) + .unwrap_or((AgentState::Unknown, true)) + } + + pub fn has_working_pane(&self) -> bool { + self.tabs.iter().any(Tab::has_working_pane) + } + + pub fn pane_details(&self) -> Vec<PaneDetail> { + self.active_tab().map(Tab::pane_details).unwrap_or_default() + } + + pub fn focused_runtime(&self) -> Option<&PaneRuntime> { + self.active_tab().and_then(Tab::focused_runtime) + } + + pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option<usize> { + self.tabs + .iter() + .position(|tab| tab.panes.contains_key(&pane_id)) + } + + pub fn pane_state(&self, pane_id: PaneId) -> Option<&PaneState> { + self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id)) + } + + pub fn runtime(&self, pane_id: PaneId) -> Option<&PaneRuntime> { + self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) + } + + pub fn focused_pane_id(&self) -> Option<PaneId> { + self.active_tab().map(|tab| tab.layout.focused()) + } + + pub fn close_pane(&mut self, pane_id: PaneId) -> bool { + let tab_idx = match self.find_tab_index_for_pane(pane_id) { + Some(idx) => idx, + None => return false, + }; + let pane_count = self.tabs[tab_idx].layout.pane_count(); + let tab_count = self.tabs.len(); + if pane_count <= 1 { + if tab_count <= 1 { + return true; + } + self.tabs.remove(tab_idx); + self.unregister_pane(pane_id); + self.renumber_tabs(); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } else if tab_idx <= self.active_tab && self.active_tab > 0 { + self.active_tab -= 1; + } + return false; + } + + if let Some((removed, runtime)) = self.tabs[tab_idx].close_pane(pane_id) { + self.unregister_pane(removed); + if let Some(runtime) = runtime { + runtime.shutdown(removed); + } + } + false + } + + fn register_new_pane(&mut self, pane_id: PaneId) { + self.public_pane_numbers + .insert(pane_id, self.next_public_pane_number); + self.next_public_pane_number += 1; + } + + fn unregister_pane(&mut self, pane_id: PaneId) { + if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) { + for number in self.public_pane_numbers.values_mut() { + if *number > removed_number { + *number -= 1; + } + } + self.next_public_pane_number = self.public_pane_numbers.len() + 1; + } + } + + fn renumber_tabs(&mut self) { + for (idx, tab) in self.tabs.iter_mut().enumerate() { + tab.number = idx + 1; + } + } + + fn close_active_tab_and_report(&mut self) -> bool { + if self.tabs.len() <= 1 { + return true; + } + self.close_active_tab(); + false } } /// Detail info for a single pane, used by the agent detail panel. pub struct PaneDetail { pub label: String, - /// The detected agent, if any. Will be used for context extraction. - #[allow(dead_code)] // used later for triage line extraction + #[allow(dead_code)] pub agent: Option<Agent>, pub state: AgentState, pub seen: bool, @@ -291,7 +542,7 @@ pub struct PaneDetail { fn pane_attention_priority(state: AgentState, seen: bool) -> u8 { match (state, seen) { (AgentState::Blocked, _) => 4, - (AgentState::Idle, false) => 3, // done, waiting for you to look + (AgentState::Idle, false) => 3, (AgentState::Working, _) => 2, (AgentState::Idle, true) => 1, (AgentState::Unknown, _) => 0, @@ -314,7 +565,7 @@ fn agent_name(agent: Agent) -> &'static str { } } -fn derive_label_from_cwd(cwd: &Path) -> String { +pub fn derive_label_from_cwd(cwd: &Path) -> String { if let Some(repo_root) = git_repo_root(cwd) { if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) { return name.to_string(); @@ -335,8 +586,6 @@ fn derive_label_from_cwd(cwd: &Path) -> String { .unwrap_or_else(|| cwd.display().to_string()) } -/// Read the current git branch name from .git/HEAD. -/// Returns None if not in a git repo or HEAD is detached. pub fn git_branch(cwd: &Path) -> Option<String> { let repo_root = git_repo_root(cwd)?; let head_path = repo_root.join(".git").join("HEAD"); @@ -364,7 +613,6 @@ fn git_repo_root(start: &Path) -> Option<PathBuf> { } } -/// Read ahead/behind counts relative to the current branch upstream. fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> { git_repo_root(cwd)?; @@ -390,45 +638,46 @@ fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> { Some((ahead, behind)) } -// --------------------------------------------------------------------------- -// Test helpers — construct workspaces without PTYs -// --------------------------------------------------------------------------- - #[cfg(test)] impl Workspace { - /// Create a test workspace with one pane, no PTY runtime. pub fn test_new(name: &str) -> Self { let (events, _) = mpsc::channel(64); - let (layout, root_id) = TileLayout::new(); let render_notify = Arc::new(Notify::new()); let render_dirty = Arc::new(AtomicBool::new(false)); + let identity_cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); + let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); - let mut public_pane_numbers = HashMap::new(); - public_pane_numbers.insert(root_id, 1); - Self { - custom_name: Some(name.to_string()), + let tab = Tab { + custom_name: None, + number: 1, root_pane: root_id, layout, - cached_git_ahead_behind: None, - public_pane_numbers, - next_public_pane_number: 2, panes, runtimes: HashMap::new(), zoomed: false, events, render_notify, render_dirty, + }; + let mut public_pane_numbers = HashMap::new(); + public_pane_numbers.insert(tab.root_pane, 1); + Self { + custom_name: Some(name.to_string()), + identity_cwd, + cached_git_ahead_behind: None, + public_pane_numbers, + next_public_pane_number: 2, + tabs: vec![tab], + active_tab: 0, } } - /// Add a test pane (splits focused, no PTY runtime). pub fn test_split(&mut self, direction: Direction) -> PaneId { - let new_id = self.layout.split_focused(direction); - self.panes.insert(new_id, PaneState::new()); - self.public_pane_numbers - .insert(new_id, self.next_public_pane_number); - self.next_public_pane_number += 1; + let tab = self.active_tab_mut().expect("workspace must have tab"); + let new_id = tab.layout.split_focused(direction); + tab.panes.insert(new_id, PaneState::new()); + self.register_new_pane(new_id); new_id } } @@ -450,10 +699,14 @@ mod tests { fn aggregate_state_priority() { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); - - let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); - ws.panes.get_mut(&root_id).unwrap().state = AgentState::Idle; - ws.panes.get_mut(&id2).unwrap().state = AgentState::Working; + let root_id = ws.tabs[0] + .panes + .keys() + .find(|id| **id != id2) + .copied() + .unwrap(); + ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle; + ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; let (state, seen) = ws.aggregate_state(); assert_eq!(state, AgentState::Working); @@ -464,104 +717,19 @@ mod tests { fn aggregate_state_done_unseen_beats_working() { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); - - let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); - let root = ws.panes.get_mut(&root_id).unwrap(); + let root_id = ws.tabs[0] + .panes + .keys() + .find(|id| **id != id2) + .copied() + .unwrap(); + let root = ws.tabs[0].panes.get_mut(&root_id).unwrap(); root.state = AgentState::Idle; root.seen = false; - ws.panes.get_mut(&id2).unwrap().state = AgentState::Working; + ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; let (state, seen) = ws.aggregate_state(); assert_eq!(state, AgentState::Idle); assert!(!seen); } - - #[test] - fn aggregate_state_blocked_beats_done_unseen() { - let mut ws = Workspace::test_new("test"); - let id2 = ws.test_split(Direction::Horizontal); - - let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); - let root = ws.panes.get_mut(&root_id).unwrap(); - root.state = AgentState::Idle; - root.seen = false; - ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked; - - let (state, seen) = ws.aggregate_state(); - assert_eq!(state, AgentState::Blocked); - assert!(seen); - } - - #[test] - fn close_focused_removes_pane() { - let mut ws = Workspace::test_new("test"); - let _id2 = ws.test_split(Direction::Horizontal); - assert_eq!(ws.panes.len(), 2); - - let closed = ws.close_focused(); - assert!(closed.is_some()); - assert_eq!(ws.panes.len(), 1); - } - - #[test] - fn close_focused_last_pane_returns_none() { - let mut ws = Workspace::test_new("test"); - assert_eq!(ws.panes.len(), 1); - - let closed = ws.close_focused(); - assert!(closed.is_none()); - assert_eq!(ws.panes.len(), 1); - } - - #[test] - fn pane_states_matches_layout_order() { - let mut ws = Workspace::test_new("test"); - let id2 = ws.test_split(Direction::Horizontal); - - ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked; - - let states = ws.pane_states(); - assert_eq!(states.len(), 2); - assert_eq!(states[0].0, AgentState::Unknown); - assert_eq!(states[1].0, AgentState::Blocked); - } - - #[test] - fn pane_details_stay_in_layout_order() { - let mut ws = Workspace::test_new("test"); - let id2 = ws.test_split(Direction::Horizontal); - - let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); - ws.panes.get_mut(&root_id).unwrap().detected_agent = Some(Agent::Pi); - ws.panes.get_mut(&root_id).unwrap().state = AgentState::Working; - ws.panes.get_mut(&id2).unwrap().detected_agent = Some(Agent::Claude); - ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked; - - let details = ws.pane_details(); - assert_eq!(details.len(), 2); - assert_eq!(details[0].label, "pi"); - assert_eq!(details[0].state, AgentState::Working); - assert_eq!(details[1].label, "claude"); - assert_eq!(details[1].state, AgentState::Blocked); - } - - #[test] - fn closing_root_promotes_another_pane() { - let mut ws = Workspace::test_new("test"); - let root = ws.root_pane; - let other = ws.test_split(Direction::Horizontal); - ws.layout.focus_pane(root); - ws.remove_pane(root); - assert_eq!(ws.root_pane, other); - } - - #[test] - fn parse_git_ahead_behind_output_maps_first_field_to_ahead() { - assert_eq!(parse_git_ahead_behind_output("7\t0\n"), Some((7, 0))); - } - - #[test] - fn parse_git_ahead_behind_output_maps_second_field_to_behind() { - assert_eq!(parse_git_ahead_behind_output("0 3\n"), Some((0, 3))); - } } |
