diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-03 01:48:15 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-03 01:48:15 +0300 |
| commit | 94e6a2ff646715cb214c6f9bac8d146c5486dd35 (patch) | |
| tree | 34a0757b1c0214f09f89d24f519b8024449d19b0 /src/app | |
| parent | 94809b2022f78216309036650e3d73841c2ee022 (diff) | |
fix workspace identity and tab-aware notifications
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 57 | ||||
| -rw-r--r-- | src/app/input.rs | 22 | ||||
| -rw-r--r-- | src/app/mod.rs | 85 |
3 files changed, 135 insertions, 29 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 7d9e27fd..dc52b174 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -11,7 +11,7 @@ use crate::pane::EffectiveStateChange; use super::state::{AppState, Mode, ToastKind, ToastNotification}; fn notification_sound_for_state_change( - is_active_ws: bool, + is_active_tab: bool, prev_state: AgentState, new_state: AgentState, ) -> Option<crate::sound::Sound> { @@ -21,7 +21,7 @@ fn notification_sound_for_state_change( match new_state { AgentState::Blocked => Some(crate::sound::Sound::Request), - AgentState::Idle if prev_state != AgentState::Idle && !is_active_ws => { + AgentState::Idle if prev_state != AgentState::Idle && !is_active_tab => { Some(crate::sound::Sound::Done) } _ => None, @@ -29,11 +29,11 @@ fn notification_sound_for_state_change( } fn notification_toast_for_state_change( - is_active_ws: bool, + is_active_tab: bool, prev_state: AgentState, new_state: AgentState, ) -> Option<ToastKind> { - if is_active_ws || new_state == prev_state { + if is_active_tab || new_state == prev_state { return None; } @@ -60,7 +60,11 @@ fn agent_label(agent: Agent) -> &'static str { } } -fn notification_context(ws: &crate::workspace::Workspace, ws_idx: usize, pane_id: PaneId) -> String { +fn notification_context( + ws: &crate::workspace::Workspace, + ws_idx: usize, + pane_id: PaneId, +) -> String { let mut context = format!("{} · {}", ws.display_name(), ws_idx + 1); if ws.tabs.len() > 1 { if let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) { @@ -86,6 +90,18 @@ pub struct PaneStateUpdate { // --------------------------------------------------------------------------- impl AppState { + fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool { + let Some(active_ws_idx) = self.active else { + return false; + }; + if active_ws_idx != ws_idx { + return false; + } + self.workspaces[ws_idx] + .find_tab_index_for_pane(pane_id) + .is_some_and(|tab_idx| tab_idx == self.workspaces[ws_idx].active_tab) + } + pub fn switch_workspace(&mut self, idx: usize) { if idx < self.workspaces.len() { self.active = Some(idx); @@ -380,7 +396,7 @@ impl AppState { pane_id: PaneId, change: EffectiveStateChange, ) { - let is_active_ws = self.active == Some(ws_idx); + let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let Some(pane) = self.workspaces[ws_idx] .tabs .iter_mut() @@ -391,14 +407,14 @@ impl AppState { if change.state == AgentState::Idle && change.previous_state != AgentState::Idle - && !is_active_ws + && !is_active_tab { pane.seen = false; } if self.sound.allows(change.agent) { if let Some(sound) = notification_sound_for_state_change( - is_active_ws, + is_active_tab, change.previous_state, change.state, ) { @@ -410,7 +426,7 @@ impl AppState { if let (Some(agent), Some(kind)) = ( change.agent, notification_toast_for_state_change( - is_active_ws, + is_active_tab, change.previous_state, change.state, ), @@ -725,7 +741,28 @@ mod tests { } #[test] - fn active_workspace_does_not_set_toast() { + fn background_tab_in_active_workspace_still_sets_toast() { + let mut state = app_with_workspaces(&["active"]); + state.active = Some(0); + state.toast_config.enabled = true; + state.workspaces[0].tabs[0].set_custom_name("main".into()); + let second_tab = state.workspaces[0].test_add_tab(Some("logs")); + let bg_pane_id = state.workspaces[0].tabs[second_tab].root_pane; + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Blocked, + }); + + let toast = state.toast.as_ref().unwrap(); + assert_eq!(toast.kind, ToastKind::NeedsAttention); + assert_eq!(toast.title, "pi needs attention"); + assert_eq!(toast.context, "active · 1 · logs"); + } + + #[test] + fn active_workspace_active_tab_does_not_set_toast() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); state.toast_config.enabled = true; diff --git a/src/app/input.rs b/src/app/input.rs index 3b63f28f..7341059c 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -383,7 +383,9 @@ impl App { let protocol = crate::input::KeyboardProtocol::from_kitty_flags(flags); let bytes = crate::input::encode_terminal_key(key, protocol); if matches!(key_event.code, KeyCode::Esc) - || key_event.modifiers.contains(crossterm::event::KeyModifiers::ALT) + || key_event + .modifiers + .contains(crossterm::event::KeyModifiers::ALT) { debug!( code = ?key_event.code, @@ -1863,7 +1865,8 @@ impl AppState { return None; } - if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) { + if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) + { self.switch_workspace(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); @@ -2103,10 +2106,7 @@ impl AppState { None } - fn agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { + fn agent_detail_target_at(&self, row: u16) -> Option<(usize, usize, crate::layout::PaneId)> { if self.sidebar_collapsed { return None; } @@ -2129,7 +2129,10 @@ impl AppState { content.width, total_h.saturating_sub(ws_h) as u16, ); - if detail_area.height < 4 || row < detail_area.y + 3 || row >= detail_area.y + detail_area.height { + if detail_area.height < 4 + || row < detail_area.y + 3 + || row >= detail_area.y + detail_area.height + { return None; } @@ -2993,7 +2996,10 @@ mod tests { app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 14)); assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!(app.state.workspaces[0].tabs[1].layout.focused(), second_pane); + assert_eq!( + app.state.workspaces[0].tabs[1].layout.focused(), + second_pane + ); assert_eq!(app.state.mode, Mode::Terminal); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 311e5f82..202fa8c3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -710,7 +710,9 @@ impl App { workspace_id: self.public_workspace_id(ws_idx), }, }); - if let Some(tab_id) = self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) { + if let Some(tab_id) = + self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) + { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::TabFocused, data: crate::api::schema::EventData::TabFocused { @@ -1402,7 +1404,12 @@ impl App { }) .unwrap(); }; - let Some(tab_idx) = self.state.workspaces.get(ws_idx).and_then(|ws| ws.find_tab_index_for_pane(pane_id)) else { + let Some(tab_idx) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.find_tab_index_for_pane(pane_id)) + else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -1776,14 +1783,30 @@ impl App { }); } + fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<std::path::PathBuf> { + self.state.workspaces.get(ws_idx)?.resolved_identity_cwd() + } + + fn workspace_creation_source(&self) -> Option<usize> { + if self.state.mode == Mode::Navigate + && self.state.workspaces.get(self.state.selected).is_some() + { + return Some(self.state.selected); + } + + self.state.active.or_else(|| { + self.state + .workspaces + .get(self.state.selected) + .map(|_| self.state.selected) + }) + } + /// Create a workspace with a real PTY (needs event_tx). fn create_workspace(&mut self) { let initial_cwd = self - .state - .active - .and_then(|i| self.state.workspaces.get(i)) - .and_then(|ws| ws.focused_runtime()) - .and_then(|rt| rt.cwd()) + .workspace_creation_source() + .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from("/")); if let Err(e) = self.create_workspace_with_options(initial_cwd, true) { @@ -1796,9 +1819,7 @@ impl App { let initial_cwd = self .state .active - .and_then(|i| self.state.workspaces.get(i)) - .and_then(|ws| ws.focused_runtime()) - .and_then(|rt| rt.cwd()) + .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from("/")); if let Err(e) = self.create_tab_with_options(initial_cwd, true) { @@ -1968,7 +1989,9 @@ impl App { focused: self.state.active == Some(index), pane_count: ws.public_pane_numbers.len(), tab_count: ws.tabs.len(), - active_tab_id: self.public_tab_id(index, ws.active_tab).unwrap_or_else(|| format!("{}:{}", index + 1, ws.active_tab + 1)), + active_tab_id: self + .public_tab_id(index, ws.active_tab) + .unwrap_or_else(|| format!("{}:{}", index + 1, ws.active_tab + 1)), agent_state: pane_agent_state(agg_state), } } @@ -2061,6 +2084,20 @@ fn agent_name(agent: crate::detect::Agent) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::Config; + use crate::workspace::Workspace; + + fn test_app() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &Config::default(), + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ) + } #[tokio::test] async fn raw_input_waits_when_reader_is_gone() { @@ -2089,4 +2126,30 @@ mod tests { assert!(!api_request_changes_ui(&read_only)); assert!(api_request_changes_ui(&mutating)); } + + #[test] + 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")); + 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")); + + app.state.workspaces = vec![first, second]; + app.state.active = Some(0); + app.state.selected = 1; + app.state.mode = Mode::Navigate; + + let ws_idx = app.workspace_creation_source().unwrap(); + let seed_cwd = app.seed_cwd_from_workspace(ws_idx).unwrap(); + + assert_eq!(ws_idx, 1); + assert_eq!(seed_cwd, std::path::PathBuf::from("/tmp/pion")); + } } |
