diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 01:41:16 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 01:41:16 +0300 |
| commit | a36da363f14639085bec91d7a378d6c2eeaa6c58 (patch) | |
| tree | 2c86155b115f8c79db2b8826fd8b2199d153f7bf /src/app/actions.rs | |
| parent | e4de58b37f58d59d146158239ed218a8accd5c3c (diff) | |
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src/app/actions.rs')
| -rw-r--r-- | src/app/actions.rs | 298 |
1 files changed, 237 insertions, 61 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index be8e1018..27ca7474 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -6,7 +6,7 @@ use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; -use crate::pane::EffectiveStateChange; +use crate::terminal::EffectiveStateChange; use crate::workspace::WorkspaceGitStatus; use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout}; @@ -91,6 +91,7 @@ pub struct PaneStateUpdate { pub agent_label: Option<String>, pub known_agent: Option<Agent>, pub state: AgentState, + pub custom_status: Option<String>, } // --------------------------------------------------------------------------- @@ -406,14 +407,75 @@ impl AppState { self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll); } + pub(crate) fn terminal_ids_for_workspace( + &self, + ws_idx: usize, + ) -> Vec<crate::terminal::TerminalId> { + self.workspaces + .get(ws_idx) + .into_iter() + .flat_map(|ws| &ws.tabs) + .flat_map(|tab| tab.panes.values()) + .map(|pane| pane.attached_terminal_id.clone()) + .collect() + } + + pub(crate) fn terminal_ids_for_tab( + &self, + ws_idx: usize, + tab_idx: usize, + ) -> Vec<crate::terminal::TerminalId> { + self.workspaces + .get(ws_idx) + .and_then(|ws| ws.tabs.get(tab_idx)) + .into_iter() + .flat_map(|tab| tab.panes.values()) + .map(|pane| pane.attached_terminal_id.clone()) + .collect() + } + + pub(crate) fn terminal_id_for_pane( + &self, + ws_idx: usize, + pane_id: PaneId, + ) -> Option<crate::terminal::TerminalId> { + self.workspaces + .get(ws_idx)? + .pane_state(pane_id) + .map(|pane| pane.attached_terminal_id.clone()) + } + + pub(crate) fn remove_unattached_terminal_ids( + &mut self, + terminal_ids: impl IntoIterator<Item = crate::terminal::TerminalId>, + ) { + for terminal_id in terminal_ids { + let still_attached = self.workspaces.iter().any(|ws| { + ws.tabs.iter().any(|tab| { + tab.panes + .values() + .any(|pane| pane.attached_terminal_id == terminal_id) + }) + }); + if !still_attached { + self.terminals.remove(&terminal_id); + if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { + runtime.shutdown(); + } + } + } + } + pub fn close_selected_workspace(&mut self) { if self.workspaces.is_empty() { return; } self.mark_session_dirty(); + let terminal_ids = self.terminal_ids_for_workspace(self.selected); let workspace_id = self.workspaces[self.selected].id.clone(); crate::logging::workspace_closed(&workspace_id); self.workspaces.remove(self.selected); + self.remove_unattached_terminal_ids(terminal_ids); if self.workspaces.is_empty() { self.active = None; self.selected = 0; @@ -529,12 +591,26 @@ impl AppState { pub fn close_pane(&mut self) { self.mark_session_dirty(); - let should_close_workspace = self - .active + let active = self.active; + let terminal_ids = active + .and_then(|i| { + self.workspaces + .get(i) + .and_then(|ws| ws.focused_pane_id().map(|pane_id| (i, pane_id))) + }) + .and_then(|(i, pane_id)| self.terminal_id_for_pane(i, pane_id)) + .into_iter() + .collect::<Vec<_>>(); + let should_close_workspace = active .and_then(|i| self.workspaces.get_mut(i)) .is_some_and(|ws| ws.close_focused()); if should_close_workspace { + if let Some(active) = active { + self.selected = active; + } self.close_selected_workspace(); + } else { + self.remove_unattached_terminal_ids(terminal_ids); } } @@ -545,16 +621,25 @@ impl AppState { .and_then(|i| self.workspaces.get(i)) .is_some_and(|ws| ws.tabs.len() <= 1); if should_close_workspace { + if let Some(active) = self.active { + self.selected = active; + } self.close_selected_workspace(); return; } if let Some(ws_idx) = self.active { + let terminal_ids = self + .workspaces + .get(ws_idx) + .map(|ws| self.terminal_ids_for_tab(ws_idx, ws.active_tab)) + .unwrap_or_default(); let Some(ws) = self.workspaces.get_mut(ws_idx) else { return; }; let workspace_id = ws.id.clone(); let closing_tab_id = format!("{}:{}", workspace_id, ws.active_tab + 1); ws.close_active_tab(); + self.remove_unattached_terminal_ids(terminal_ids); crate::logging::tab_closed(&workspace_id, &closing_tab_id); self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); @@ -572,37 +657,23 @@ impl AppState { } pub fn copy_selection(&mut self) { - let text = { - 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 => { - self.selection = None; - return; - } - }; - - let rt = match ws.runtime(sel.pane_id) { - Some(r) => r, - None => { - self.selection = None; - return; - } - }; + let mut sel = match self.selection.take() { + Some(sel) => sel, + None => return, + }; + if !sel.finish() { + return; + } - rt.extract_selection(sel) + let ws_idx = match self.active { + Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx, + _ => return, }; + let text = self + .runtime_for_pane_in_workspace(ws_idx, sel.pane_id) + .and_then(|rt| rt.extract_selection(&sel)); + if let Some(text) = text { if !text.is_empty() { self.request_clipboard_write = Some(text.into_bytes()); @@ -678,7 +749,9 @@ impl AppState { agent, state, } => self - .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state)) + .update_terminal_state(pane_id, |terminal| { + terminal.set_detected_state(agent, state) + }) .into_iter() .collect(), AppEvent::HookStateReported { @@ -690,8 +763,8 @@ impl AppState { custom_status, seq, } => self - .update_pane_state(pane_id, |pane| { - pane.set_hook_authority_with_custom_status( + .update_terminal_state(pane_id, |terminal| { + terminal.set_hook_authority_with_custom_status( source, agent_label, state, @@ -707,8 +780,8 @@ impl AppState { source, seq, } => self - .update_pane_state(pane_id, |pane| { - pane.clear_hook_authority(source.as_deref(), seq) + .update_terminal_state(pane_id, |terminal| { + terminal.clear_hook_authority(source.as_deref(), seq) }) .into_iter() .collect(), @@ -719,8 +792,8 @@ impl AppState { seq, .. } => self - .update_pane_state(pane_id, |pane| { - pane.release_agent(&source, &agent_label, seq) + .update_terminal_state(pane_id, |terminal| { + terminal.release_agent(&source, &agent_label, seq) }) .into_iter() .collect(), @@ -734,20 +807,21 @@ impl AppState { } } - fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate> + fn update_terminal_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate> where - F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>, + F: FnOnce(&mut crate::terminal::TerminalState) -> Option<EffectiveStateChange>, { let ws_idx = self .workspaces .iter() .position(|ws| ws.pane_state(pane_id).is_some())?; + let terminal_id = self.workspaces[ws_idx] + .pane_state(pane_id)? + .attached_terminal_id + .clone(); let change = { - let pane = self.workspaces[ws_idx] - .tabs - .iter_mut() - .find_map(|tab| tab.panes.get_mut(&pane_id))?; - update(pane)? + let terminal = self.terminals.get_mut(&terminal_id)?; + update(terminal)? }; let update = PaneStateUpdate { pane_id, @@ -758,6 +832,7 @@ impl AppState { agent_label: change.agent_label.clone(), known_agent: change.known_agent, state: change.state, + custom_status: change.custom_status.clone(), }; self.apply_pane_state_change(ws_idx, pane_id, &change); Some(update) @@ -838,6 +913,8 @@ impl AppState { return; }; + let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); + let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); let should_close_workspace = { let ws = &mut self.workspaces[ws_idx]; ws.remove_pane(pane_id) @@ -846,6 +923,7 @@ impl AppState { if should_close_workspace { self.workspaces.remove(ws_idx); + self.remove_unattached_terminal_ids(workspace_terminal_ids); if self.workspaces.is_empty() { self.active = None; self.selected = 0; @@ -862,6 +940,8 @@ impl AppState { self.selected = self.workspaces.len() - 1; } } + } else { + self.remove_unattached_terminal_ids(pane_terminal_id); } } } @@ -883,6 +963,7 @@ mod tests { let ws = Workspace::test_new(name); state.workspaces.push(ws); } + state.ensure_test_terminals(); if !state.workspaces.is_empty() { state.active = Some(0); state.mode = Mode::Terminal; @@ -969,11 +1050,15 @@ mod tests { } fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { - state.workspaces[ws_idx].tabs[tab_idx] + let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] .panes - .get_mut(&pane_id) + .get(&pane_id) .unwrap() - .set_detected_state(Some(Agent::Pi), AgentState::Idle); + .attached_terminal_id + .clone(); + if let Some(terminal) = state.terminals.get_mut(&terminal_id) { + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + } } #[test] @@ -987,6 +1072,7 @@ mod tests { let mut state = AppState::test_new(); state.workspaces = vec![first, second]; + state.ensure_test_terminals(); state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; @@ -1044,6 +1130,7 @@ mod tests { let mut state = AppState::test_new(); state.workspaces = vec![first, second]; + state.ensure_test_terminals(); state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; @@ -1068,6 +1155,7 @@ mod tests { let mut state = AppState::test_new(); state.workspaces = vec![workspace]; + state.ensure_test_terminals(); state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; @@ -1256,9 +1344,15 @@ mod tests { state: AgentState::Working, }); - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert_eq!(pane.state, AgentState::Working); - assert_eq!(pane.detected_agent, Some(Agent::Pi)); + let terminal_id = state.workspaces[0] + .panes + .get(&pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let terminal = state.terminals.get(&terminal_id).unwrap(); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); } #[test] @@ -1268,11 +1362,13 @@ mod tests { let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); // First set it to Working - state.workspaces[1] + let bg_terminal_id = state.workspaces[1] .panes - .get_mut(&bg_pane_id) + .get(&bg_pane_id) .unwrap() - .state = AgentState::Working; + .attached_terminal_id + .clone(); + state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working; // Now transition to Idle while in background state.handle_app_event(AppEvent::StateChanged { @@ -1291,9 +1387,14 @@ mod tests { state.active = Some(0); state.outer_terminal_focus = Some(true); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - let pane = state.workspaces[0].panes.get_mut(&pane_id).unwrap(); - pane.state = AgentState::Working; - pane.seen = false; + let terminal_id = state.workspaces[0] + .panes + .get(&pane_id) + .unwrap() + .attached_terminal_id + .clone(); + state.terminals.get_mut(&terminal_id).unwrap().state = AgentState::Working; + state.workspaces[0].panes.get_mut(&pane_id).unwrap().seen = false; state.handle_app_event(AppEvent::StateChanged { pane_id, @@ -1301,8 +1402,9 @@ mod tests { state: AgentState::Idle, }); + let terminal = state.terminals.get(&terminal_id).unwrap(); + assert_eq!(terminal.state, AgentState::Idle); let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert_eq!(pane.state, AgentState::Idle); assert!(pane.seen); } @@ -1394,11 +1496,13 @@ mod tests { state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); - state.workspaces[1] + let bg_terminal_id = state.workspaces[1] .panes - .get_mut(&bg_pane_id) + .get(&bg_pane_id) .unwrap() - .state = AgentState::Working; + .attached_terminal_id + .clone(); + state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working; state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, @@ -1422,6 +1526,7 @@ mod tests { state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.workspaces[1].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[1].test_add_tab(Some("logs")); + state.ensure_test_terminals(); let bg_pane_id = state.workspaces[1].tabs[second_tab].root_pane; state.handle_app_event(AppEvent::StateChanged { @@ -1443,6 +1548,7 @@ mod tests { state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.workspaces[0].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[0].test_add_tab(Some("logs")); + state.ensure_test_terminals(); let bg_pane_id = state.workspaces[0].tabs[second_tab].root_pane; state.handle_app_event(AppEvent::StateChanged { @@ -1567,4 +1673,74 @@ mod tests { state.close_pane(); assert_eq!(state.workspaces[0].panes.len(), 1); } + + #[test] + fn close_pane_removes_unattached_terminal_state() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); + let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + + state.close_pane(); + + assert!(!state.terminals.contains_key(&terminal_id)); + } + + #[test] + fn close_tab_removes_unattached_terminal_states() { + let mut state = app_with_workspaces(&["test"]); + let tab_idx = state.workspaces[0].test_add_tab(Some("logs")); + state.ensure_test_terminals(); + state.workspaces[0].switch_tab(tab_idx); + let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; + let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + + state.close_tab(); + + assert!(!state.terminals.contains_key(&terminal_id)); + } + + #[test] + fn close_workspace_removes_unattached_terminal_states() { + let mut state = app_with_workspaces(&["one", "two"]); + let terminal_id = state + .terminal_id_for_pane(0, state.workspaces[0].tabs[0].root_pane) + .unwrap(); + + state.close_selected_workspace(); + + assert!(!state.terminals.contains_key(&terminal_id)); + } + + #[test] + fn close_tab_last_tab_closes_active_workspace_not_selected_workspace() { + let mut state = app_with_workspaces(&["selected", "active"]); + let active_terminal_id = state + .terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane) + .unwrap(); + state.active = Some(1); + state.selected = 0; + + state.close_tab(); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "selected"); + assert!(!state.terminals.contains_key(&active_terminal_id)); + } + + #[test] + fn close_pane_last_pane_closes_active_workspace_not_selected_workspace() { + let mut state = app_with_workspaces(&["selected", "active"]); + let active_terminal_id = state + .terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane) + .unwrap(); + state.active = Some(1); + state.selected = 0; + + state.close_pane(); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "selected"); + assert!(!state.terminals.contains_key(&active_terminal_id)); + } } |
