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 | |
| parent | e4de58b37f58d59d146158239ed218a8accd5c3c (diff) | |
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 298 | ||||
| -rw-r--r-- | src/app/agents.rs | 66 | ||||
| -rw-r--r-- | src/app/api.rs | 75 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 4 | ||||
| -rw-r--r-- | src/app/creation.rs | 50 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 21 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 29 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 67 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 37 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 183 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 58 | ||||
| -rw-r--r-- | src/app/mod.rs | 180 | ||||
| -rw-r--r-- | src/app/runtime.rs | 4 | ||||
| -rw-r--r-- | src/app/session.rs | 2 | ||||
| -rw-r--r-- | src/app/state.rs | 91 | ||||
| -rw-r--r-- | src/app/terminal_targets.rs | 19 | ||||
| -rw-r--r-- | src/app/theme_sync.rs | 8 |
17 files changed, 846 insertions, 346 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)); + } } diff --git a/src/app/agents.rs b/src/app/agents.rs index 35e26c42..f9e410f5 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -77,19 +77,19 @@ impl App { } } - let Some(ws) = self.state.workspaces.get_mut(resolved.ws_idx) else { - return Err(AgentRenameError::Target(TerminalTargetError::NotFound { - target: target.to_string(), - })); - }; - let Some(pane_state) = ws.pane_state_mut(resolved.pane_id) else { + let Some(terminal) = self + .state + .terminals + .values_mut() + .find(|terminal| terminal.id.to_string() == resolved.terminal_id) + else { return Err(AgentRenameError::Target(TerminalTargetError::NotFound { target: target.to_string(), })); }; match normalized_name { - Some(name) => pane_state.set_manual_label(name), - None => pane_state.clear_manual_label(), + Some(name) => terminal.set_manual_label(name), + None => terminal.clear_manual_label(), } self.state.mark_session_dirty(); self.agent_info(resolved.ws_idx, resolved.pane_id) @@ -185,13 +185,17 @@ impl App { )? }; - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return Err(AgentStartError::SpawnFailed("workspace disappeared".into())); - }; - let Some(pane_state) = ws.pane_state_mut(pane_id) else { - return Err(AgentStartError::SpawnFailed("pane disappeared".into())); + let terminal_id = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + .ok_or_else(|| AgentStartError::SpawnFailed("terminal disappeared".into()))?; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return Err(AgentStartError::SpawnFailed("terminal disappeared".into())); }; - pane_state.set_manual_label(name); + terminal.set_manual_label(name); self.state.mark_session_dirty(); let agent = self @@ -317,7 +321,7 @@ impl App { argv: &[String], focus: bool, ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let ws = crate::workspace::Workspace::new_argv_command( + let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command( cwd, rows, cols, @@ -329,6 +333,10 @@ impl App { self.render_dirty.clone(), ) .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let ws_idx = self.state.workspaces.len() - 1; if focus || self.state.active.is_none() { @@ -375,13 +383,19 @@ impl App { target: target_pane.raw().to_string(), })? .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; + self.state + .terminal_runtimes + .insert(result.1.terminal.id.clone(), result.1.runtime); + self.state + .terminals + .insert(result.1.terminal.id.clone(), result.1.terminal); if focus { self.state.switch_workspace(ws_idx); self.state.switch_tab(result.0); self.state.mode = Mode::Terminal; } self.schedule_session_save(); - Ok((ws_idx, result.0, result.1)) + Ok((ws_idx, result.0, result.1.pane_id)) } fn agent_info( @@ -409,23 +423,11 @@ impl App { name: &str, except_terminal_id: &str, ) -> Vec<crate::api::schema::AgentInfo> { - self.state - .workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - ws.tabs.iter().flat_map(move |tab| { - tab.layout - .pane_ids() - .into_iter() - .filter_map(move |pane_id| { - let pane_state = ws.pane_state(pane_id)?; - (pane_state.manual_label.as_deref() == Some(name)) - .then(|| self.agent_info(ws_idx, pane_id))? - }) - }) + self.collect_agent_infos() + .into_iter() + .filter(|agent| { + agent.name.as_deref() == Some(name) && agent.terminal_id != except_terminal_id }) - .filter(|agent| agent.terminal_id != except_terminal_id) .collect() } } diff --git a/src/app/api.rs b/src/app/api.rs index c6fa88fa..ab26f82b 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -75,7 +75,8 @@ impl App { if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { if let Some((ws_idx, _)) = self.find_pane(pane_id) { - if let Some(runtime) = self.state.workspaces[ws_idx].runtimes.get(&pane_id) { + if let Some(runtime) = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) + { runtime.begin_graceful_release(agent); } } @@ -129,7 +130,12 @@ impl App { else { continue; }; - let Some(agent_label) = pane.effective_agent_label() else { + let Some(agent_label) = self + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + else { continue; }; let event_text = match kind { @@ -199,14 +205,9 @@ impl App { .workspaces .get(update.ws_idx) .and_then(|ws| ws.pane_state(update.pane_id)) - .map(|pane| pane_agent_status(pane.state, pane.seen)) + .map(|pane| pane_agent_status(update.state, pane.seen)) .unwrap_or_else(|| pane_agent_status(update.state, true)); - let custom_status = self - .state - .workspaces - .get(update.ws_idx) - .and_then(|ws| ws.pane_state(update.pane_id)) - .and_then(|pane| pane.effective_custom_status().map(str::to_string)); + let custom_status = update.custom_status.clone(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentStatusChanged, data: crate::api::schema::EventData::PaneAgentStatusChanged { @@ -296,7 +297,7 @@ impl App { .state .workspaces .get(ws_idx) - .and_then(|ws| ws.runtime(pane_id)) + .and_then(|_| self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)) else { return; }; @@ -629,11 +630,9 @@ impl App { let cwd = cwd .map(std::path::PathBuf::from) .or_else(|| { - self.state.workspaces.get(ws_idx).and_then(|ws| { - ws.active_tab() - .and_then(|tab| tab.focused_runtime()) - .and_then(|rt| rt.cwd()) - }) + self.state + .focused_runtime_in_workspace(ws_idx) + .and_then(|rt| rt.cwd()) }) .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from("/")); @@ -653,7 +652,11 @@ impl App { ) }); match result { - Ok(tab_idx) => { + Ok((tab_idx, terminal, runtime)) => { + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); if let Some(label) = label { let workspace_id = self.state.workspaces[ws_idx].id.clone(); let tab_id = self @@ -785,6 +788,7 @@ impl App { }) .unwrap(); }; + let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return serde_json::to_string(&ErrorResponse { id: request.id, @@ -815,6 +819,7 @@ impl App { }) .unwrap(); } + self.state.remove_unattached_terminal_ids(terminal_ids); self.schedule_session_save(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::TabClosed, @@ -1004,6 +1009,16 @@ impl App { .unwrap(); }; let (rows, cols) = self.state.estimate_pane_size(); + let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; + ws.tabs.get(tab_idx)?.cwd_for_pane( + target_pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) + }) + }); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return serde_json::to_string(&ErrorResponse { id: request.id, @@ -1022,12 +1037,12 @@ impl App { ratatui::layout::Direction::Vertical } }; - let (target_tab_idx, new_pane_id) = match ws.split_pane( + let (target_tab_idx, new_pane) = match ws.split_pane( target_pane_id, direction, rows, cols, - params.cwd.map(std::path::PathBuf::from), + split_cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, params.focus, @@ -1059,8 +1074,14 @@ impl App { self.state.switch_tab(target_tab_idx); self.state.mode = Mode::Terminal; } + self.state + .terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); self.schedule_session_save(); - let pane = self.pane_info(ws_idx, new_pane_id).unwrap(); + let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneCreated, data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, @@ -1122,7 +1143,13 @@ impl App { }) .unwrap(); }; - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -1132,7 +1159,7 @@ impl App { }) .unwrap(); }; - let Some(pane_state) = ws.pane_state_mut(pane_id) else { + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -1143,8 +1170,8 @@ impl App { .unwrap(); }; match params.label.map(|label| label.trim().to_string()) { - Some(label) if !label.is_empty() => pane_state.set_manual_label(label), - _ => pane_state.clear_manual_label(), + Some(label) if !label.is_empty() => terminal.set_manual_label(label), + _ => terminal.clear_manual_label(), } self.state.mark_session_dirty(); let pane = self.pane_info(ws_idx, pane_id).unwrap(); @@ -1419,6 +1446,7 @@ impl App { .unwrap(); }; let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); let should_close_workspace = { let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return serde_json::to_string(&ErrorResponse { @@ -1447,6 +1475,7 @@ impl App { data: crate::api::schema::EventData::WorkspaceClosed { workspace_id }, }); } else { + self.state.remove_unattached_terminal_ids(terminal_id); self.schedule_session_save(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneClosed, diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index 56907ece..1ebb30ef 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -30,7 +30,7 @@ fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { } } -pub(super) fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> Vec<u8> { +pub(super) fn encode_api_text(runtime: &crate::terminal::TerminalRuntime, text: &str) -> Vec<u8> { let bracketed = runtime .input_state() .map(|state| state.bracketed_paste) @@ -43,7 +43,7 @@ pub(super) fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> } pub(super) fn encode_api_keys( - runtime: &crate::pane::PaneRuntime, + runtime: &crate::terminal::TerminalRuntime, keys: &[String], ) -> Result<Vec<Vec<u8>>, String> { let mut encoded_keys = Vec::with_capacity(keys.len()); diff --git a/src/app/creation.rs b/src/app/creation.rs index 6e557066..1832e637 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -78,13 +78,17 @@ impl App { }; let (rows, cols) = self.state.estimate_pane_size(); let ws = &mut self.state.workspaces[ws_idx]; - let idx = ws.create_tab( + let (idx, terminal, runtime) = ws.create_tab( rows, cols, initial_cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, )?; + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); if focus { ws.switch_tab(idx); self.state.mode = Mode::Terminal; @@ -105,7 +109,7 @@ impl App { focus: bool, ) -> std::io::Result<usize> { let (rows, cols) = self.state.estimate_pane_size(); - let ws = Workspace::new( + let (ws, terminal, runtime) = Workspace::new( initial_cwd, rows, cols, @@ -115,6 +119,10 @@ impl App { self.render_notify.clone(), self.render_dirty.clone(), )?; + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; let workspace_id = self.state.workspaces[idx].id.clone(); @@ -177,7 +185,12 @@ impl App { let (agg_state, seen) = tab .panes .values() - .map(|pane| (pane.state, pane.seen)) + .filter_map(|pane| { + self.state + .terminals + .get(&pane.attached_terminal_id) + .map(|terminal| (terminal.state, pane.seen)) + }) .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen)) .unwrap_or((crate::detect::AgentState::Unknown, true)); Some(crate::api::schema::TabInfo { @@ -230,6 +243,7 @@ impl App { ) -> Option<crate::api::schema::PaneInfo> { let ws = self.state.workspaces.get(ws_idx)?; let pane = ws.pane_state(pane_id)?; + let terminal = self.state.terminals.get(&pane.attached_terminal_id)?; let tab_idx = ws.find_tab_index_for_pane(pane_id)?; let focused = self.state.active == Some(ws_idx) && ws.active_tab == tab_idx @@ -238,18 +252,22 @@ impl App { .is_some_and(|focused| focused == pane_id); Some(crate::api::schema::PaneInfo { pane_id: self.public_pane_id(ws_idx, pane_id)?, - terminal_id: ws.terminal_id(pane_id)?.to_string(), + terminal_id: terminal.id.to_string(), workspace_id: self.public_workspace_id(ws_idx), tab_id: self.public_tab_id(ws_idx, tab_idx)?, focused, cwd: ws.tabs[tab_idx] - .cwd_for_pane(pane_id) + .cwd_for_pane( + pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) .map(|cwd| cwd.display().to_string()), - label: pane.manual_label.clone(), - agent: pane.effective_agent_label().map(str::to_string), - agent_status: pane_agent_status(pane.state, pane.seen), - custom_status: pane.effective_custom_status().map(str::to_string), - revision: 0, + label: terminal.manual_label.clone(), + agent: terminal.effective_agent_label().map(str::to_string), + agent_status: pane_agent_status(terminal.state, pane.seen), + custom_status: terminal.effective_custom_status().map(str::to_string), + revision: terminal.revision, }) } @@ -257,9 +275,8 @@ impl App { &self, ws_idx: usize, pane_id: crate::layout::PaneId, - ) -> Option<(&crate::pane::PaneRuntime, String)> { - let ws = self.state.workspaces.get(ws_idx)?; - let runtime = ws.runtime(pane_id)?; + ) -> Option<(&crate::terminal::TerminalRuntime, String)> { + let runtime = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; Some((runtime, self.public_workspace_id(ws_idx))) } @@ -267,14 +284,13 @@ impl App { &self, ws_idx: usize, pane_id: crate::layout::PaneId, - ) -> Option<&crate::pane::PaneRuntime> { - let ws = self.state.workspaces.get(ws_idx)?; - ws.runtime(pane_id) + ) -> Option<&crate::terminal::TerminalRuntime> { + self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) } pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo { let ws = &self.state.workspaces[index]; - let (agg_state, seen) = ws.aggregate_state(); + let (agg_state, seen) = ws.aggregate_state(&self.state.terminals); crate::api::schema::WorkspaceInfo { workspace_id: self.public_workspace_id(index), number: index + 1, diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 575b373d..fea88b55 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -82,8 +82,8 @@ impl App { if self.state.mode != Mode::Terminal { return; } - if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { - if let Some(rt) = ws.focused_runtime() { + if let Some(ws_idx) = self.state.active { + if let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) { let _ = rt.send_paste(text).await; } } @@ -197,11 +197,17 @@ impl AppState { let cwd = self .active .and_then(|i| self.workspaces.get(i)) - .and_then(|ws| ws.focused_runtime()) - .and_then(|rt| rt.cwd()); + .and_then(|ws| { + let tab = ws.active_tab()?; + tab.cwd_for_pane( + tab.layout.focused(), + &self.terminals, + &self.terminal_runtimes, + ) + }); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if let Ok(new_id) = ws.split_focused( + if let Ok(new_pane) = ws.split_focused( direction, new_rows, new_cols, @@ -209,6 +215,9 @@ impl AppState { self.pane_scrollback_limit_bytes, self.host_terminal_theme, ) { + let new_id = new_pane.pane_id; + self.terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); ws.layout.focus_pane(new_id); self.mark_session_dirty(); self.mode = Mode::Terminal; @@ -277,6 +286,8 @@ fn numbered_lines_bytes(count: usize) -> Vec<u8> { fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { crate::persist::capture( &state.workspaces, + &state.terminals, + &state.terminal_runtimes, state.active, state.selected, state.agent_panel_scope, diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 367e7e84..6de349cb 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -190,11 +190,14 @@ pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::Pan let Some(pane) = ws.pane_state(pane_id) else { return; }; + let terminal = state.terminals.get(&pane.attached_terminal_id); state.creating_new_tab = false; state.requested_new_tab_name = None; state.rename_pane_target = Some(pane_id); - state.name_input = pane.manual_label.clone().unwrap_or_default(); - state.name_input_replace_on_type = pane.manual_label.is_none(); + state.name_input = terminal + .and_then(|t| t.manual_label.clone()) + .unwrap_or_default(); + state.name_input_replace_on_type = terminal.and_then(|t| t.manual_label.as_ref()).is_none(); state.mode = Mode::RenamePane; } @@ -316,10 +319,13 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { Mode::RenamePane => { if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target) { - if let Some(ws) = state.workspaces.get_mut(ws_idx) { - if let Some(pane) = ws.pane_state_mut(pane_id) { - pane.set_manual_label(new_name); - state.mark_session_dirty(); + if let Some(ws) = state.workspaces.get(ws_idx) { + if let Some(pane) = ws.pane_state(pane_id) { + let terminal_id = pane.attached_terminal_id.clone(); + if let Some(terminal) = state.terminals.get_mut(&terminal_id) { + terminal.set_manual_label(new_name); + state.mark_session_dirty(); + } } } } @@ -468,10 +474,13 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS } (ContextMenuKind::Pane { pane_id, .. }, Some("Clear pane name")) => { if let Some(ws_idx) = state.active { - if let Some(ws) = state.workspaces.get_mut(ws_idx) { - if let Some(pane) = ws.pane_state_mut(pane_id) { - pane.clear_manual_label(); - state.mark_session_dirty(); + if let Some(ws) = state.workspaces.get(ws_idx) { + if let Some(pane) = ws.pane_state(pane_id) { + let terminal_id = pane.attached_terminal_id.clone(); + if let Some(terminal) = state.terminals.get_mut(&terminal_id) { + terminal.clear_manual_label(); + state.mark_session_dirty(); + } } } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index a7a2159a..10ef9c18 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -712,7 +712,8 @@ impl AppState { .active .and_then(|ws_idx| self.workspaces.get(ws_idx)) .and_then(|ws| ws.pane_state(info.id)) - .and_then(|pane| pane.manual_label.as_ref()) + .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) + .and_then(|terminal| terminal.manual_label.as_ref()) .is_some(); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { @@ -1061,16 +1062,16 @@ impl AppState { } pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.scroll_up(lines); } } } pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.scroll_down(lines); } } @@ -1081,9 +1082,8 @@ impl AppState { pane_id: crate::layout::PaneId, ) -> Option<crate::pane::ScrollMetrics> { self.active - .and_then(|i| self.workspaces.get(i)) - .and_then(|ws| ws.runtime(pane_id)) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .and_then(|i| self.runtime_for_pane_in_workspace(i, pane_id)) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) } pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { @@ -1112,8 +1112,8 @@ impl AppState { return; } - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.focused_runtime() { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.focused_runtime_in_workspace(ws_idx) { match mouse.kind { MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), @@ -1124,10 +1124,10 @@ impl AppState { } pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { - let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = ws.runtimes.get(&info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); @@ -1169,10 +1169,10 @@ impl AppState { } pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { - let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = ws.runtimes.get(&info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { return false; }; match rt.wheel_routing() { @@ -1209,8 +1209,8 @@ impl AppState { pane_id: crate::layout::PaneId, offset_from_bottom: usize, ) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.set_scroll_offset_from_bottom(offset_from_bottom); } } @@ -1221,7 +1221,7 @@ impl AppState { col: u16, row: u16, ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { - let ws = self.active.and_then(|i| self.workspaces.get(i))?; + let ws_idx = self.active?; let info = self.view.pane_infos.iter().find(|info| { crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { col >= track.x @@ -1230,7 +1230,7 @@ impl AppState { && row < track.y + track.height }) })?; - let rt = ws.runtimes.get(&info.id)?; + let rt = self.runtime_for_pane_in_workspace(ws_idx, info.id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1254,14 +1254,14 @@ impl AppState { row: u16, grab_row_offset: u16, ) -> Option<usize> { - let ws = self.active.and_then(|i| self.workspaces.get(i))?; + let ws_idx = self.active?; let info = self .view .pane_infos .iter() .find(|info| info.id == pane_id)?; let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = ws.runtimes.get(&pane_id)?; + let rt = self.runtime_for_pane_in_workspace(ws_idx, pane_id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1345,12 +1345,19 @@ mod tests { background.tabs[0].layout.focus_pane(first_pane); app.state.workspaces = vec![active, background]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - app.state.workspaces[1] + let target_terminal_id = app.state.workspaces[1] .panes - .get_mut(&target_pane) + .get(&target_pane) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&target_terminal_id) .unwrap() .state = AgentState::Working; @@ -1606,17 +1613,17 @@ mod tests { .expect("second pane info") .clone(); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", @@ -1675,17 +1682,17 @@ mod tests { .expect("second pane info") .clone(); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index f5a0bcaa..9fc11605 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -126,10 +126,10 @@ impl App { } fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { - let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) else { + let Some(ws_idx) = self.state.active else { return false; }; - let Some(rt) = ws.focused_runtime() else { + let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) else { return false; }; @@ -191,10 +191,13 @@ impl App { if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); } - if let Some(pane_cwd) = workspace - .active_tab() - .and_then(|tab| tab.cwd_for_pane(pane_id)) - { + if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { + tab.cwd_for_pane( + pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) + }) { env.push(( "HERDR_ACTIVE_PANE_CWD".to_string(), pane_cwd.display().to_string(), @@ -308,10 +311,14 @@ impl App { .focused_pane_id() .ok_or_else(|| std::io::Error::other("no focused pane"))?; let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let cwd = ws - .active_tab() - .and_then(|tab| tab.cwd_for_pane(previous_focus)); - let new_pane_id = ws.split_focused_command( + let cwd = ws.active_tab().and_then(|tab| { + tab.cwd_for_pane( + previous_focus, + &self.state.terminals, + &self.state.terminal_runtimes, + ) + }); + let new_pane = ws.split_focused_command( Direction::Horizontal, new_rows, new_cols, @@ -321,6 +328,10 @@ impl App { self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, )?; + let new_pane_id = new_pane.pane_id; + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); ws.active_tab_mut() .expect("workspace must have an active tab") .layout @@ -1072,7 +1083,7 @@ mod tests { api_rx, crate::api::EventHub::default(), ); - let workspace = Workspace::new( + let (workspace, terminal, runtime) = Workspace::new( std::env::current_dir().unwrap_or_else(|_| "/".into()), 24, 80, @@ -1084,6 +1095,10 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; + app.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 8f6c7792..e8e1e094 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -351,7 +351,7 @@ impl AppState { let ws_idx = self.collapsed_detail_workspace_idx()?; let ws = self.workspaces.get(ws_idx)?; let detail_idx = (row - detail_content_area.y) as usize; - let details = ws.pane_details(); + let details = ws.pane_details(&self.terminals); let detail = details.get(detail_idx)?; Some((ws_idx, detail.tab_idx, detail.pane_id)) } @@ -633,19 +633,26 @@ mod tests { let mut ws = Workspace::test_new("test"); ws.tabs[0].set_custom_name("main".into()); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) + let first_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[first_tab].root_pane; + app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) .unwrap() .detected_agent = Some(Agent::Pi); - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) + let second_terminal_id = app.state.workspaces[0].tabs[first_tab].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) .unwrap() .detected_agent = Some(Agent::Claude); - app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -659,9 +666,9 @@ mod tests { ); assert_eq!(app.state.mode, Mode::Terminal); let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.workspaces[0].active_tab, second_tab); + assert_eq!(snapshot.workspaces[0].active_tab, first_tab); assert_eq!( - snapshot.workspaces[0].tabs[second_tab].focused, + snapshot.workspaces[0].tabs[first_tab].focused, Some(second_pane.raw()) ); } @@ -701,23 +708,30 @@ mod tests { #[test] fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { let mut app = app_for_mouse_test(); - let mut first = Workspace::test_new("one"); + let first = Workspace::test_new("one"); let first_pane = first.tabs[0].root_pane; - first.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - let mut second = Workspace::test_new("two"); + let second = Workspace::test_new("two"); let second_pane = second.tabs[0].root_pane; - second.tabs[0] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); app.state.workspaces = vec![first, second]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Claude); app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -747,12 +761,8 @@ mod tests { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); + let mut tabs = Vec::new(); for (tab_name, agent) in [ ("logs", Agent::Claude), ("review", Agent::Codex), @@ -760,14 +770,29 @@ mod tests { ] { let tab_idx = ws.test_add_tab(Some(tab_name)); let pane_id = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&pane_id) - .unwrap() - .detected_agent = Some(agent); + tabs.push((tab_idx, pane_id, agent)); } app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + for (tab_idx, pane_id, agent) in tabs { + let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(agent); + } app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -792,31 +817,43 @@ mod tests { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_tab = ws.test_add_tab(Some("logs")); let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - + let mut extra_tabs = Vec::new(); for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { let tab_idx = ws.test_add_tab(Some(tab_name)); let pane_id = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&pane_id) - .unwrap() - .detected_agent = Some(agent); + extra_tabs.push((tab_idx, pane_id, agent)); } app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Claude); + for (tab_idx, pane_id, agent) in extra_tabs { + let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(agent); + } app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -843,19 +880,26 @@ mod tests { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); let second_tab = ws.test_add_tab(Some("logs")); let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) + app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) .unwrap() .detected_agent = Some(Agent::Claude); - app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -1128,20 +1172,27 @@ mod tests { let mut first = Workspace::test_new("a"); let first_root = first.tabs[0].root_pane; first.identity_cwd = first_repo.clone(); - first.tabs[0] - .pane_cwds - .insert(first_root, first_repo.clone()); first.refresh_git_ahead_behind(); let mut second = Workspace::test_new("b"); let second_root = second.tabs[0].root_pane; second.identity_cwd = second_repo.clone(); - second.tabs[0] - .pane_cwds - .insert(second_root, second_repo.clone()); second.refresh_git_ahead_behind(); app.state.workspaces = vec![first, second]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_root] + .attached_terminal_id + .clone(); + app.state.terminals.get_mut(&first_terminal_id).unwrap().cwd = first_repo.clone(); + let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_root] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .cwd = second_repo.clone(); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 22cf3169..3f0bff2c 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -64,7 +64,7 @@ impl App { let ws_idx = self.state.active?; let ws = self.state.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; - let rt = ws.runtimes.get(&pane_id)?; + let rt = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; // Intercept plain PageUp/PageDown presses for pane scrollback when the // focused pane doesn't handle its own scrolling (e.g., a plain shell @@ -179,9 +179,9 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -195,9 +195,10 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let start_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let start_row = info.inner_rect.y; let start_col = info.inner_rect.x + 2; @@ -213,9 +214,10 @@ mod tests { info.inner_rect.y.saturating_sub(1), )); - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let end_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after drag"); assert_eq!( end_metrics.offset_from_bottom, @@ -243,9 +245,9 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -283,9 +285,9 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -299,9 +301,10 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let start_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let top_row = info.inner_rect.y; let col = info.inner_rect.x + 2; @@ -309,9 +312,10 @@ mod tests { app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let end_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after wheel"); assert_eq!( end_metrics.offset_from_bottom, @@ -352,17 +356,17 @@ mod tests { .unwrap() .clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"", ), ); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", @@ -410,17 +414,17 @@ mod tests { .unwrap() .clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"", ), ); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", diff --git a/src/app/mod.rs b/src/app/mod.rs index d37bcb5d..b1fb9775 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -207,6 +207,8 @@ impl App { let render_dirty = Arc::new(AtomicBool::new(false)); // Try to restore previous session + let mut restored_terminals = std::collections::HashMap::new(); + let mut restored_terminal_runtimes = std::collections::HashMap::new(); let ( workspaces, active, @@ -226,7 +228,7 @@ impl App { 0.5_f32, ) } else if let Some(snap) = crate::persist::load() { - let ws = crate::persist::restore( + let (ws, terminals, terminal_runtimes) = crate::persist::restore( &snap, 24, 80, @@ -235,6 +237,8 @@ impl App { render_notify.clone(), render_dirty.clone(), ); + restored_terminals = terminals; + restored_terminal_runtimes = terminal_runtimes; if ws.is_empty() { crate::logging::session_restored(0, "empty"); ( @@ -306,6 +310,8 @@ impl App { }; let mut state = AppState { + terminals: std::collections::HashMap::new(), + terminal_runtimes: std::collections::HashMap::new(), workspaces, active, selected, @@ -402,6 +408,9 @@ impl App { session_dirty: false, }; + state.terminals = restored_terminals; + state.terminal_runtimes = restored_terminal_runtimes; + for ws in &mut state.workspaces { ws.refresh_git_branch(); } @@ -857,7 +866,9 @@ impl App { if let Some(ws_idx) = self.state.active { if let Some(ws) = self.state.workspaces.get(ws_idx) { if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = ws.runtimes.get(&focused) { + if let Some(runtime) = + self.state.runtime_for_pane_in_workspace(ws_idx, focused) + { let _ = runtime.try_send_bytes(bytes::Bytes::from( if runtime .input_state() @@ -945,7 +956,7 @@ mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; - use crate::pane::PaneRuntime; + use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use std::sync::{Mutex, OnceLock}; @@ -1369,22 +1380,44 @@ mod tests { let background_tab = workspace.test_add_tab(Some("background")); let background_pane = workspace.tabs[background_tab].root_pane; - workspace.tabs[0].panes.get_mut(&root_pane).unwrap().state = AgentState::Idle; - workspace.tabs[0].panes.get_mut(&root_pane).unwrap().seen = false; - workspace.tabs[0].panes.get_mut(&split_pane).unwrap().state = AgentState::Idle; - workspace.tabs[0].panes.get_mut(&split_pane).unwrap().seen = false; - workspace.tabs[background_tab] + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let root_terminal_id = app.state.workspaces[0].tabs[0].panes[&root_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&root_terminal_id) + .unwrap() + .state = AgentState::Idle; + app.state.workspaces[0].tabs[0] .panes - .get_mut(&background_pane) + .get_mut(&root_pane) + .unwrap() + .seen = false; + let split_terminal_id = app.state.workspaces[0].tabs[0].panes[&split_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&split_terminal_id) .unwrap() .state = AgentState::Idle; - workspace.tabs[background_tab] + app.state.workspaces[0].tabs[0] + .panes + .get_mut(&split_pane) + .unwrap() + .seen = false; + let bg_terminal_id = app.state.workspaces[0].tabs[background_tab].panes[&background_pane] + .attached_terminal_id + .clone(); + app.state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Idle; + app.state.workspaces[0].tabs[background_tab] .panes .get_mut(&background_pane) .unwrap() .seen = false; - app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -1498,6 +1531,7 @@ mod tests { fn workspace_create_response_includes_initial_tab_and_root_pane() { let mut app = test_app(); app.state.workspaces = vec![Workspace::test_new("api-root-pane")]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1524,6 +1558,7 @@ mod tests { let mut workspace = Workspace::test_new("api-tab-root-pane"); workspace.test_add_tab(None); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1542,15 +1577,9 @@ mod tests { fn workspace_creation_in_navigate_mode_uses_selected_workspace_seed_cwd() { let mut app = test_app(); let mut first = Workspace::test_new("herdr"); - let first_root = first.tabs[0].root_pane; - first.tabs[0] - .pane_cwds - .insert(first_root, std::path::PathBuf::from("/tmp/herdr")); + first.identity_cwd = std::path::PathBuf::from("/tmp/herdr"); let mut second = Workspace::test_new("pion"); - let second_root = second.tabs[0].root_pane; - second.tabs[0] - .pane_cwds - .insert(second_root, std::path::PathBuf::from("/tmp/pion")); + second.identity_cwd = std::path::PathBuf::from("/tmp/pion"); app.state.workspaces = vec![first, second]; app.state.active = Some(0); @@ -1586,6 +1615,7 @@ mod tests { let workspace = Workspace::test_new("api-pane-rename"); let pane = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1601,9 +1631,15 @@ mod tests { assert_eq!(response["result"]["type"], "pane_info"); assert_eq!(response["result"]["pane"]["label"], "reviewer"); + let terminal_id = app.state.workspaces[0] + .pane_state(pane) + .unwrap() + .attached_terminal_id + .clone(); assert_eq!( - app.state.workspaces[0] - .pane_state(pane) + app.state + .terminals + .get(&terminal_id) .unwrap() .manual_label .as_deref(), @@ -1621,8 +1657,10 @@ mod tests { assert_eq!(response["result"]["type"], "pane_info"); assert!(response["result"]["pane"].get("label").is_none()); - assert!(app.state.workspaces[0] - .pane_state(pane) + assert!(app + .state + .terminals + .get(&terminal_id) .unwrap() .manual_label .is_none()); @@ -1665,11 +1703,21 @@ mod tests { #[test] fn terminal_target_resolves_unique_manual_name() { let mut app = test_app(); - let mut workspace = Workspace::test_new("terminal-target-name"); + let workspace = Workspace::test_new("terminal-target-name"); let pane = workspace.tabs[0].root_pane; - workspace.pane_state_mut(pane).unwrap().manual_label = Some("reviewer".into()); let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let attached_terminal_id = app.state.workspaces[0] + .pane_state(pane) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&attached_terminal_id) + .unwrap() + .manual_label = Some("reviewer".into()); app.state.active = Some(0); app.state.selected = 0; @@ -1702,9 +1750,28 @@ mod tests { let mut workspace = Workspace::test_new("terminal-target-ambiguous"); let first = workspace.tabs[0].root_pane; let second = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.pane_state_mut(first).unwrap().manual_label = Some("worker".into()); - workspace.pane_state_mut(second).unwrap().manual_label = Some("worker".into()); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0] + .pane_state(first) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .manual_label = Some("worker".into()); + let second_terminal_id = app.state.workspaces[0] + .pane_state(second) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .manual_label = Some("worker".into()); app.state.active = Some(0); app.state.selected = 0; @@ -1741,6 +1808,18 @@ mod tests { workspace.test_split(ratatui::layout::Direction::Horizontal); workspace.switch_tab(0); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let split_cwd = std::env::temp_dir(); + let target_terminal_id = app.state.workspaces[0] + .pane_state(target_pane) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&target_terminal_id) + .unwrap() + .cwd = split_cwd.clone(); app.state.active = Some(0); app.state.selected = 0; @@ -1761,6 +1840,10 @@ mod tests { assert_eq!(response["result"]["type"], "pane_info"); assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); + assert_eq!( + response["result"]["pane"]["cwd"], + split_cwd.display().to_string() + ); assert_eq!(response["result"]["pane"]["focused"], false); assert_eq!(app.state.active, Some(0)); assert_eq!(app.state.workspaces[0].active_tab, 0); @@ -1782,13 +1865,9 @@ mod tests { 3 ); - let runtimes: Vec<_> = app.state.workspaces[0] - .tabs - .iter_mut() - .flat_map(|tab| tab.runtimes.drain()) - .collect(); - for (pane_id, runtime) in runtimes { - runtime.shutdown(pane_id); + let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); } match original_shell { Some(value) => std::env::set_var("SHELL", value), @@ -1807,6 +1886,7 @@ mod tests { let background_tab = workspace.test_add_tab(Some("worker")); workspace.switch_tab(0); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1832,13 +1912,9 @@ mod tests { assert_eq!(app.state.active, Some(0)); assert_eq!(app.state.workspaces[0].active_tab, background_tab); - let runtimes: Vec<_> = app.state.workspaces[0] - .tabs - .iter_mut() - .flat_map(|tab| tab.runtimes.drain()) - .collect(); - for (pane_id, runtime) in runtimes { - runtime.shutdown(pane_id); + let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); } match original_shell { Some(value) => std::env::set_var("SHELL", value), @@ -1853,6 +1929,7 @@ mod tests { let second_tab = workspace.test_add_tab(Some("logs")); workspace.switch_tab(second_tab); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1878,6 +1955,7 @@ mod tests { let mut app = test_app(); let workspace = Workspace::test_new("api-pane-close-last"); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1968,17 +2046,23 @@ mod tests { let pane_id = ws.tabs[0].root_pane; app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; + let terminal_id = app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); app.handle_internal_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, }); assert_eq!( - app.state.workspaces[0].pane_state(pane_id).unwrap().state, + app.state.terminals.get(&terminal_id).unwrap().state, AgentState::Working ); @@ -2016,7 +2100,7 @@ mod tests { app.drain_internal_events(); assert_eq!( - app.state.workspaces[0].pane_state(pane_id).unwrap().state, + app.state.terminals.get(&terminal_id).unwrap().state, AgentState::Idle, "Working→Idle should still apply after temporary queue pressure" ); @@ -2116,7 +2200,7 @@ mod tests { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2138,7 +2222,7 @@ mod tests { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2157,7 +2241,7 @@ mod tests { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2178,7 +2262,7 @@ mod tests { let focused = workspace.focused_pane_id().unwrap(); let text = "中日한🙂"; let (runtime, mut rx) = - PaneRuntime::test_with_channel_capacity(80, 24, text.chars().count()); + TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count()); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2203,7 +2287,7 @@ mod tests { let focused = workspace.focused_pane_id().unwrap(); let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64); let char_count = text.chars().count(); - let (runtime, mut rx) = PaneRuntime::test_with_channel_capacity(80, 24, char_count); + let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 4d2f6c47..98ae4e0e 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -198,12 +198,12 @@ impl App { .state .active .and_then(|idx| self.state.workspaces.get(idx)) - .is_some_and(Workspace::has_working_pane), + .is_some_and(|ws| ws.has_working_pane(&self.state.terminals)), crate::app::state::AgentPanelScope::AllWorkspaces => self .state .workspaces .iter() - .any(Workspace::has_working_pane), + .any(|ws| ws.has_working_pane(&self.state.terminals)), } } diff --git a/src/app/session.rs b/src/app/session.rs index c075fd4c..85bdd1bb 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -27,6 +27,8 @@ impl App { } else { let snap = crate::persist::capture( &self.state.workspaces, + &self.state.terminals, + &self.state.terminal_runtimes, self.state.active, self.state.selected, self.state.agent_panel_scope, diff --git a/src/app/state.rs b/src/app/state.rs index 45473435..daed47f0 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -818,6 +818,10 @@ pub enum SidebarWidthSource { /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. pub struct AppState { + pub terminals: + std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>, + pub terminal_runtimes: + std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalRuntime>, pub workspaces: Vec<Workspace>, pub active: Option<usize>, pub selected: usize, @@ -953,6 +957,57 @@ impl AppState { /// Returns true when the given (workspace, tab, pane) refers to the /// currently focused pane in the active workspace's active tab. + pub(crate) fn runtime_for_pane_in_workspace( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalRuntime> { + #[cfg(test)] + if let Some(runtime) = self.workspaces.get(ws_idx)?.test_runtimes.get(&pane_id) { + return Some(runtime); + } + #[cfg(test)] + if let Some(runtime) = self + .workspaces + .get(ws_idx)? + .tabs + .iter() + .find_map(|tab| tab.runtimes.get(&pane_id)) + { + return Some(runtime); + } + let terminal_id = self.workspaces.get(ws_idx)?.terminal_id(pane_id)?; + self.terminal_runtimes.get(terminal_id) + } + + #[cfg(test)] + pub(crate) fn runtime_for_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalRuntime> { + self.workspaces.iter().find_map(|ws| { + #[cfg(test)] + if let Some(runtime) = ws.test_runtimes.get(&pane_id) { + return Some(runtime); + } + #[cfg(test)] + if let Some(runtime) = ws.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) { + return Some(runtime); + } + let terminal_id = ws.terminal_id(pane_id)?; + self.terminal_runtimes.get(terminal_id) + }) + } + + pub(crate) fn focused_runtime_in_workspace( + &self, + ws_idx: usize, + ) -> Option<&crate::terminal::TerminalRuntime> { + let ws = self.workspaces.get(ws_idx)?; + let pane_id = ws.focused_pane_id()?; + self.runtime_for_pane_in_workspace(ws_idx, pane_id) + } + pub fn is_active_pane( &self, ws_idx: usize, @@ -1003,6 +1058,8 @@ impl AppState { /// Create an AppState for testing — no channels, no PTYs. pub fn test_new() -> Self { Self { + terminals: std::collections::HashMap::new(), + terminal_runtimes: std::collections::HashMap::new(), workspaces: Vec::new(), active: None, selected: 0, @@ -1155,6 +1212,40 @@ impl AppState { session_dirty: false, } } + + /// Populate missing `TerminalState` entries for every pane so tests that + /// read or write terminal metadata don't need to manually create them. + pub fn ensure_test_terminals(&mut self) { + use crate::terminal::TerminalState; + for ws in &self.workspaces { + for tab in &ws.tabs { + for pane in tab.panes.values() { + if !self.terminals.contains_key(&pane.attached_terminal_id) { + let cwd = ws.identity_cwd.clone(); + self.terminals.insert( + pane.attached_terminal_id.clone(), + TerminalState::new(pane.attached_terminal_id.clone(), cwd), + ); + } + } + } + } + } + + pub fn insert_test_runtime( + &mut self, + pane_id: crate::layout::PaneId, + runtime: crate::terminal::TerminalRuntime, + ) { + let Some(terminal_id) = self + .workspaces + .iter() + .find_map(|ws| ws.terminal_id(pane_id).cloned()) + else { + return; + }; + self.terminal_runtimes.insert(terminal_id, runtime); + } } #[cfg(test)] diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index cf90717d..94cf89d0 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -61,9 +61,11 @@ impl App { .terminal_targets() .into_iter() .filter(|candidate| { - self.state.workspaces[candidate.ws_idx] - .pane_state(candidate.pane_id) - .and_then(|pane| pane.manual_label.as_deref()) + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == candidate.terminal_id) + .and_then(|terminal| terminal.manual_label.as_deref()) .is_some_and(|label| label == target) }) .collect(); @@ -151,15 +153,20 @@ impl App { let ws = self.state.workspaces.get(ws_idx)?; let tab_idx = ws.find_tab_index_for_pane(pane_id)?; let pane = ws.pane_state(pane_id)?; + let terminal = self.state.terminals.get(&pane.attached_terminal_id)?; Some(TerminalTargetCandidate { - terminal_id: ws.terminal_id(pane_id)?.to_string(), + terminal_id: terminal.id.to_string(), pane_id: self.public_pane_id(ws_idx, pane_id)?, workspace_id: self.public_workspace_id(ws_idx), tab_id: self.public_tab_id(ws_idx, tab_idx)?, cwd: ws.tabs[tab_idx] - .cwd_for_pane(pane_id) + .cwd_for_pane( + pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) .map(|cwd| cwd.display().to_string()), - agent_status: pane_agent_status(pane.state, pane.seen), + agent_status: pane_agent_status(terminal.state, pane.seen), }) } } diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index df47f6a3..802725f0 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -37,12 +37,8 @@ impl App { return; } - for workspace in &self.state.workspaces { - for tab in &workspace.tabs { - for runtime in tab.runtimes.values() { - runtime.apply_host_terminal_theme(self.state.host_terminal_theme); - } - } + for runtime in self.state.terminal_runtimes.values() { + runtime.apply_host_terminal_theme(self.state.host_terminal_theme); } self.render_dirty.store(true, Ordering::Release); |
