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 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::{PaneLaunchEnv, PaneState}; use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState}; mod aggregate; mod git; mod tab; #[cfg(test)] use self::git::git_ahead_behind; pub(crate) use self::tab::MovedPane; pub use self::{ git::{ derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key, GitSpaceMetadata, GitStatusCacheEntry, }, tab::{NewPane, Tab}, }; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct WorktreeSpaceMembership { pub key: String, pub label: String, pub repo_root: PathBuf, pub checkout_path: PathBuf, pub is_linked_worktree: bool, } #[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)>, pub space: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceGitStatusSnapshot { pub branch: Option, pub ahead_behind: Option<(usize, usize)>, pub space: Option, } impl WorkspaceGitStatusSnapshot { pub fn into_workspace_status( self, workspace_id: String, resolved_identity_cwd: PathBuf, ) -> WorkspaceGitStatus { WorkspaceGitStatus { workspace_id, resolved_identity_cwd, branch: self.branch, ahead_behind: self.ahead_behind, space: self.space, } } } static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1); const PUBLIC_ID_ALPHABET: &[u8; 32] = b"123456789ABCDEFGHJKMNPQRSTVWXYZ0"; pub(crate) fn generate_workspace_id() -> String { let counter = NEXT_WORKSPACE_ID.fetch_add(1, Ordering::Relaxed); format!("w{}", encode_public_number(counter as usize)) } pub(crate) fn encode_public_number(mut value: usize) -> String { if value == 0 { return "0".to_string(); } let mut encoded = Vec::new(); while value > 0 { let digit = (value - 1) % PUBLIC_ID_ALPHABET.len(); encoded.push(PUBLIC_ID_ALPHABET[digit] as char); value = (value - 1) / PUBLIC_ID_ALPHABET.len(); } encoded.iter().rev().collect() } pub(crate) fn decode_public_number(value: &str) -> Option { let mut decoded = 0usize; for ch in value.chars() { let digit = PUBLIC_ID_ALPHABET .iter() .position(|candidate| *candidate as char == ch)?; decoded = decoded .checked_mul(PUBLIC_ID_ALPHABET.len())? .checked_add(digit + 1)?; } Some(decoded) } pub(crate) fn public_workspace_number(id: &str) -> Option { id.strip_prefix('w').and_then(decode_public_number) } pub(crate) fn public_pane_id_for_number(workspace_id: &str, pane_number: usize) -> String { format!("{workspace_id}:p{}", encode_public_number(pane_number)) } pub(crate) fn public_tab_id_for_number(workspace_id: &str, tab_number: usize) -> String { format!("{workspace_id}:t{}", encode_public_number(tab_number)) } pub(crate) fn reserve_workspace_ids(workspaces: &[Workspace]) { let Some(next) = workspaces .iter() .filter_map(|workspace| public_workspace_number(&workspace.id)) .max() .and_then(|max| u64::try_from(max.checked_add(1)?).ok()) else { return; }; let mut current = NEXT_WORKSPACE_ID.load(Ordering::Relaxed); while current < next { match NEXT_WORKSPACE_ID.compare_exchange_weak( current, next, Ordering::Relaxed, Ordering::Relaxed, ) { Ok(_) => break, Err(observed) => current = observed, } } } /// 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)>, /// Cached derived Git repo metadata for worktree actions and status display. pub(crate) cached_git_space: Option, /// Explicit Herdr-managed worktree grouping provenance. pub worktree_space: Option, /// Public pane numbers within this workspace. Closed pane numbers are not reused. pub public_pane_numbers: HashMap, pub(crate) next_public_pane_number: usize, pub(crate) next_public_tab_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 { fn adjust_active_tab_after_removal(&mut self, removed_idx: usize) { if self.tabs.is_empty() { self.active_tab = 0; } else if self.active_tab >= self.tabs.len() { self.active_tab = self.tabs.len() - 1; } else if removed_idx <= self.active_tab && self.active_tab > 0 { self.active_tab -= 1; } } pub(crate) fn from_existing_pane( label: Option, tab_label: Option, identity_cwd: PathBuf, moved: MovedPane, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> Self { let id = generate_workspace_id(); let root_pane = moved.pane_id; let tab = Tab::from_existing_pane(1, tab_label, moved, events, render_notify, render_dirty); let mut public_pane_numbers = HashMap::new(); public_pane_numbers.insert(root_pane, 1); Self { id, custom_name: label, identity_cwd: identity_cwd.clone(), cached_git_branch: git_branch(&identity_cwd), cached_git_ahead_behind: None, cached_git_space: git_space_metadata(&identity_cwd), worktree_space: None, public_pane_numbers, next_public_pane_number: 2, next_public_tab_number: 2, tabs: vec![tab], active_tab: 0, #[cfg(test)] test_runtimes: HashMap::new(), } } // Test modules construct workspaces through the default constructor; production paths // use the env-aware variant so pane identity env is always explicit. #[cfg_attr(not(test), allow(dead_code))] pub fn new( initial_cwd: PathBuf, rows: u16, cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_extra_env( initial_cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, shell_config, events, render_notify, render_dirty, Vec::new(), ) } #[allow(clippy::too_many_arguments)] pub fn new_with_extra_env( initial_cwd: PathBuf, rows: u16, cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, shell_config, events, render_notify, render_dirty, None, extra_env, ) } // Kept for tests that do not need launch-env customization. #[allow(dead_code)] 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_argv_command_with_extra_env( initial_cwd, rows, cols, argv, scrollback_limit_bytes, host_terminal_theme, events, render_notify, render_dirty, Vec::new(), ) } #[allow(clippy::too_many_arguments)] pub fn new_argv_command_with_extra_env( 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, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), events, render_notify, render_dirty, Some(argv), extra_env, ) } #[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, shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, argv: Option<&[String]>, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let id = generate_workspace_id(); let launch_env = PaneLaunchEnv::from_extra(extra_env).with_identity( id.clone(), public_tab_id_for_number(&id, 1), public_pane_id_for_number(&id, 1), ); 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, &launch_env, events, render_notify, render_dirty, )? } else { Tab::new( 1, initial_cwd.clone(), rows, cols, scrollback_limit_bytes, host_terminal_theme, shell_config, &launch_env, events, render_notify, render_dirty, )? }; let mut public_pane_numbers = HashMap::new(); public_pane_numbers.insert(tab.root_pane, 1); Ok(( Self { id, custom_name: None, identity_cwd: initial_cwd.clone(), cached_git_branch: git_branch(&initial_cwd), cached_git_ahead_behind: None, cached_git_space: None, worktree_space: None, public_pane_numbers, next_public_pane_number: 2, next_public_tab_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.tab_display_name(self.active_tab) } pub fn tab_display_name(&self, tab_idx: usize) -> Option { let tab = self.tabs.get(tab_idx)?; Some( tab.custom_name .clone() .unwrap_or_else(|| (tab_idx + 1).to_string()), ) } 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, shell_config: crate::pane::PaneShellConfig<'_>, extra_env: Vec<(String, String)>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { self.create_tab_with_runtime( rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, shell_config, None, extra_env, ) } pub fn create_tab_argv_command( &mut self, rows: u16, cols: u16, cwd: PathBuf, argv: &[String], extra_env: Vec<(String, String)>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { self.create_tab_with_runtime( rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), Some(argv), extra_env, ) } fn create_tab_with_runtime( &mut self, rows: u16, cols: u16, cwd: PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, argv: Option<&[String]>, extra_env: Vec<(String, String)>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { let number = self.next_public_tab_number; self.next_public_tab_number += 1; let pane_number = self.next_public_pane_number; let launch_env = self.launch_env_for_new_pane(number, pane_number, extra_env); 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, &launch_env, events, render_notify, render_dirty, )? } else { Tab::new( number, cwd, rows, cols, scrollback_limit_bytes, host_terminal_theme, shell_config, &launch_env, events, render_notify, render_dirty, )? }; self.register_new_pane_with_number(tab.root_pane, pane_number); 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); } 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.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, shell_config: crate::pane::PaneShellConfig<'_>, extra_env: Vec<(String, String)>, ) -> std::io::Result { let pane_number = self.next_public_pane_number; let tab_number = self .active_tab() .map(|tab| tab.number) .expect("workspace must always have at least one tab"); let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); 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, shell_config, &launch_env, )?; self.register_new_pane_with_number(new_pane.pane_id, pane_number); Ok(new_pane) } #[allow(clippy::too_many_arguments)] pub fn split_focused_command( &mut self, direction: Direction, rows: u16, cols: u16, cwd: Option, command: &str, extra_env: Vec<(String, String)>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result { let pane_number = self.next_public_pane_number; let tab_number = self .active_tab() .map(|tab| tab.number) .expect("workspace must always have at least one tab"); let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); let new_pane = self .active_tab_mut() .expect("workspace must always have at least one tab") .split_focused_command( direction, rows, cols, cwd, command, &launch_env, scrollback_limit_bytes, host_terminal_theme, )?; self.register_new_pane_with_number(new_pane.pane_id, pane_number); 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, shell_config: crate::pane::PaneShellConfig<'_>, extra_env: Vec<(String, String)>, focus_new_pane: bool, ) -> Option> { self.split_pane_with_runtime( pane_id, direction, None, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, shell_config, extra_env, focus_new_pane, None, ) } #[allow(clippy::too_many_arguments)] pub fn split_pane_with_ratio( &mut self, pane_id: PaneId, direction: Direction, ratio: f32, rows: u16, cols: u16, cwd: Option, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, extra_env: Vec<(String, String)>, focus_new_pane: bool, ) -> Option> { self.split_pane_with_runtime( pane_id, direction, Some(ratio), rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, shell_config, extra_env, 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], extra_env: Vec<(String, 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, None, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), extra_env, focus_new_pane, Some(argv), ) } #[allow(clippy::too_many_arguments)] pub fn split_pane_argv_command_with_ratio( &mut self, pane_id: PaneId, direction: Direction, ratio: f32, rows: u16, cols: u16, cwd: Option, argv: &[String], extra_env: Vec<(String, 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, Some(ratio), rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), extra_env, focus_new_pane, Some(argv), ) } #[allow(clippy::too_many_arguments)] fn split_pane_with_runtime( &mut self, pane_id: PaneId, direction: Direction, ratio: Option, rows: u16, cols: u16, cwd: Option, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, shell_config: crate::pane::PaneShellConfig<'_>, extra_env: Vec<(String, String)>, focus_new_pane: bool, argv: Option<&[String]>, ) -> Option> { let tab_idx = self.find_tab_index_for_pane(pane_id)?; let pane_number = self.next_public_pane_number; let tab_number = self.tabs[tab_idx].number; let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); 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 { match ratio { Some(ratio) => tab.split_focused_argv_command_with_ratio( direction, ratio, rows, cols, cwd, argv, &launch_env, scrollback_limit_bytes, host_terminal_theme, ), None => tab.split_focused_argv_command( direction, rows, cols, cwd, argv, &launch_env, scrollback_limit_bytes, host_terminal_theme, ), } } else { match ratio { Some(ratio) => tab.split_focused_with_ratio( direction, ratio, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, shell_config, &launch_env, ), None => tab.split_focused( direction, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, shell_config, &launch_env, ), } } { 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_with_number(new_pane.pane_id, pane_number); 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); 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(crate) fn take_pane_for_move(&mut self, pane_id: PaneId) -> Option { let tab_idx = self.find_tab_index_for_pane(pane_id)?; let pane_count = self.tabs[tab_idx].layout.pane_count(); if pane_count <= 1 { let mut tab = self.tabs.remove(tab_idx); let moved = tab.take_pane_for_move(pane_id)?; self.adjust_active_tab_after_removal(tab_idx); return Some(TakenPane { moved, removed_tab_idx: Some(tab_idx), workspace_empty: self.tabs.is_empty(), }); } let moved = self.tabs[tab_idx].take_pane_for_move(pane_id)?; Some(TakenPane { moved, removed_tab_idx: None, workspace_empty: false, }) } pub(crate) fn insert_moved_pane_into_tab( &mut self, tab_idx: usize, target_pane_id: PaneId, moved: MovedPane, direction: Direction, ratio: f32, ) -> Result { let pane_id = moved.pane_id; let Some(tab) = self.tabs.get_mut(tab_idx) else { return Err(moved); }; tab.insert_existing_pane(target_pane_id, moved, direction, ratio)?; if !self.public_pane_numbers.contains_key(&pane_id) { self.register_new_pane_with_number(pane_id, self.next_public_pane_number); } Ok(pane_id) } pub(crate) fn create_tab_from_existing_pane( &mut self, moved: MovedPane, label: Option, fallback_events: mpsc::Sender, fallback_render_notify: Arc, fallback_render_dirty: Arc, ) -> usize { let number = self.next_public_tab_number; self.next_public_tab_number += 1; let pane_id = moved.pane_id; let (events, render_notify, render_dirty) = self .active_tab() .map(|tab| { ( tab.events.clone(), tab.render_notify.clone(), tab.render_dirty.clone(), ) }) .unwrap_or(( fallback_events, fallback_render_notify, fallback_render_dirty, )); let tab = Tab::from_existing_pane(number, label, moved, events, render_notify, render_dirty); if !self.public_pane_numbers.contains_key(&pane_id) { self.register_new_pane_with_number(pane_id, self.next_public_pane_number); } self.tabs.push(tab); self.tabs.len() - 1 } pub(crate) fn unregister_moved_pane(&mut self, pane_id: PaneId) { self.unregister_pane(pane_id); } pub fn public_pane_number(&self, pane_id: PaneId) -> Option { self.public_pane_numbers.get(&pane_id).copied() } fn launch_env_for_new_pane( &self, tab_number: usize, pane_number: usize, extra_env: Vec<(String, String)>, ) -> PaneLaunchEnv { PaneLaunchEnv::from_extra(extra_env).with_identity( self.id.clone(), public_tab_id_for_number(&self.id, tab_number), public_pane_id_for_number(&self.id, pane_number), ) } pub fn public_tab_number(&self, tab_idx: usize) -> Option { self.tabs.get(tab_idx).map(|tab| tab.number) } #[cfg(test)] pub fn public_tab_number_for_pane(&self, pane_id: PaneId) -> Option { let tab_idx = self.find_tab_index_for_pane(pane_id)?; self.public_tab_number(tab_idx) } 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: &TerminalRuntimeRegistry, ) -> 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: &TerminalRuntimeRegistry, ) -> 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 } pub fn git_space(&self) -> Option<&GitSpaceMetadata> { self.cached_git_space.as_ref() } pub fn worktree_space(&self) -> Option<&WorktreeSpaceMembership> { self.worktree_space.as_ref() } #[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); self.cached_git_space = cwd.as_deref().and_then(git_space_metadata); } pub fn git_status_snapshot_for_cwd_with_cache( resolved_identity_cwd: &std::path::Path, cached: Option<&GitStatusCacheEntry>, ) -> (WorkspaceGitStatusSnapshot, Option) { self::git::git_status_snapshot_for_cwd(resolved_identity_cwd, cached) } 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); 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 } #[cfg(test)] fn register_new_pane(&mut self, pane_id: PaneId) { self.register_new_pane_with_number(pane_id, self.next_public_pane_number); } fn register_new_pane_with_number(&mut self, pane_id: PaneId, number: usize) { self.public_pane_numbers.insert(pane_id, number); self.next_public_pane_number = self.next_public_pane_number.max(number + 1); } fn unregister_pane(&mut self, pane_id: PaneId) { self.public_pane_numbers.remove(&pane_id); } fn close_active_tab_and_report(&mut self) -> bool { if self.tabs.len() <= 1 { return true; } self.close_active_tab(); false } } pub(crate) struct TakenPane { pub moved: MovedPane, pub removed_tab_idx: Option, pub workspace_empty: bool, } #[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, cached_git_space: None, worktree_space: None, public_pane_numbers, next_public_pane_number: 2, next_public_tab_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.next_public_tab_number, root_pane: root_id, layout, panes, runtimes: HashMap::new(), zoomed: false, events, render_notify, render_dirty, }; self.next_public_tab_number += 1; self.register_new_pane(root_id); self.tabs.push(tab); self.tabs.len() - 1 } pub(crate) fn test_adversarial_identity_state() -> Self { let mut ws = Self::test_new("adversarial-identity"); let removed_pane = ws.test_split(Direction::Horizontal); ws.test_split(Direction::Vertical); assert!(!ws.close_pane(removed_pane)); let _unused_raw_id = PaneId::alloc(); let later_pane = ws.test_split(Direction::Horizontal); let removed_tab = ws.test_add_tab(Some("removed")); let survivor_tab = ws.test_add_tab(None); let final_tab = ws.test_add_tab(None); let survivor_root = ws.tabs[survivor_tab].root_pane; let final_root = ws.tabs[final_tab].root_pane; assert!(ws.close_tab(removed_tab)); assert!(ws.move_tab(0, ws.tabs.len())); ws.switch_tab( ws.find_tab_index_for_pane(survivor_root) .expect("survivor tab should still exist"), ); assert_ne!( ws.active_tab + 1, ws.tabs[ws.active_tab].number, "adversarial active tab must distinguish position from public tab number" ); assert_ne!( later_pane.raw() as usize, ws.public_pane_number(later_pane).unwrap(), "adversarial pane must distinguish raw pane id from public pane number" ); assert_eq!(ws.find_tab_index_for_pane(final_root), Some(1)); ws } pub(crate) fn assert_invariants_for_test(&self) { assert!( !self.tabs.is_empty(), "workspace {} must contain at least one tab", self.id ); assert!( self.active_tab < self.tabs.len(), "workspace {} active_tab {} out of bounds for {} tabs", self.id, self.active_tab, self.tabs.len() ); let mut tab_numbers = std::collections::HashSet::new(); let mut max_tab_number = 0usize; let mut live_panes = std::collections::HashSet::new(); let mut terminal_ids = std::collections::HashSet::new(); for (tab_idx, tab) in self.tabs.iter().enumerate() { assert!( tab.number > 0, "workspace {} tab {} has invalid public tab number 0", self.id, tab_idx ); assert!( tab_numbers.insert(tab.number), "workspace {} has duplicate public tab number {}", self.id, tab.number ); max_tab_number = max_tab_number.max(tab.number); assert!( tab.panes.contains_key(&tab.root_pane), "workspace {} tab {} root pane {:?} is missing from tab panes", self.id, tab_idx, tab.root_pane ); let layout_panes = tab.layout.pane_ids(); let layout_set: std::collections::HashSet<_> = layout_panes.iter().copied().collect(); assert_eq!( layout_panes.len(), layout_set.len(), "workspace {} tab {} layout contains duplicate pane ids", self.id, tab_idx ); assert!( layout_set.contains(&tab.layout.focused()), "workspace {} tab {} focused pane {:?} is not in layout", self.id, tab_idx, tab.layout.focused() ); let pane_set: std::collections::HashSet<_> = tab.panes.keys().copied().collect(); assert_eq!( layout_set, pane_set, "workspace {} tab {} layout panes must exactly match pane states", self.id, tab_idx ); for (pane_id, pane) in &tab.panes { assert!( live_panes.insert(*pane_id), "workspace {} pane {:?} appears in more than one tab", self.id, pane_id ); assert!( self.public_pane_numbers.contains_key(pane_id), "workspace {} live pane {:?} has no public pane number", self.id, pane_id ); assert!( terminal_ids.insert(pane.attached_terminal_id.clone()), "workspace {} terminal {} is attached to multiple panes", self.id, pane.attached_terminal_id ); } } assert!( self.next_public_tab_number > 0, "workspace {} next_public_tab_number must be greater than 0", self.id ); assert!( self.next_public_tab_number > max_tab_number, "workspace {} next_public_tab_number {} must be greater than max live public tab number {}", self.id, self.next_public_tab_number, max_tab_number ); let public_pane_keys: std::collections::HashSet<_> = self.public_pane_numbers.keys().copied().collect(); assert_eq!( public_pane_keys, live_panes, "workspace {} public pane map must exactly match live panes", self.id ); let mut pane_numbers = std::collections::HashSet::new(); let mut max_pane_number = 0usize; for (pane_id, pane_number) in &self.public_pane_numbers { assert!( *pane_number > 0, "workspace {} pane {:?} has invalid public pane number 0", self.id, pane_id ); assert!( pane_numbers.insert(*pane_number), "workspace {} duplicate public pane number {} for pane {:?}", self.id, pane_number, pane_id ); max_pane_number = max_pane_number.max(*pane_number); } assert!( self.next_public_pane_number > 0, "workspace {} next_public_pane_number must be greater than 0", self.id ); assert!( self.next_public_pane_number > max_pane_number, "workspace {} next_public_pane_number {} must be greater than max live public pane number {}", self.id, self.next_public_pane_number, max_pane_number ); } } #[cfg(test)] mod tests { use super::*; #[test] fn generated_workspace_ids_are_short_base32_handles() { let first = generate_workspace_id(); let second = generate_workspace_id(); assert!(first.starts_with('w')); assert!(second.starts_with('w')); assert_ne!(first, second); assert!(first.len() <= 3, "unexpectedly long workspace id: {first}"); assert!( second.len() <= 3, "unexpectedly long workspace id: {second}" ); } #[test] fn public_numbers_round_trip_readable_base32_handles() { assert_eq!(encode_public_number(1), "1"); assert_eq!(encode_public_number(9), "9"); assert_eq!(encode_public_number(10), "A"); assert_eq!(encode_public_number(31), "Z"); assert_eq!(encode_public_number(32), "0"); assert_eq!(encode_public_number(33), "11"); for value in [1, 9, 10, 31, 32, 33, 1024, 1025] { let encoded = encode_public_number(value); assert_eq!(decode_public_number(&encoded), Some(value)); } } #[test] fn reserving_restored_workspace_ids_prevents_reuse() { let mut restored = Workspace::test_new("restored"); restored.id = "wZ".to_string(); reserve_workspace_ids(&[restored]); let generated = generate_workspace_id(); assert_ne!(generated, "wZ"); assert!(public_workspace_number(&generated) > public_workspace_number("wZ")); } #[test] fn pane_public_numbers_are_stable_and_not_reused_after_close() { let mut ws = Workspace::test_new("test"); let root = ws.tabs[0].root_pane; let second = ws.test_split(Direction::Horizontal); let third = ws.test_split(Direction::Vertical); assert_eq!(ws.public_pane_number(root), Some(1)); assert_eq!(ws.public_pane_number(second), Some(2)); assert_eq!(ws.public_pane_number(third), Some(3)); assert!(!ws.close_pane(second)); assert_eq!(ws.public_pane_number(root), Some(1)); assert_eq!(ws.public_pane_number(second), None); assert_eq!(ws.public_pane_number(third), Some(3)); let fourth = ws.test_split(Direction::Horizontal); assert_eq!(ws.public_pane_number(fourth), Some(4)); } #[test] fn tab_public_numbers_are_stable_and_not_reused_after_close() { let mut ws = Workspace::test_new("test"); let first_root = ws.tabs[0].root_pane; let second_tab = ws.test_add_tab(None); let second_root = ws.tabs[second_tab].root_pane; let third_tab = ws.test_add_tab(None); let third_root = ws.tabs[third_tab].root_pane; assert_eq!(ws.public_tab_number_for_pane(first_root), Some(1)); assert_eq!(ws.public_tab_number_for_pane(second_root), Some(2)); assert_eq!(ws.public_tab_number_for_pane(third_root), Some(3)); assert!(ws.close_tab(second_tab)); assert_eq!(ws.public_tab_number_for_pane(first_root), Some(1)); assert_eq!(ws.public_tab_number_for_pane(third_root), Some(3)); let fourth_tab = ws.test_add_tab(None); let fourth_root = ws.tabs[fourth_tab].root_pane; assert_eq!(ws.public_tab_number_for_pane(fourth_root), Some(4)); ws.assert_invariants_for_test(); } #[test] fn adversarial_identity_state_satisfies_workspace_invariants_after_mutation() { let mut ws = Workspace::test_adversarial_identity_state(); ws.assert_invariants_for_test(); let active_public = ws.tabs[ws.active_tab].number; assert_ne!(ws.active_tab + 1, active_public); let divergent_pane = ws .public_pane_numbers .iter() .find_map(|(pane_id, public_number)| { (pane_id.raw() as usize != *public_number).then_some(*pane_id) }) .expect("adversarial state should contain raw/public pane divergence"); assert_ne!( divergent_pane.raw() as usize, ws.public_pane_number(divergent_pane).unwrap() ); let new_pane = ws.test_split(Direction::Vertical); assert!(ws.public_pane_number(new_pane).is_some()); assert!(ws.move_tab(ws.active_tab, ws.tabs.len())); ws.assert_invariants_for_test(); } #[test] fn failed_moved_pane_insert_returns_pane_for_recovery() { let mut source = Workspace::test_new("source"); let source_pane = source.tabs[0].root_pane; let taken = source .take_pane_for_move(source_pane) .expect("source pane should be movable"); let mut target = Workspace::test_new("target"); let missing_target = PaneId::alloc(); let recovered = target .insert_moved_pane_into_tab(0, missing_target, taken.moved, Direction::Horizontal, 0.5) .expect_err("invalid target should return the moved pane"); assert_eq!(recovered.pane_id, source_pane); assert!(!target.tabs[0].panes.contains_key(&source_pane)); } #[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 = TerminalRuntimeRegistry::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_stable_tab_numbers() { 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<_> = (0..ws.tabs.len()) .map(|tab_idx| ws.tab_display_name(tab_idx).unwrap()) .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[0].number, 2); assert_eq!(ws.tabs[1].number, 3); assert_eq!(ws.tabs[2].number, 1); assert_eq!(ws.tabs[2].root_pane, moved_root); assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root); ws.assert_invariants_for_test(); } }