diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-23 00:36:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-27 21:37:05 +0300 |
| commit | a57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch) | |
| tree | 25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/workspace.rs | |
initial release
terminal workspace manager for AI coding agents.
single rust binary, runs inside your existing terminal.
workspaces, tiled panes, intelligent agent state detection.
https://herdr.dev
Diffstat (limited to 'src/workspace.rs')
| -rw-r--r-- | src/workspace.rs | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 00000000..2b8a2902 --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,220 @@ +use std::collections::HashMap; + +use ratatui::layout::Direction; +use tokio::sync::mpsc; +use tracing::info; + +use crate::detect::{self, AgentState}; +use crate::events::AppEvent; +use crate::layout::{PaneId, TileLayout}; +use crate::pane::{PaneRuntime, PaneState}; + +/// A named workspace containing tiled terminal panes. +pub struct Workspace { + pub name: String, + 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>, +} + +impl Workspace { + pub fn new( + name: String, + rows: u16, + cols: u16, + events: mpsc::Sender<AppEvent>, + ) -> std::io::Result<Self> { + let (layout, root_id) = TileLayout::new(); + + let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); + let runtime = PaneRuntime::spawn(root_id, rows, cols, cwd, events.clone())?; + + let mut panes = HashMap::new(); + panes.insert(root_id, PaneState::new()); + let mut runtimes = HashMap::new(); + runtimes.insert(root_id, runtime); + + info!(workspace = %name, root_pane = root_id.raw(), "workspace created"); + Ok(Self { + name, + layout, + panes, + runtimes, + zoomed: false, + events, + }) + } + + /// Split the focused pane. Returns the new pane id. + pub fn split_focused( + &mut self, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option<std::path::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.panes.insert(new_id, PaneState::new()); + self.runtimes.insert(new_id, runtime); + self.zoomed = false; + Ok(new_id) + } + + /// Close the focused pane. Returns the removed pane id, or None if last pane. + pub fn close_focused(&mut self) -> Option<PaneId> { + if self.layout.pane_count() <= 1 { + return None; + } + let closed = self.layout.focused(); + if self.layout.close_focused() { + self.panes.remove(&closed); + self.runtimes.remove(&closed); + self.zoomed = false; + Some(closed) + } else { + None + } + } + + /// Get the runtime for the focused pane. + pub fn focused_runtime(&self) -> Option<&PaneRuntime> { + self.runtimes.get(&self.layout.focused()) + } + + /// Aggregate state + seen across all panes. + /// Returns the highest-priority state and the worst-case seen flag. + pub fn aggregate_state(&self) -> (AgentState, bool) { + let states: Vec<AgentState> = self.panes.values().map(|p| p.state).collect(); + let state = detect::workspace_state(&states); + let seen = self.panes.values().all(|p| p.seen); + (state, seen) + } + + /// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom). + 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() + } +} + +// --------------------------------------------------------------------------- +// Test helpers — construct workspaces without PTYs +// --------------------------------------------------------------------------- + +#[cfg(test)] +impl Workspace { + /// Create a test workspace with one pane, no PTY runtime. + pub fn test_new(name: &str) -> Self { + let (events, _) = mpsc::channel(64); + let (layout, root_id) = TileLayout::new(); + let mut panes = HashMap::new(); + panes.insert(root_id, PaneState::new()); + Self { + name: name.to_string(), + layout, + panes, + runtimes: HashMap::new(), + zoomed: false, + events, + } + } + + /// Add a test pane (splits focused, no PTY runtime). + pub fn test_split(&mut self, direction: Direction) -> PaneId { + let new_id = self.layout.split_focused(direction); + self.panes.insert(new_id, PaneState::new()); + 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); + + // Set one pane to Busy, one to Idle + let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); + ws.panes.get_mut(&root_id).unwrap().state = AgentState::Idle; + ws.panes.get_mut(&id2).unwrap().state = AgentState::Busy; + + let (state, _) = ws.aggregate_state(); + // Busy should be higher priority than Idle + assert_eq!(state, AgentState::Busy); + } + + #[test] + fn aggregate_seen_any_unseen_means_unseen() { + let mut ws = Workspace::test_new("test"); + let id2 = ws.test_split(Direction::Horizontal); + + ws.panes.get_mut(&id2).unwrap().seen = false; + + let (_, seen) = ws.aggregate_state(); + assert!(!seen); + } + + #[test] + fn close_focused_removes_pane() { + let mut ws = Workspace::test_new("test"); + let _id2 = ws.test_split(Direction::Horizontal); + assert_eq!(ws.panes.len(), 2); + + let closed = ws.close_focused(); + assert!(closed.is_some()); + assert_eq!(ws.panes.len(), 1); + } + + #[test] + fn close_focused_last_pane_returns_none() { + let mut ws = Workspace::test_new("test"); + assert_eq!(ws.panes.len(), 1); + + let closed = ws.close_focused(); + assert!(closed.is_none()); + assert_eq!(ws.panes.len(), 1); + } + + #[test] + fn pane_states_matches_layout_order() { + let mut ws = Workspace::test_new("test"); + let id2 = ws.test_split(Direction::Horizontal); + + // Set second pane to Waiting + ws.panes.get_mut(&id2).unwrap().state = AgentState::Waiting; + + let states = ws.pane_states(); + assert_eq!(states.len(), 2); + // One should be Unknown, one Waiting + assert!(states.iter().any(|(s, _)| *s == AgentState::Waiting)); + assert!(states.iter().any(|(s, _)| *s == AgentState::Unknown)); + } +} |
