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/workspace | |
| parent | e4de58b37f58d59d146158239ed218a8accd5c3c (diff) | |
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src/workspace')
| -rw-r--r-- | src/workspace/aggregate.rs | 164 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 222 |
2 files changed, 208 insertions, 178 deletions
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index 0507781e..3ac7ea9b 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -1,5 +1,8 @@ +use std::collections::HashMap; + use crate::detect::{Agent, AgentState}; use crate::layout::PaneId; +use crate::terminal::{TerminalId, TerminalState}; use super::{Tab, Workspace}; @@ -18,29 +21,32 @@ pub struct PaneDetail { } impl Tab { - pub fn has_working_pane(&self) -> bool { - self.panes - .values() - .any(|pane| pane.state == AgentState::Working) + pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> bool { + self.panes.values().any(|pane| { + terminals + .get(&pane.attached_terminal_id) + .is_some_and(|terminal| terminal.state == AgentState::Working) + }) } - pub fn pane_details(&self) -> Vec<PaneDetail> { + pub fn pane_details(&self, terminals: &HashMap<TerminalId, TerminalState>) -> Vec<PaneDetail> { self.layout .pane_ids() .iter() .filter_map(|id| { let pane = self.panes.get(id)?; - let agent_label = pane.effective_agent_label()?.to_string(); + let terminal = terminals.get(&pane.attached_terminal_id)?; + let agent_label = terminal.effective_agent_label()?.to_string(); Some(PaneDetail { pane_id: *id, tab_idx: self.number.saturating_sub(1), tab_label: self.display_name(), label: agent_label.clone(), agent_label, - agent: pane.effective_known_agent(), - state: pane.state, + agent: terminal.effective_known_agent(), + state: terminal.state, seen: pane.seen, - custom_status: pane.effective_custom_status().map(str::to_string), + custom_status: terminal.effective_custom_status().map(str::to_string), }) }) .collect() @@ -58,24 +64,31 @@ fn pane_attention_priority(state: AgentState, seen: bool) -> u8 { } impl Workspace { - pub fn aggregate_state(&self) -> (AgentState, bool) { + pub fn aggregate_state( + &self, + terminals: &HashMap<TerminalId, TerminalState>, + ) -> (AgentState, bool) { self.tabs .iter() .flat_map(|tab| tab.panes.values()) - .map(|pane| (pane.state, pane.seen)) + .filter_map(|pane| { + terminals + .get(&pane.attached_terminal_id) + .map(|terminal| (terminal.state, pane.seen)) + }) .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen)) .unwrap_or((AgentState::Unknown, true)) } - pub fn has_working_pane(&self) -> bool { - self.tabs.iter().any(Tab::has_working_pane) + pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> bool { + self.tabs.iter().any(|tab| tab.has_working_pane(terminals)) } - pub fn pane_details(&self) -> Vec<PaneDetail> { + pub fn pane_details(&self, terminals: &HashMap<TerminalId, TerminalState>) -> Vec<PaneDetail> { let multi_tab = self.tabs.len() > 1; self.tabs .iter() - .flat_map(Tab::pane_details) + .flat_map(|tab| tab.pane_details(terminals)) .map(|mut detail| { if multi_tab { detail.label = format!("{}·{}", detail.tab_label, detail.agent_label); @@ -93,10 +106,18 @@ mod tests { use super::*; use crate::detect::Agent; + fn terminal_for_pane(ws: &Workspace, pane_id: PaneId) -> TerminalState { + TerminalState::new(ws.terminal_id(pane_id).unwrap().clone(), "/tmp".into()) + } + #[test] fn aggregate_state_all_unknown() { let ws = Workspace::test_new("test"); - let (state, seen) = ws.aggregate_state(); + let mut terminals = HashMap::new(); + let root = ws.tabs[0].root_pane; + let terminal = terminal_for_pane(&ws, root); + terminals.insert(terminal.id.clone(), terminal); + let (state, seen) = ws.aggregate_state(&terminals); assert_eq!(state, AgentState::Unknown); assert!(seen); } @@ -111,10 +132,16 @@ mod tests { .find(|id| **id != id2) .copied() .unwrap(); - ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle; - ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; + let mut terminals = HashMap::new(); + let mut root_terminal = terminal_for_pane(&ws, root_id); + root_terminal.state = AgentState::Idle; + terminals.insert(root_terminal.id.clone(), root_terminal); + let mut second_terminal = terminal_for_pane(&ws, id2); + second_terminal.state = AgentState::Working; + terminals.insert(second_terminal.id.clone(), second_terminal); + + let (state, seen) = ws.aggregate_state(&terminals); - let (state, seen) = ws.aggregate_state(); assert_eq!(state, AgentState::Working); assert!(seen); } @@ -129,68 +156,61 @@ mod tests { .find(|id| **id != id2) .copied() .unwrap(); + let mut terminals = HashMap::new(); + let mut root_terminal = terminal_for_pane(&ws, root_id); + root_terminal.state = AgentState::Idle; + terminals.insert(root_terminal.id.clone(), root_terminal); + let mut second_terminal = terminal_for_pane(&ws, id2); + second_terminal.state = AgentState::Working; + terminals.insert(second_terminal.id.clone(), second_terminal); let root = ws.tabs[0].panes.get_mut(&root_id).unwrap(); - root.state = AgentState::Idle; root.seen = false; - ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; - let (state, seen) = ws.aggregate_state(); + let (state, seen) = ws.aggregate_state(&terminals); + assert_eq!(state, AgentState::Idle); assert!(!seen); } #[test] - fn pane_details_hide_plain_shells() { - let mut ws = Workspace::test_new("test"); - let root_pane = ws.tabs[0].root_pane; - ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi); - ws.test_split(Direction::Horizontal); - - let details = ws.pane_details(); - assert_eq!(details.len(), 1); - assert_eq!(details[0].label, "pi"); - } - - #[test] - fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("main".into()); - let root_pane = ws.tabs[0].root_pane; - ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi); - - let tab_idx = ws.test_add_tab(Some("logs")); - let second_root_pane = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&second_root_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - - let details = ws.pane_details(); - assert_eq!(details.len(), 2); - assert!(details.iter().any(|detail| detail.label == "main·pi")); - assert!(details.iter().any(|detail| detail.label == "logs·claude")); - } - - #[test] - fn pane_details_include_hook_reported_unknown_agents() { + fn pane_details_includes_tab_context_for_multi_tab_workspace() { let mut ws = Workspace::test_new("test"); + ws.tabs[0].custom_name = Some("main".into()); let root_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&root_pane) - .unwrap() - .set_hook_authority( - "custom:hermes".into(), - "hermes".into(), - AgentState::Working, - None, - None, - ); - - let details = ws.pane_details(); - assert_eq!(details.len(), 1); - assert_eq!(details[0].agent_label, "hermes"); - assert_eq!(details[0].agent, None); + let second_tab = ws.test_add_tab(Some("review")); + let review_pane = ws.tabs[second_tab].root_pane; + let mut terminals = HashMap::new(); + let mut root_terminal = terminal_for_pane(&ws, root_pane); + root_terminal.set_hook_authority( + "test".into(), + "pi".into(), + AgentState::Working, + None, + None, + ); + terminals.insert(root_terminal.id.clone(), root_terminal); + let mut review_terminal = terminal_for_pane(&ws, review_pane); + review_terminal.set_hook_authority( + "test".into(), + "claude".into(), + AgentState::Idle, + None, + None, + ); + terminals.insert(review_terminal.id.clone(), review_terminal); + + let labels: Vec<_> = ws + .pane_details(&terminals) + .into_iter() + .map(|detail| (detail.label, detail.agent_label, detail.agent)) + .collect(); + + assert_eq!( + labels, + vec![ + ("main·pi".into(), "pi".into(), Some(Agent::Pi)), + ("review·claude".into(), "claude".into(), Some(Agent::Claude)), + ] + ); } } diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index d809f944..943a45a0 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -8,8 +8,16 @@ use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{PaneId, TileLayout}; -use crate::pane::{PaneRuntime, PaneState}; -use crate::terminal::TerminalId; +use crate::pane::PaneState; +use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; + +pub(crate) type DetachedPane = (PaneId, TerminalId); + +pub struct NewPane { + pub pane_id: PaneId, + pub terminal: TerminalState, + pub runtime: TerminalRuntime, +} enum SplitCommand<'a> { Shell { @@ -27,29 +35,16 @@ pub struct Tab { /// Identity source for this tab's pane tree. pub root_pane: PaneId, pub layout: TileLayout, - /// Pane state — always present, testable without PTYs. + /// Pane viewport state — always present, testable without PTYs. pub panes: HashMap<PaneId, PaneState>, - /// Best-effort cwd cache per pane. Live runtime cwd wins when available. - pub pane_cwds: HashMap<PaneId, PathBuf>, - /// One-to-one terminal identity for each pane-backed PTY during the transition. - pub terminal_ids: HashMap<PaneId, TerminalId>, - /// Pane runtimes — only present in production (empty in tests). - pub runtimes: HashMap<PaneId, PaneRuntime>, + #[cfg(test)] + pub runtimes: HashMap<PaneId, TerminalRuntime>, pub zoomed: bool, pub events: mpsc::Sender<AppEvent>, pub(crate) render_notify: Arc<Notify>, pub(crate) render_dirty: Arc<AtomicBool>, } -impl Drop for Tab { - fn drop(&mut self) { - let runtimes = std::mem::take(&mut self.runtimes); - for (pane_id, runtime) in runtimes { - runtime.shutdown(pane_id); - } - } -} - impl Tab { pub fn new( number: usize, @@ -61,7 +56,7 @@ impl Tab { events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, initial_cwd, @@ -87,7 +82,7 @@ impl Tab { events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, initial_cwd, @@ -114,10 +109,10 @@ impl Tab { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, argv: Option<&[String]>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let (layout, root_id) = TileLayout::new(); let runtime = if let Some(argv) = argv { - PaneRuntime::spawn_argv_command( + TerminalRuntime::spawn_argv_command( root_id, rows, cols, @@ -130,7 +125,7 @@ impl Tab { render_dirty.clone(), )? } else { - PaneRuntime::spawn( + TerminalRuntime::spawn( root_id, rows, cols, @@ -143,29 +138,33 @@ impl Tab { )? }; + let terminal_id = TerminalId::alloc(); + let terminal = match argv { + Some(argv) => { + TerminalState::new(terminal_id.clone(), initial_cwd).with_launch_argv(argv.to_vec()) + } + None => TerminalState::new(terminal_id.clone(), initial_cwd), + }; let mut panes = HashMap::new(); - panes.insert(root_id, PaneState::new()); - let mut pane_cwds = HashMap::new(); - pane_cwds.insert(root_id, initial_cwd); - let mut terminal_ids = HashMap::new(); - terminal_ids.insert(root_id, TerminalId::alloc()); - let mut runtimes = HashMap::new(); - runtimes.insert(root_id, runtime); + panes.insert(root_id, PaneState::new(terminal_id)); - Ok(Self { - custom_name: None, - number, - root_pane: root_id, - layout, - panes, - pane_cwds, - terminal_ids, - runtimes, - zoomed: false, - events, - render_notify, - render_dirty, - }) + Ok(( + Self { + custom_name: None, + number, + root_pane: root_id, + layout, + panes, + #[cfg(test)] + runtimes: HashMap::new(), + zoomed: false, + events, + render_notify, + render_dirty, + }, + terminal, + runtime, + )) } pub fn display_name(&self) -> String { @@ -190,7 +189,7 @@ impl Tab { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, rows, @@ -212,7 +211,7 @@ impl Tab { extra_env: &[(String, String)], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, rows, @@ -233,7 +232,7 @@ impl Tab { argv: &[String], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, rows, @@ -254,25 +253,33 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, command: Option<SplitCommand<'_>>, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { + let previous_focus = self.layout.focused(); let new_id = self.layout.split_focused(direction); let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + let launch_argv = if let Some(SplitCommand::Argv { argv }) = &command { + Some((*argv).to_vec()) + } else { + None + }; let runtime = match command { - Some(SplitCommand::Shell { command, extra_env }) => PaneRuntime::spawn_shell_command( - new_id, - rows, - cols, - actual_cwd.clone(), - command, - extra_env, - scrollback_limit_bytes, - host_terminal_theme, - self.events.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - )?, - Some(SplitCommand::Argv { argv }) => PaneRuntime::spawn_argv_command( + Some(SplitCommand::Shell { command, extra_env }) => { + TerminalRuntime::spawn_shell_command( + new_id, + rows, + cols, + actual_cwd.clone(), + command, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + self.events.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + ) + } + Some(SplitCommand::Argv { argv }) => TerminalRuntime::spawn_argv_command( new_id, rows, cols, @@ -283,8 +290,8 @@ impl Tab { self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), - )?, - None => PaneRuntime::spawn( + ), + None => TerminalRuntime::spawn( new_id, rows, cols, @@ -294,30 +301,46 @@ impl Tab { self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), - )?, + ), + }; + let runtime = match runtime { + Ok(runtime) => runtime, + Err(err) => { + self.layout.close_focused(); + self.layout.focus_pane(previous_focus); + return Err(err); + } }; - self.panes.insert(new_id, PaneState::new()); - self.pane_cwds.insert(new_id, actual_cwd); - self.terminal_ids.insert(new_id, TerminalId::alloc()); - self.runtimes.insert(new_id, runtime); + let terminal_id = TerminalId::alloc(); + let terminal = match launch_argv { + Some(argv) => { + TerminalState::new(terminal_id.clone(), actual_cwd).with_launch_argv(argv) + } + None => TerminalState::new(terminal_id.clone(), actual_cwd), + }; + self.panes.insert(new_id, PaneState::new(terminal_id)); self.zoomed = false; - Ok(new_id) + Ok(NewPane { + pane_id: new_id, + terminal, + runtime, + }) } - pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> { + pub fn close_focused(&mut self) -> Option<DetachedPane> { let pane_id = self.layout.focused(); self.detach_pane(pane_id) } - pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + pub fn close_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> { self.detach_pane(pane_id) } - pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> { self.detach_pane(pane_id) } - fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + fn detach_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> { if self.layout.pane_count() <= 1 { return None; } @@ -333,15 +356,13 @@ impl Tab { self.layout.focus_pane(prev_focus); } - self.panes.remove(&pane_id); - self.pane_cwds.remove(&pane_id); - self.terminal_ids.remove(&pane_id); - let runtime = self.runtimes.remove(&pane_id); + let pane = self.panes.remove(&pane_id)?; + let terminal_id = pane.attached_terminal_id; self.zoomed = false; if let Some(next_root) = next_root { self.root_pane = next_root; } - Some((pane_id, runtime)) + Some((pane_id, terminal_id)) } fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> { @@ -351,37 +372,26 @@ impl Tab { self.layout.pane_ids().into_iter().find(|id| *id != closing) } - pub fn focused_runtime(&self) -> Option<&PaneRuntime> { - self.runtimes.get(&self.layout.focused()) - } - pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { - self.terminal_ids.get(&pane_id) - } - - pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> { - self.runtimes + self.panes .get(&pane_id) - .and_then(|rt| rt.cwd()) - .or_else(|| self.pane_cwds.get(&pane_id).cloned()) + .map(|pane| &pane.attached_terminal_id) } - pub fn root_cwd(&self) -> Option<PathBuf> { - self.cwd_for_pane(self.root_pane) - } - - #[cfg(test)] - #[allow(dead_code)] // retained for focused layout-order assertions in tests - pub fn pane_states(&self) -> Vec<(crate::detect::AgentState, bool)> { - self.layout - .pane_ids() - .iter() - .map(|id| { - self.panes - .get(id) - .map(|p| (p.state, p.seen)) - .unwrap_or((crate::detect::AgentState::Unknown, true)) + pub fn cwd_for_pane( + &self, + pane_id: PaneId, + terminals: &HashMap<TerminalId, TerminalState>, + terminal_runtimes: &HashMap<TerminalId, TerminalRuntime>, + ) -> Option<PathBuf> { + let terminal_id = self.terminal_id(pane_id)?; + terminal_runtimes + .get(terminal_id) + .and_then(|rt| rt.cwd()) + .or_else(|| { + terminals + .get(terminal_id) + .map(|terminal| terminal.cwd.clone()) }) - .collect() } } |
