herdr/src/workspace.rs

736 lines
22 KiB
Rust

use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
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::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>,
/// 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,
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,
events.clone(),
render_notify.clone(),
render_dirty.clone(),
)?;
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
let mut runtimes = HashMap::new();
runtimes.insert(root_id, runtime);
Ok(Self {
custom_name: None,
number,
root_pane: root_id,
layout,
panes,
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 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>,
) -> 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 = PaneRuntime::spawn(
new_id,
rows,
cols,
actual_cwd,
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
)?;
self.panes.insert(new_id, PaneState::new());
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);
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 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()
.map(|id| {
let pane = self.panes.get(id);
let agent = pane.and_then(|p| p.detected_agent);
let state = pane.map(|p| p.state).unwrap_or(AgentState::Unknown);
let seen = pane.map(|p| p.seen).unwrap_or(true);
let label = agent
.map(|a| agent_name(a).to_string())
.unwrap_or_else(|| "shell".to_string());
PaneDetail {
label,
agent,
state,
seen,
}
})
.collect()
}
}
/// A named workspace containing tabs.
pub struct Workspace {
/// User-provided override. If set, auto-derived identity stops updating.
pub custom_name: Option<String>,
/// Fixed workspace identity source, seeded when the workspace is created.
pub identity_cwd: PathBuf,
/// 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<PaneId, usize>,
pub(crate) next_public_pane_number: usize,
pub tabs: Vec<Tab>,
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,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
let tab = Tab::new(
1,
initial_cwd.clone(),
rows,
cols,
events,
render_notify,
render_dirty,
)?;
let mut public_pane_numbers = HashMap::new();
public_pane_numbers.insert(tab.root_pane, 1);
info!(root_pane = tab.root_pane.raw(), "workspace created");
Ok(Self {
custom_name: None,
identity_cwd: 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_mut(&mut self) -> Option<&mut Tab> {
self.tabs.get_mut(self.active_tab)
}
pub fn active_tab_display_name(&self) -> Option<String> {
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) -> std::io::Result<usize> {
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, events, render_notify, render_dirty)?;
self.register_new_pane(tab.root_pane);
self.tabs.push(tab);
self.active_tab = self.tabs.len() - 1;
Ok(self.active_tab)
}
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 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<PathBuf>,
) -> std::io::Result<PaneId> {
let new_id = self
.active_tab_mut()
.expect("workspace must always have at least one tab")
.split_focused(direction, rows, cols, cwd)?;
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<usize> {
self.public_pane_numbers.get(&pane_id).copied()
}
pub fn set_custom_name(&mut self, name: String) {
self.custom_name = Some(name);
}
pub fn display_name(&self) -> String {
if let Some(name) = &self.custom_name {
return name.clone();
}
derive_label_from_cwd(&self.identity_cwd)
}
pub fn branch(&self) -> Option<String> {
git_branch(&self.identity_cwd)
}
pub fn git_ahead_behind(&self) -> Option<(usize, usize)> {
self.cached_git_ahead_behind
}
pub fn refresh_git_ahead_behind(&mut self) {
self.cached_git_ahead_behind = git_ahead_behind(&self.identity_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> {
self.active_tab().map(Tab::pane_details).unwrap_or_default()
}
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<usize> {
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<PaneId> {
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
}
}
/// Detail info for a single pane, used by the agent detail panel.
pub struct PaneDetail {
pub 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,
}
}
fn agent_name(agent: Agent) -> &'static str {
match agent {
Agent::Pi => "pi",
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Gemini => "gemini",
Agent::Cursor => "cursor",
Agent::Cline => "cline",
Agent::OpenCode => "opencode",
Agent::GithubCopilot => "copilot",
Agent::Kimi => "kimi",
Agent::Droid => "droid",
Agent::Amp => "amp",
}
}
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 head_path = repo_root.join(".git").join("HEAD");
let content = std::fs::read_to_string(head_path).ok()?;
let trimmed = content.trim();
trimmed
.strip_prefix("ref: refs/heads/")
.map(|s| s.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 {
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 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 {
custom_name: Some(name.to_string()),
identity_cwd,
cached_git_ahead_behind: None,
public_pane_numbers,
next_public_pane_number: 2,
tabs: vec![tab],
active_tab: 0,
}
}
pub 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());
self.register_new_pane(new_id);
new_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::AgentState;
#[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);
}
}