aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-23 00:36:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-27 21:37:05 +0300
commita57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch)
tree25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/app/actions.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/app/actions.rs')
-rw-r--r--src/app/actions.rs417
1 files changed, 417 insertions, 0 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
new file mode 100644
index 00000000..8e2e9297
--- /dev/null
+++ b/src/app/actions.rs
@@ -0,0 +1,417 @@
+//! Pure state mutations on AppState.
+//! These don't need channels, async, or PTY runtime.
+
+use tracing::{info, warn};
+
+use crate::detect::AgentState;
+use crate::events::AppEvent;
+use crate::layout::{find_in_direction, NavDirection, PaneId};
+
+use super::state::{AppState, Mode};
+
+// ---------------------------------------------------------------------------
+// Workspace operations
+// ---------------------------------------------------------------------------
+
+impl AppState {
+ pub fn switch_workspace(&mut self, idx: usize) {
+ if idx < self.workspaces.len() {
+ self.active = Some(idx);
+ self.selected = idx;
+ for pane in self.workspaces[idx].panes.values_mut() {
+ pane.seen = true;
+ }
+ }
+ }
+
+ pub fn close_selected_workspace(&mut self) {
+ if self.workspaces.is_empty() {
+ return;
+ }
+ let name = self.workspaces[self.selected].name.clone();
+ info!(workspace = %name, "workspace closed");
+ self.workspaces.remove(self.selected);
+ if self.workspaces.is_empty() {
+ self.active = None;
+ self.selected = 0;
+ } else {
+ if self.selected >= self.workspaces.len() {
+ self.selected = self.workspaces.len() - 1;
+ }
+ self.active = Some(self.selected);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pane operations
+// ---------------------------------------------------------------------------
+
+impl AppState {
+ pub fn navigate_pane(&mut self, direction: NavDirection) {
+ let panes = &self.view.pane_infos;
+ if let Some(focused) = panes.iter().find(|p| p.is_focused) {
+ if let Some(target) = find_in_direction(focused, direction, panes) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ ws.layout.focus_pane(target);
+ }
+ }
+ }
+ }
+
+ pub fn resize_pane(&mut self, direction: NavDirection) {
+ if let Some(first) = self.view.pane_infos.first() {
+ let area = self
+ .view.pane_infos
+ .iter()
+ .fold(first.rect, |acc, p| acc.union(p.rect));
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ ws.layout.resize_focused(direction, 0.05, area);
+ }
+ }
+ }
+
+ pub fn cycle_pane(&mut self, reverse: bool) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if reverse {
+ ws.layout.focus_prev();
+ } else {
+ ws.layout.focus_next();
+ }
+ }
+ }
+
+ pub fn toggle_fullscreen(&mut self) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if ws.layout.pane_count() > 1 {
+ ws.zoomed = !ws.zoomed;
+ }
+ }
+ }
+
+ pub fn close_pane(&mut self) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ ws.close_focused();
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Selection
+// ---------------------------------------------------------------------------
+
+impl AppState {
+ pub fn clear_selection(&mut self) {
+ self.selection = None;
+ }
+
+ pub fn copy_selection(&mut self) {
+ let sel = match self.selection.as_mut() {
+ Some(s) => {
+ if !s.finish() {
+ self.selection = None;
+ return;
+ }
+ s
+ }
+ None => return,
+ };
+
+ let ws = match self.active.and_then(|i| self.workspaces.get(i)) {
+ Some(ws) => ws,
+ None => return,
+ };
+
+ let rt = match ws.runtimes.get(&sel.pane_id) {
+ Some(r) => r,
+ None => return,
+ };
+
+ if let Ok(parser) = rt.parser.read() {
+ let text = crate::selection::extract_text(parser.screen(), sel);
+ if !text.is_empty() {
+ crate::selection::write_osc52(&text);
+ info!(len = text.len(), "copied selection to clipboard");
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Event handling
+// ---------------------------------------------------------------------------
+
+impl AppState {
+ pub fn handle_app_event(&mut self, event: AppEvent) {
+ match event {
+ AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id),
+ AppEvent::UpdateReady { version } => {
+ self.update_available = Some(version);
+ self.update_dismissed = false;
+ }
+ AppEvent::StateChanged {
+ pane_id,
+ agent,
+ state,
+ } => {
+ for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() {
+ if let Some(pane) = ws.panes.get_mut(&pane_id) {
+ let is_active_ws = self.active == Some(ws_idx);
+ if state == AgentState::Idle
+ && pane.state != AgentState::Idle
+ && !is_active_ws
+ {
+ pane.seen = false;
+ }
+ pane.detected_agent = agent;
+ pane.state = state;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ fn handle_pane_died(&mut self, pane_id: PaneId) {
+ let ws_idx = self
+ .workspaces
+ .iter()
+ .position(|ws| ws.panes.contains_key(&pane_id));
+
+ let Some(ws_idx) = ws_idx else {
+ warn!(pane = pane_id.raw(), "PaneDied for unknown pane");
+ return;
+ };
+
+ let ws = &mut self.workspaces[ws_idx];
+
+ if ws.layout.pane_count() <= 1 {
+ self.workspaces.remove(ws_idx);
+ if self.workspaces.is_empty() {
+ self.active = None;
+ self.selected = 0;
+ if self.mode == Mode::Terminal {
+ self.mode = Mode::Navigate;
+ }
+ } else {
+ if let Some(active) = self.active {
+ if active >= self.workspaces.len() {
+ self.active = Some(self.workspaces.len() - 1);
+ }
+ }
+ if self.selected >= self.workspaces.len() {
+ self.selected = self.workspaces.len() - 1;
+ }
+ }
+ } else {
+ if ws.layout.focused() == pane_id {
+ ws.layout.close_focused();
+ } else {
+ let prev_focus = ws.layout.focused();
+ ws.layout.focus_pane(pane_id);
+ ws.layout.close_focused();
+ ws.layout.focus_pane(prev_focus);
+ }
+ ws.panes.remove(&pane_id);
+ ws.runtimes.remove(&pane_id);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::detect::{Agent, AgentState};
+ use crate::workspace::Workspace;
+ use ratatui::layout::Direction;
+
+ fn app_with_workspaces(names: &[&str]) -> AppState {
+ let mut state = AppState::test_new();
+ for name in names {
+ let ws = Workspace::test_new(name);
+ state.workspaces.push(ws);
+ }
+ if !state.workspaces.is_empty() {
+ state.active = Some(0);
+ state.mode = Mode::Terminal;
+ }
+ state
+ }
+
+ #[test]
+ fn switch_workspace_updates_active_and_selected() {
+ let mut state = app_with_workspaces(&["a", "b", "c"]);
+ state.switch_workspace(2);
+ assert_eq!(state.active, Some(2));
+ assert_eq!(state.selected, 2);
+ }
+
+ #[test]
+ fn switch_workspace_marks_panes_seen() {
+ let mut state = app_with_workspaces(&["a", "b"]);
+ // Mark a pane in workspace 1 as unseen
+ let id = *state.workspaces[1].panes.keys().next().unwrap();
+ state.workspaces[1].panes.get_mut(&id).unwrap().seen = false;
+
+ state.switch_workspace(1);
+ assert!(state.workspaces[1].panes.get(&id).unwrap().seen);
+ }
+
+ #[test]
+ fn switch_workspace_out_of_bounds_is_noop() {
+ let mut state = app_with_workspaces(&["a"]);
+ state.switch_workspace(5);
+ assert_eq!(state.active, Some(0));
+ }
+
+ #[test]
+ fn close_workspace_adjusts_indices() {
+ let mut state = app_with_workspaces(&["a", "b", "c"]);
+ state.selected = 1;
+ state.active = Some(1);
+
+ state.close_selected_workspace();
+
+ assert_eq!(state.workspaces.len(), 2);
+ assert_eq!(state.selected, 1);
+ assert_eq!(state.active, Some(1));
+ assert_eq!(state.workspaces[1].name, "c");
+ }
+
+ #[test]
+ fn close_last_workspace_clears_active() {
+ let mut state = app_with_workspaces(&["only"]);
+ state.selected = 0;
+ state.close_selected_workspace();
+
+ assert!(state.workspaces.is_empty());
+ assert_eq!(state.active, None);
+ assert_eq!(state.selected, 0);
+ }
+
+ #[test]
+ fn close_workspace_at_end_adjusts_selected() {
+ let mut state = app_with_workspaces(&["a", "b"]);
+ state.selected = 1;
+ state.active = Some(1);
+
+ state.close_selected_workspace();
+
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.selected, 0);
+ assert_eq!(state.active, Some(0));
+ }
+
+ #[test]
+ fn pane_died_last_pane_removes_workspace() {
+ let mut state = app_with_workspaces(&["a", "b"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+
+ state.handle_pane_died(pane_id);
+
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].name, "b");
+ }
+
+ #[test]
+ fn pane_died_last_workspace_enters_navigate() {
+ let mut state = app_with_workspaces(&["only"]);
+ state.mode = Mode::Terminal;
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+
+ state.handle_pane_died(pane_id);
+
+ assert!(state.workspaces.is_empty());
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn pane_died_multi_pane_keeps_workspace() {
+ let mut state = app_with_workspaces(&["test"]);
+ let second_id = state.workspaces[0].test_split(Direction::Horizontal);
+
+ state.handle_pane_died(second_id);
+
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].panes.len(), 1);
+ }
+
+ #[test]
+ fn pane_died_unknown_pane_is_noop() {
+ let mut state = app_with_workspaces(&["test"]);
+ let fake_id = PaneId::from_raw(9999);
+
+ state.handle_pane_died(fake_id);
+
+ assert_eq!(state.workspaces.len(), 1);
+ }
+
+ #[test]
+ fn state_changed_updates_pane() {
+ let mut state = app_with_workspaces(&["test"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Busy,
+ });
+
+ let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
+ assert_eq!(pane.state, AgentState::Busy);
+ assert_eq!(pane.detected_agent, Some(Agent::Pi));
+ }
+
+ #[test]
+ fn state_changed_idle_in_background_marks_unseen() {
+ let mut state = app_with_workspaces(&["active", "background"]);
+ state.active = Some(0);
+ let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
+
+ // First set it to Busy
+ state.workspaces[1].panes.get_mut(&bg_pane_id).unwrap().state = AgentState::Busy;
+
+ // Now transition to Idle while in background
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id: bg_pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Idle,
+ });
+
+ let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
+ assert!(!pane.seen);
+ }
+
+ #[test]
+ fn toggle_fullscreen_works() {
+ let mut state = app_with_workspaces(&["test"]);
+ state.workspaces[0].test_split(Direction::Horizontal);
+
+ assert!(!state.workspaces[0].zoomed);
+ state.toggle_fullscreen();
+ assert!(state.workspaces[0].zoomed);
+ state.toggle_fullscreen();
+ assert!(!state.workspaces[0].zoomed);
+ }
+
+ #[test]
+ fn toggle_fullscreen_single_pane_noop() {
+ let mut state = app_with_workspaces(&["test"]);
+ state.toggle_fullscreen();
+ assert!(!state.workspaces[0].zoomed);
+ }
+
+ #[test]
+ fn close_pane_removes_from_workspace() {
+ let mut state = app_with_workspaces(&["test"]);
+ state.workspaces[0].test_split(Direction::Horizontal);
+ assert_eq!(state.workspaces[0].panes.len(), 2);
+
+ state.close_pane();
+ assert_eq!(state.workspaces[0].panes.len(), 1);
+ }
+}