aboutsummaryrefslogtreecommitdiff
path: root/src/workspace
diff options
context:
space:
mode:
Diffstat (limited to 'src/workspace')
-rw-r--r--src/workspace/aggregate.rs193
-rw-r--r--src/workspace/git.rs149
-rw-r--r--src/workspace/tab.rs265
3 files changed, 607 insertions, 0 deletions
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
new file mode 100644
index 00000000..53e08920
--- /dev/null
+++ b/src/workspace/aggregate.rs
@@ -0,0 +1,193 @@
+use crate::detect::{Agent, AgentState};
+use crate::layout::PaneId;
+
+use super::{Tab, 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,
+}
+
+impl Tab {
+ pub fn has_working_pane(&self) -> bool {
+ self.panes
+ .values()
+ .any(|pane| pane.state == AgentState::Working)
+ }
+
+ 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()
+ }
+}
+
+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,
+ }
+}
+
+impl Workspace {
+ 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()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::layout::Direction;
+
+ use super::*;
+ use crate::detect::Agent;
+
+ #[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 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 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);
+ }
+}
diff --git a/src/workspace/git.rs b/src/workspace/git.rs
new file mode 100644
index 00000000..ca453003
--- /dev/null
+++ b/src/workspace/git.rs
@@ -0,0 +1,149 @@
+use std::path::{Path, PathBuf};
+
+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;
+ }
+ }
+}
+
+pub(super) 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)]
+mod tests {
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ use super::*;
+
+ 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 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();
+ }
+}
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
new file mode 100644
index 00000000..f8b31eb5
--- /dev/null
+++ b/src/workspace/tab.rs
@@ -0,0 +1,265 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+
+use ratatui::layout::Direction;
+use tokio::sync::{mpsc, Notify};
+
+use crate::events::AppEvent;
+use crate::layout::{PaneId, TileLayout};
+use crate::pane::{PaneRuntime, PaneState};
+
+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)
+ }
+
+ #[cfg(test)]
+ #[allow(dead_code)] // retained for focused layout-order assertions in tests
+ pub fn pane_states(&self) -> Vec<(crate::detect::AgentState, bool)> {
+ self.layout
+ .pane_ids()
+ .iter()
+ .map(|id| {
+ self.panes
+ .get(id)
+ .map(|p| (p.state, p.seen))
+ .unwrap_or((crate::detect::AgentState::Unknown, true))
+ })
+ .collect()
+ }
+}