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::PaneState; use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; mod aggregate; mod git; mod tab; use self::git::git_ahead_behind; pub use self::{ git::{derive_label_from_cwd, git_branch}, tab::Tab, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceGitStatus { pub workspace_id: String, pub resolved_identity_cwd: PathBuf, pub branch: Option, pub ahead_behind: Option<(usize, usize)>, } 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, #[cfg(test)] pub(crate) test_runtimes: HashMap, } 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, default_shell: &str, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, default_shell, events, render_notify, render_dirty, None, ) } pub fn new_argv_command( initial_cwd: PathBuf, rows: u16, cols: u16, argv: &[String], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, "", events, render_notify, render_dirty, Some(argv), ) } #[allow(clippy::too_many_arguments)] fn new_with_tab( initial_cwd: PathBuf, rows: u16, cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, default_shell: &str, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, argv: Option<&[String]>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let (tab, terminal, runtime) = if let Some(argv) = argv { Tab::new_argv_command( 1, initial_cwd.clone(), rows, cols, argv, scrollback_limit_bytes, host_terminal_theme, events, render_notify, render_dirty, )? } else { Tab::new( 1, initial_cwd.clone(), rows, cols, scrollback_limit_bytes, host_terminal_theme, default_shell, 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, #[cfg(test)] test_runtimes: HashMap::new(), }, terminal, runtime, )) } 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, default_shell: &str, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { self.create_tab_with_runtime( rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, default_shell, None, ) } fn create_tab_with_runtime( &mut self, rows: u16, cols: u16, cwd: PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, default_shell: &str, argv: Option<&[String]>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { 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, terminal, runtime) = if let Some(argv) = argv { Tab::new_argv_command( number, cwd, rows, cols, argv, scrollback_limit_bytes, host_terminal_theme, events, render_notify, render_dirty, )? } else { Tab::new( number, cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, default_shell, events, render_notify, render_dirty, )? }; self.register_new_pane(tab.root_pane); self.tabs.push(tab); Ok((self.tabs.len() - 1, terminal, runtime)) } 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, default_shell: &str, ) -> std::io::Result { let new_pane = 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, default_shell, )?; self.register_new_pane(new_pane.pane_id); Ok(new_pane) } pub fn split_pane( &mut self, pane_id: PaneId, direction: Direction, rows: u16, cols: u16, cwd: Option, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, default_shell: &str, focus_new_pane: bool, ) -> Option> { self.split_pane_with_runtime( pane_id, direction, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, default_shell, focus_new_pane, None, ) } #[allow(clippy::too_many_arguments)] pub fn split_pane_argv_command( &mut self, pane_id: PaneId, direction: Direction, rows: u16, cols: u16, cwd: Option, argv: &[String], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, focus_new_pane: bool, ) -> Option> { self.split_pane_with_runtime( pane_id, direction, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, "", focus_new_pane, Some(argv), ) } #[allow(clippy::too_many_arguments)] fn split_pane_with_runtime( &mut self, pane_id: PaneId, direction: Direction, rows: u16, cols: u16, cwd: Option, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, default_shell: &str, focus_new_pane: bool, argv: Option<&[String]>, ) -> Option> { let tab_idx = self.find_tab_index_for_pane(pane_id)?; let tab = &mut self.tabs[tab_idx]; let previous_focus = tab.layout.focused(); tab.layout.focus_pane(pane_id); let new_pane = match if let Some(argv) = argv { tab.split_focused_argv_command( direction, rows, cols, cwd, argv, scrollback_limit_bytes, host_terminal_theme, ) } else { tab.split_focused( direction, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, default_shell, ) } { Ok(new_pane) => new_pane, Err(err) => { tab.layout.focus_pane(previous_focus); return Some(Err(err)); } }; if !focus_new_pane { tab.layout.focus_pane(previous_focus); } self.register_new_pane(new_pane.pane_id); Some(Ok((tab_idx, new_pane))) } /// 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, _terminal_id)) = self.active_tab_mut().and_then(Tab::close_focused) { self.unregister_pane(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, _terminal_id)) = 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 { Some(self.identity_cwd.clone()) } pub fn resolved_identity_cwd_from( &self, terminals: &HashMap, terminal_runtimes: &HashMap, ) -> Option { self.tabs .first() .and_then(|tab| tab.cwd_for_pane(tab.root_pane, terminals, terminal_runtimes)) .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 display_name_from( &self, terminals: &HashMap, terminal_runtimes: &HashMap, ) -> String { if let Some(name) = &self.custom_name { return name.clone(); } self.resolved_identity_cwd_from(terminals, terminal_runtimes) .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 } #[cfg(test)] 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 git_status_for_cwd( workspace_id: String, resolved_identity_cwd: PathBuf, ) -> WorkspaceGitStatus { WorkspaceGitStatus { branch: git_branch(&resolved_identity_cwd), ahead_behind: git_ahead_behind(&resolved_identity_cwd), workspace_id, resolved_identity_cwd, } } 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 terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { self.tabs.iter().find_map(|tab| tab.terminal_id(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, _terminal_id)) = self.tabs[tab_idx].close_pane(pane_id) { self.unregister_pane(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 terminal_id = TerminalId::alloc(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new(terminal_id)); let tab = Tab { custom_name: None, number: 1, root_pane: root_id, layout, 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 { 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, test_runtimes: HashMap::new(), } } pub(crate) fn insert_test_runtime(&mut self, pane_id: PaneId, runtime: TerminalRuntime) { self.test_runtimes.insert(pane_id, runtime); } 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(TerminalId::alloc())); 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(TerminalId::alloc())); let tab = Tab { custom_name: name.map(str::to_string), number: self.tabs.len() + 1, root_pane: root_id, layout, panes, 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; let terminal_id = ws.tabs[0].terminal_id(root_pane).unwrap().clone(); let mut terminals = HashMap::new(); terminals.insert( terminal_id.clone(), TerminalState::new(terminal_id, PathBuf::from("/herdr-test/pion")), ); let terminal_runtimes = HashMap::new(); assert_eq!(ws.display_name_from(&terminals, &terminal_runtimes), "pion"); assert_eq!( ws.resolved_identity_cwd_from(&terminals, &terminal_runtimes), Some(PathBuf::from("/herdr-test/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); } }