aboutsummaryrefslogtreecommitdiff
path: root/src/workspace.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/workspace.rs')
-rw-r--r--src/workspace.rs596
1 files changed, 17 insertions, 579 deletions
diff --git a/src/workspace.rs b/src/workspace.rs
index 30e968fe..b80e5773 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -9,11 +9,22 @@ use ratatui::layout::Direction;
use tokio::sync::{mpsc, Notify};
use tracing::info;
-use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
-use crate::layout::{PaneId, TileLayout};
+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 {
@@ -25,287 +36,6 @@ pub(crate) fn generate_workspace_id() -> String {
format!("w{micros:x}{counter:x}")
}
-pub struct Tab {
- pub custom_name: Option<String>,
- pub number: usize,
- /// Identity source for this tab's pane tree.
- pub root_pane: PaneId,
- pub layout: TileLayout,
- /// Pane state — always present, testable without PTYs.
- pub panes: HashMap<PaneId, PaneState>,
- /// Best-effort cwd cache per pane. Live runtime cwd wins when available.
- pub pane_cwds: HashMap<PaneId, PathBuf>,
- /// Pane runtimes — only present in production (empty in tests).
- pub runtimes: HashMap<PaneId, PaneRuntime>,
- pub zoomed: bool,
- pub events: mpsc::Sender<AppEvent>,
- pub(crate) render_notify: Arc<Notify>,
- pub(crate) render_dirty: Arc<AtomicBool>,
-}
-
-impl Drop for Tab {
- fn drop(&mut self) {
- let runtimes = std::mem::take(&mut self.runtimes);
- for (pane_id, runtime) in runtimes {
- runtime.shutdown(pane_id);
- }
- }
-}
-
-impl Tab {
- pub fn new(
- number: usize,
- initial_cwd: PathBuf,
- rows: u16,
- cols: u16,
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- events: mpsc::Sender<AppEvent>,
- render_notify: Arc<Notify>,
- render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
- let (layout, root_id) = TileLayout::new();
- let runtime = PaneRuntime::spawn(
- root_id,
- rows,
- cols,
- initial_cwd.clone(),
- scrollback_limit_bytes,
- host_terminal_theme,
- events.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- )?;
-
- let mut panes = HashMap::new();
- panes.insert(root_id, PaneState::new());
- let mut pane_cwds = HashMap::new();
- pane_cwds.insert(root_id, initial_cwd);
- let mut runtimes = HashMap::new();
- runtimes.insert(root_id, runtime);
-
- Ok(Self {
- custom_name: None,
- number,
- root_pane: root_id,
- layout,
- panes,
- pane_cwds,
- runtimes,
- zoomed: false,
- events,
- render_notify,
- render_dirty,
- })
- }
-
- pub fn display_name(&self) -> String {
- self.custom_name
- .clone()
- .unwrap_or_else(|| self.number.to_string())
- }
-
- pub fn is_auto_named(&self) -> bool {
- self.custom_name.is_none()
- }
-
- pub fn set_custom_name(&mut self, name: String) {
- self.custom_name = Some(name);
- }
-
- pub fn split_focused(
- &mut self,
- direction: Direction,
- rows: u16,
- cols: u16,
- cwd: Option<PathBuf>,
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
- self.split_focused_with_runtime(
- direction,
- rows,
- cols,
- cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- None,
- )
- }
-
- pub fn split_focused_command(
- &mut self,
- direction: Direction,
- rows: u16,
- cols: u16,
- cwd: Option<PathBuf>,
- command: &str,
- extra_env: &[(String, String)],
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
- self.split_focused_with_runtime(
- direction,
- rows,
- cols,
- cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- Some((command, extra_env)),
- )
- }
-
- fn split_focused_with_runtime(
- &mut self,
- direction: Direction,
- rows: u16,
- cols: u16,
- cwd: Option<PathBuf>,
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- command: Option<(&str, &[(String, String)])>,
- ) -> std::io::Result<PaneId> {
- let new_id = self.layout.split_focused(direction);
- let actual_cwd =
- cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let runtime = if let Some((command, extra_env)) = command {
- PaneRuntime::spawn_shell_command(
- new_id,
- rows,
- cols,
- actual_cwd.clone(),
- command,
- extra_env,
- scrollback_limit_bytes,
- host_terminal_theme,
- self.events.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- )?
- } else {
- PaneRuntime::spawn(
- new_id,
- rows,
- cols,
- actual_cwd.clone(),
- scrollback_limit_bytes,
- host_terminal_theme,
- self.events.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- )?
- };
- self.panes.insert(new_id, PaneState::new());
- self.pane_cwds.insert(new_id, actual_cwd);
- self.runtimes.insert(new_id, runtime);
- self.zoomed = false;
- Ok(new_id)
- }
-
- pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> {
- let pane_id = self.layout.focused();
- self.detach_pane(pane_id)
- }
-
- pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
- self.detach_pane(pane_id)
- }
-
- 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>)> {
- if self.layout.pane_count() <= 1 {
- return None;
- }
-
- let next_root = self.promoted_root_if_needed(pane_id);
-
- if self.layout.focused() == pane_id {
- self.layout.close_focused();
- } else {
- let prev_focus = self.layout.focused();
- self.layout.focus_pane(pane_id);
- self.layout.close_focused();
- self.layout.focus_pane(prev_focus);
- }
-
- self.panes.remove(&pane_id);
- self.pane_cwds.remove(&pane_id);
- let runtime = self.runtimes.remove(&pane_id);
- self.zoomed = false;
- if let Some(next_root) = next_root {
- self.root_pane = next_root;
- }
- Some((pane_id, runtime))
- }
-
- fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> {
- if self.root_pane != closing {
- return None;
- }
- self.layout.pane_ids().into_iter().find(|id| *id != closing)
- }
-
- pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
- self.runtimes.get(&self.layout.focused())
- }
-
- pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> {
- self.runtimes
- .get(&pane_id)
- .and_then(|rt| rt.cwd())
- .or_else(|| self.pane_cwds.get(&pane_id).cloned())
- }
-
- pub fn root_cwd(&self) -> Option<PathBuf> {
- self.cwd_for_pane(self.root_pane)
- }
-
- pub fn has_working_pane(&self) -> bool {
- self.panes
- .values()
- .any(|pane| pane.state == AgentState::Working)
- }
-
- #[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()
- .iter()
- .map(|id| {
- self.panes
- .get(id)
- .map(|p| (p.state, p.seen))
- .unwrap_or((AgentState::Unknown, true))
- })
- .collect()
- }
-
- pub fn pane_details(&self) -> Vec<PaneDetail> {
- self.layout
- .pane_ids()
- .iter()
- .filter_map(|id| {
- let pane = self.panes.get(id)?;
- let agent_label = pane.effective_agent_label()?.to_string();
- Some(PaneDetail {
- pane_id: *id,
- tab_idx: self.number.saturating_sub(1),
- tab_label: self.display_name(),
- label: agent_label.clone(),
- agent_label,
- agent: pane.effective_known_agent(),
- state: pane.state,
- seen: pane.seen,
- })
- })
- .collect()
- }
-}
-
/// A named workspace containing tabs.
pub struct Workspace {
/// Stable public workspace identity, independent of display order.
@@ -602,33 +332,6 @@ impl Workspace {
.and_then(|cwd| git_ahead_behind(&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> {
- let multi_tab = self.tabs.len() > 1;
- self.tabs
- .iter()
- .flat_map(Tab::pane_details)
- .map(|mut detail| {
- if multi_tab {
- detail.label = format!("{}·{}", detail.tab_label, detail.agent_label);
- }
- detail
- })
- .collect()
- }
-
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
self.active_tab().and_then(Tab::focused_runtime)
}
@@ -714,123 +417,9 @@ impl Workspace {
}
}
-/// Detail info for a single pane, used by the agent detail panel.
-pub struct PaneDetail {
- pub pane_id: PaneId,
- pub tab_idx: usize,
- pub tab_label: String,
- pub label: String,
- pub agent_label: String,
- #[allow(dead_code)]
- pub agent: Option<Agent>,
- pub state: AgentState,
- pub seen: bool,
-}
-
-fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
- match (state, seen) {
- (AgentState::Blocked, _) => 4,
- (AgentState::Idle, false) => 3,
- (AgentState::Working, _) => 2,
- (AgentState::Idle, true) => 1,
- (AgentState::Unknown, _) => 0,
- }
-}
-
-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();
- }
- }
-
- if let Ok(home) = std::env::var("HOME") {
- let home = Path::new(&home);
- if cwd == home {
- return "~".to_string();
- }
- }
-
- cwd.file_name()
- .and_then(|n| n.to_str())
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string())
- .unwrap_or_else(|| cwd.display().to_string())
-}
-
-pub fn git_branch(cwd: &Path) -> Option<String> {
- let repo_root = git_repo_root(cwd)?;
- let git_dir = git_dir_for_repo_root(&repo_root)?;
- let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
- parse_git_head_branch(&head)
-}
-
-fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> {
- let git_path = repo_root.join(".git");
- if git_path.is_dir() {
- return Some(git_path);
- }
-
- let gitdir = std::fs::read_to_string(&git_path).ok()?;
- let relative = gitdir.trim().strip_prefix("gitdir:")?.trim();
- let resolved = Path::new(relative);
- Some(if resolved.is_absolute() {
- resolved.to_path_buf()
- } else {
- repo_root.join(resolved)
- })
-}
-
-fn parse_git_head_branch(head: &str) -> Option<String> {
- let branch = head.trim().strip_prefix("ref: refs/heads/")?;
- (!branch.is_empty()).then(|| branch.to_string())
-}
-
-fn git_repo_root(start: &Path) -> Option<PathBuf> {
- let mut current = if start.is_dir() {
- start.to_path_buf()
- } else {
- start.parent()?.to_path_buf()
- };
-
- loop {
- if current.join(".git").exists() {
- return Some(current);
- }
- if !current.pop() {
- return None;
- }
- }
-}
-
-fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> {
- git_repo_root(cwd)?;
-
- let output = std::process::Command::new("git")
- .arg("-C")
- .arg(cwd)
- .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
- .output()
- .ok()?;
-
- if !output.status.success() {
- return None;
- }
-
- let stdout = String::from_utf8(output.stdout).ok()?;
- parse_git_ahead_behind_output(&stdout)
-}
-
-fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> {
- let mut parts = stdout.split_whitespace();
- let ahead = parts.next()?.parse().ok()?;
- let behind = parts.next()?.parse().ok()?;
- Some((ahead, behind))
-}
-
#[cfg(test)]
impl Workspace {
- pub fn test_new(name: &str) -> Self {
+ 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));
@@ -867,7 +456,7 @@ impl Workspace {
}
}
- pub fn test_split(&mut self, direction: Direction) -> PaneId {
+ 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());
@@ -877,7 +466,7 @@ impl Workspace {
new_id
}
- pub fn test_add_tab(&mut self, name: Option<&str>) -> usize {
+ 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));
@@ -909,68 +498,6 @@ impl Workspace {
#[cfg(test)]
mod tests {
use super::*;
- use crate::detect::{Agent, AgentState};
-
- fn temp_test_dir(name: &str) -> PathBuf {
- let unique = format!(
- "herdr-workspace-tests-{}-{}-{}",
- name,
- std::process::id(),
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- );
- let path = std::env::temp_dir().join(unique);
- std::fs::create_dir_all(&path).unwrap();
- path
- }
-
- #[test]
- fn aggregate_state_all_unknown() {
- let ws = Workspace::test_new("test");
- let (state, seen) = ws.aggregate_state();
- assert_eq!(state, AgentState::Unknown);
- assert!(seen);
- }
-
- #[test]
- fn aggregate_state_priority() {
- let mut ws = Workspace::test_new("test");
- let id2 = ws.test_split(Direction::Horizontal);
- 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);
- assert!(seen);
- }
-
- #[test]
- 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.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.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 workspace_identity_follows_first_tab_root_pane_cwd() {
@@ -986,53 +513,6 @@ mod tests {
}
#[test]
- fn git_branch_reads_head_from_standard_repo() {
- let root = temp_test_dir("standard-repo");
- std::fs::create_dir_all(root.join(".git")).unwrap();
- std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
-
- assert_eq!(git_branch(&root).as_deref(), Some("main"));
-
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn git_branch_reads_head_from_worktree_gitdir_file() {
- let root = temp_test_dir("worktree");
- let worktree_git_dir = root.join(".bare/worktrees/feature");
- std::fs::create_dir_all(&worktree_git_dir).unwrap();
- std::fs::write(root.join(".git"), "gitdir: .bare/worktrees/feature\n").unwrap();
- std::fs::write(worktree_git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
-
- assert_eq!(git_branch(&root).as_deref(), Some("feature"));
-
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn git_branch_returns_none_for_detached_head() {
- let root = temp_test_dir("detached-head");
- std::fs::create_dir_all(root.join(".git")).unwrap();
- std::fs::write(root.join(".git/HEAD"), "3e1b9a8d\n").unwrap();
-
- assert_eq!(git_branch(&root), None);
-
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn pane_details_hide_plain_shells() {
- let mut ws = Workspace::test_new("test");
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
- ws.test_split(Direction::Horizontal);
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 1);
- assert_eq!(details[0].label, "pi");
- }
-
- #[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;
@@ -1051,46 +531,4 @@ mod tests {
assert_eq!(ws.tabs[2].root_pane, moved_root);
assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root);
}
-
- #[test]
- fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() {
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("main".into());
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
-
- let tab_idx = ws.test_add_tab(Some("logs"));
- let second_root_pane = ws.tabs[tab_idx].root_pane;
- ws.tabs[tab_idx]
- .panes
- .get_mut(&second_root_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 2);
- assert!(details.iter().any(|detail| detail.label == "main·pi"));
- assert!(details.iter().any(|detail| detail.label == "logs·claude"));
- }
-
- #[test]
- fn pane_details_include_hook_reported_unknown_agents() {
- let mut ws = Workspace::test_new("test");
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&root_pane)
- .unwrap()
- .set_hook_authority(
- "custom:hermes".into(),
- "hermes".into(),
- AgentState::Working,
- None,
- );
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 1);
- assert_eq!(details[0].agent_label, "hermes");
- assert_eq!(details[0].agent, None);
- }
}