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 | |
| parent | 94809b2022f78216309036650e3d73841c2ee022 (diff) | |
fix workspace identity and tab-aware notifications
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 57 | ||||
| -rw-r--r-- | src/app/input.rs | 22 | ||||
| -rw-r--r-- | src/app/mod.rs | 85 | ||||
| -rw-r--r-- | src/cli.rs | 4 | ||||
| -rw-r--r-- | src/persist.rs | 13 | ||||
| -rw-r--r-- | src/ui.rs | 3 | ||||
| -rw-r--r-- | src/workspace.rs | 70 |
7 files changed, 206 insertions, 48 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")); + } } @@ -8,8 +8,8 @@ use crate::api; use crate::api::schema::{ EmptyParams, Method, OutputMatch, PaneAgentState, PaneListParams, PaneReadParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, - ReadSource, Request, SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, - TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, + ReadSource, Request, SplitDirection, Subscription, TabCreateParams, TabListParams, + TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, }; pub enum CommandOutcome { diff --git a/src/persist.rs b/src/persist.rs index 3585228e..ba88df8e 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -97,7 +97,9 @@ pub fn capture( fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot { WorkspaceSnapshot { custom_name: ws.custom_name.clone(), - identity_cwd: ws.identity_cwd.clone(), + identity_cwd: ws + .resolved_identity_cwd() + .unwrap_or_else(|| ws.identity_cwd.clone()), tabs: ws.tabs.iter().map(capture_tab).collect(), active_tab: ws.active_tab, } @@ -107,9 +109,7 @@ fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot { let mut panes = HashMap::new(); for id in tab.panes.keys() { let cwd = tab - .runtimes - .get(id) - .and_then(|rt| rt.cwd()) + .cwd_for_pane(*id) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); panes.insert(id.raw(), PaneSnapshot { cwd }); } @@ -233,6 +233,7 @@ fn restore_tab( let layout = TileLayout::from_saved(node, focus); let mut panes = HashMap::new(); + let mut pane_cwds = HashMap::new(); let mut runtimes = HashMap::new(); for id in &pane_ids { let old_id = id_map @@ -248,13 +249,14 @@ fn restore_tab( *id, rows, cols, - cwd, + cwd.clone(), events.clone(), render_notify.clone(), render_dirty.clone(), ) { Ok(runtime) => { panes.insert(*id, PaneState::new()); + pane_cwds.insert(*id, cwd.clone()); runtimes.insert(*id, runtime); } Err(e) => { @@ -276,6 +278,7 @@ fn restore_tab( root_pane, layout, panes, + pane_cwds, runtimes, zoomed: snap.zoomed, events, @@ -316,7 +316,8 @@ fn compute_sidebar_width(app: &AppState) -> u16 { .map(|detail| 3 + detail.label.len() + 1 + state_label(detail.state, detail.seen).len()) .max() .unwrap_or(0); - ((max_workspace_line.max(max_agent_line) as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) + ((max_workspace_line.max(max_agent_line) as u16) + 2) + .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) } /// Collapsed sidebar: pure glance mode. diff --git a/src/workspace.rs b/src/workspace.rs index 81e1f2f2..24c17521 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -21,6 +21,8 @@ pub struct Tab { pub layout: TileLayout, /// Pane 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>, /// Pane runtimes — only present in production (empty in tests). pub runtimes: HashMap<PaneId, PaneRuntime>, pub zoomed: bool, @@ -53,7 +55,7 @@ impl Tab { root_id, rows, cols, - initial_cwd, + initial_cwd.clone(), events.clone(), render_notify.clone(), render_dirty.clone(), @@ -61,6 +63,8 @@ impl Tab { 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 runtimes = HashMap::new(); runtimes.insert(root_id, runtime); @@ -70,6 +74,7 @@ impl Tab { root_pane: root_id, layout, panes, + pane_cwds, runtimes, zoomed: false, events, @@ -102,12 +107,13 @@ impl Tab { new_id, rows, cols, - actual_cwd, + actual_cwd.clone(), self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), )?; self.panes.insert(new_id, PaneState::new()); + self.pane_cwds.insert(new_id, actual_cwd); self.runtimes.insert(new_id, runtime); self.zoomed = false; Ok(new_id) @@ -143,6 +149,7 @@ impl Tab { } self.panes.remove(&pane_id); + self.pane_cwds.remove(&pane_id); let runtime = self.runtimes.remove(&pane_id); self.zoomed = false; if let Some(next_root) = next_root { @@ -162,6 +169,17 @@ impl Tab { self.runtimes.get(&self.layout.focused()) } + pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> { + self.runtimes + .get(&pane_id) + .and_then(|rt| rt.cwd()) + .or_else(|| self.pane_cwds.get(&pane_id).cloned()) + } + + pub fn root_cwd(&self) -> Option<PathBuf> { + self.cwd_for_pane(self.root_pane) + } + pub fn has_working_pane(&self) -> bool { self.panes .values() @@ -214,7 +232,7 @@ impl Tab { pub struct Workspace { /// User-provided override. If set, auto-derived identity stops updating. pub custom_name: Option<String>, - /// Fixed workspace identity source, seeded when the workspace is created. + /// Fallback workspace identity source for tests, old snapshots, or missing runtimes. pub identity_cwd: PathBuf, /// Cached ahead/behind counts for the workspace repo's current branch upstream. pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, @@ -412,16 +430,26 @@ impl Workspace { self.custom_name = Some(name); } + pub fn resolved_identity_cwd(&self) -> Option<PathBuf> { + self.tabs + .first() + .and_then(Tab::root_cwd) + .or_else(|| Some(self.identity_cwd.clone())) + } + pub fn display_name(&self) -> String { if let Some(name) = &self.custom_name { return name.clone(); } - derive_label_from_cwd(&self.identity_cwd) + self.resolved_identity_cwd() + .map(|cwd| derive_label_from_cwd(&cwd)) + .unwrap_or_else(|| "workspace".into()) } pub fn branch(&self) -> Option<String> { - git_branch(&self.identity_cwd) + self.resolved_identity_cwd() + .and_then(|cwd| git_branch(&cwd)) } pub fn git_ahead_behind(&self) -> Option<(usize, usize)> { @@ -429,7 +457,9 @@ impl Workspace { } pub fn refresh_git_ahead_behind(&mut self) { - self.cached_git_ahead_behind = git_ahead_behind(&self.identity_cwd); + self.cached_git_ahead_behind = self + .resolved_identity_cwd() + .and_then(|cwd| git_ahead_behind(&cwd)); } pub fn aggregate_state(&self) -> (AgentState, bool) { @@ -666,12 +696,15 @@ impl Workspace { let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); + let mut pane_cwds = HashMap::new(); + pane_cwds.insert(root_id, identity_cwd.clone()); let tab = Tab { custom_name: None, number: 1, root_pane: root_id, layout, panes, + pane_cwds, runtimes: HashMap::new(), zoomed: false, events, @@ -695,6 +728,8 @@ impl Workspace { let tab = self.active_tab_mut().expect("workspace must have tab"); let new_id = tab.layout.split_focused(direction); tab.panes.insert(new_id, PaneState::new()); + let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); + tab.pane_cwds.insert(new_id, cwd); self.register_new_pane(new_id); new_id } @@ -706,12 +741,16 @@ impl Workspace { let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); + let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); + let mut pane_cwds = HashMap::new(); + pane_cwds.insert(root_id, cwd); let tab = Tab { custom_name: name.map(str::to_string), number: self.tabs.len() + 1, root_pane: root_id, layout, panes, + pane_cwds, runtimes: HashMap::new(), zoomed: false, events, @@ -776,15 +815,24 @@ mod tests { } #[test] + fn workspace_identity_follows_first_tab_root_pane_cwd() { + let mut ws = Workspace::test_new("ignored"); + ws.custom_name = None; + let root_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .pane_cwds + .insert(root_pane, PathBuf::from("/tmp/pion")); + + assert_eq!(ws.display_name(), "pion"); + assert_eq!(ws.resolved_identity_cwd(), Some(PathBuf::from("/tmp/pion"))); + } + + #[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); + 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; |
