aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
commita36da363f14639085bec91d7a378d6c2eeaa6c58 (patch)
tree2c86155b115f8c79db2b8826fd8b2199d153f7bf /src/app/actions.rs
parente4de58b37f58d59d146158239ed218a8accd5c3c (diff)
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src/app/actions.rs')
-rw-r--r--src/app/actions.rs298
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));
+ }
}