use std::collections::HashMap; use std::ops::{Deref, DerefMut}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use ratatui::layout::Direction; use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::PaneId; #[cfg(test)] use crate::layout::TileLayout; use crate::pane::{PaneRuntime, PaneState}; mod aggregate; mod git; mod tab; use self::git::git_ahead_behind; pub use self::{ git::{derive_label_from_cwd, git_branch}, tab::Tab, }; static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1); pub(crate) fn generate_workspace_id() -> String { let micros = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_micros()) .unwrap_or(0); let counter = NEXT_WORKSPACE_ID.fetch_add(1, Ordering::Relaxed); format!("w{micros:x}{counter:x}") } /// A named workspace containing tabs. pub struct Workspace { /// Stable public workspace identity, independent of display order. pub id: String, /// User-provided override. If set, auto-derived identity stops updating. pub custom_name: Option, /// Fallback workspace identity source for tests, old snapshots, or missing runtimes. pub identity_cwd: PathBuf, /// Cached current git branch for the workspace repo. pub(crate) cached_git_branch: Option, /// 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, pub(crate) next_public_pane_number: usize, pub tabs: Vec, 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, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result { let tab = Tab::new( 1, initial_cwd.clone(), rows, cols, scrollback_limit_bytes, host_terminal_theme, events, render_notify, render_dirty, )?; let mut public_pane_numbers = HashMap::new(); public_pane_numbers.insert(tab.root_pane, 1); Ok(Self { id: generate_workspace_id(), custom_name: None, identity_cwd: initial_cwd.clone(), cached_git_branch: git_branch(&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_index(&self) -> usize { 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 { 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, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result { 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, scrollback_limit_bytes, host_terminal_theme, events, render_notify, render_dirty, )?; self.register_new_pane(tab.root_pane); self.tabs.push(tab); Ok(self.tabs.len() - 1) } 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 move_tab(&mut self, source_idx: usize, insert_idx: usize) -> bool { if source_idx >= self.tabs.len() || insert_idx > self.tabs.len() { return false; } let target_idx = if source_idx < insert_idx { insert_idx.saturating_sub(1) } else { insert_idx } .min(self.tabs.len().saturating_sub(1)); if source_idx == target_idx { return false; } let active_root_pane = self.tabs.get(self.active_tab).map(|tab| tab.root_pane); let tab = self.tabs.remove(source_idx); self.tabs.insert(target_idx, tab); self.renumber_tabs(); self.active_tab = active_root_pane .and_then(|root_pane| self.tabs.iter().position(|tab| tab.root_pane == root_pane)) .unwrap_or(target_idx); 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, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result { let new_id = self .active_tab_mut() .expect("workspace must always have at least one tab") .split_focused( direction, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, )?; 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 { self.public_pane_numbers.get(&pane_id).copied() } pub fn set_custom_name(&mut self, name: String) { self.custom_name = Some(name); } pub fn resolved_identity_cwd(&self) -> Option { self.tabs .first() .and_then(Tab::root_cwd) .or_else(|| Some(self.identity_cwd.clone())) } pub fn display_name(&self) -> String { if let Some(name) = &self.custom_name { return name.clone(); } self.resolved_identity_cwd() .map(|cwd| derive_label_from_cwd(&cwd)) .unwrap_or_else(|| "workspace".into()) } pub fn branch(&self) -> Option { self.cached_git_branch.clone() } pub fn git_ahead_behind(&self) -> Option<(usize, usize)> { self.cached_git_ahead_behind } pub fn refresh_git_ahead_behind(&mut self) { let cwd = self.resolved_identity_cwd(); self.cached_git_branch = cwd.as_deref().and_then(git_branch); self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind); } 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 { 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 { 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 } } #[cfg(test)] impl Workspace { pub(crate) fn test_new(name: &str) -> Self { let (events, _) = mpsc::channel(64); 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 pane_cwds = HashMap::new(); pane_cwds.insert(root_id, identity_cwd.clone()); let tab = Tab { custom_name: None, number: 1, root_pane: root_id, layout, panes, pane_cwds, 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 { id: generate_workspace_id(), custom_name: Some(name.to_string()), identity_cwd: identity_cwd.clone(), cached_git_branch: git_branch(&identity_cwd), cached_git_ahead_behind: None, public_pane_numbers, next_public_pane_number: 2, tabs: vec![tab], active_tab: 0, } } pub(crate) fn test_split(&mut self, direction: Direction) -> PaneId { 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()); let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); tab.pane_cwds.insert(new_id, cwd); self.register_new_pane(new_id); new_id } pub(crate) fn test_add_tab(&mut self, name: Option<&str>) -> usize { let (events, _) = mpsc::channel(64); let render_notify = Arc::new(Notify::new()); let render_dirty = Arc::new(AtomicBool::new(false)); let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); let mut pane_cwds = HashMap::new(); pane_cwds.insert(root_id, cwd); let tab = Tab { custom_name: name.map(str::to_string), number: self.tabs.len() + 1, root_pane: root_id, layout, panes, pane_cwds, runtimes: HashMap::new(), zoomed: false, events, render_notify, render_dirty, }; self.register_new_pane(root_id); self.tabs.push(tab); self.tabs.len() - 1 } } #[cfg(test)] mod tests { use super::*; #[test] fn workspace_identity_follows_first_tab_root_pane_cwd() { let mut ws = Workspace::test_new("ignored"); ws.custom_name = None; let root_pane = ws.tabs[0].root_pane; ws.tabs[0] .pane_cwds .insert(root_pane, PathBuf::from("/tmp/pion")); assert_eq!(ws.display_name(), "pion"); assert_eq!(ws.resolved_identity_cwd(), Some(PathBuf::from("/tmp/pion"))); } #[test] fn moving_tab_keeps_active_identity_and_renumbers_auto_tabs() { let mut ws = Workspace::test_new("test"); let moved_root = ws.tabs[0].root_pane; ws.test_add_tab(Some("foo")); let final_auto_idx = ws.test_add_tab(None); let active_root = ws.tabs[final_auto_idx].root_pane; ws.switch_tab(final_auto_idx); assert!(ws.move_tab(0, ws.tabs.len())); let labels: Vec<_> = ws.tabs.iter().map(|tab| tab.display_name()).collect(); assert_eq!(labels, vec!["foo", "2", "3"]); assert_eq!(ws.tabs[0].custom_name.as_deref(), Some("foo")); assert!(ws.tabs[1].custom_name.is_none()); assert!(ws.tabs[2].custom_name.is_none()); assert_eq!(ws.tabs[2].root_pane, moved_root); assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root); } }