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};
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);
}
}
}
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();
}
}
}
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");
}
}
}
}
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);
}
}
}
#[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"]);
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();
state.workspaces[1].panes.get_mut(&bg_pane_id).unwrap().state = AgentState::Busy;
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);
}
}