From 57c880f0ebaa5011abc90f4e2fd4f84fc072ce35 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 26 May 2026 14:48:57 +0300 Subject: feat: add global last-pane navigation refs #287 --- src/app/input/mod.rs | 8 ++++-- src/app/input/mouse.rs | 40 +++++++++++++++--------------- src/app/input/navigate.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 23 deletions(-) (limited to 'src/app/input') diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 0f0b3568..e145e628 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -367,7 +367,11 @@ impl AppState { follow_cwd, )); - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + let previous_focus = self.current_pane_focus_target(); + if let Some(ws_idx) = self.active { + let Some(ws) = self.workspaces.get_mut(ws_idx) else { + return; + }; if let Ok(new_pane) = ws.split_focused( direction, new_rows, @@ -381,7 +385,7 @@ impl AppState { terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); self.terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); - ws.layout.focus_pane(new_id); + self.record_pane_focus_change(previous_focus, ws_idx, new_id); self.mark_session_dirty(); self.mode = Mode::Terminal; } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 8adfa55b..6db1a4a9 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -455,12 +455,10 @@ impl AppState { return None; } - if let Some((ws_idx, tab_idx, pane_id)) = + if let Some((ws_idx, _tab_idx, pane_id)) = self.collapsed_agent_detail_target_at(mouse.row) { - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - self.focus_pane(pane_id); + self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; } return None; @@ -550,11 +548,10 @@ 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); - self.switch_tab(tab_idx); - self.focus_pane(pane_id); + self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; return None; } @@ -1027,12 +1024,10 @@ impl AppState { } Some(crate::ui::MobileSwitcherTarget::Agent { ws_idx, - tab_idx, + tab_idx: _, pane_id, }) => { - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - self.focus_pane(pane_id); + self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; } Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { @@ -1264,11 +1259,8 @@ impl AppState { } pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if ws.layout.focused() != pane_id { - ws.layout.focus_pane(pane_id); - self.mark_session_dirty(); - } + if let Some(ws_idx) = self.active { + self.focus_pane_in_workspace(ws_idx, pane_id); } } @@ -1290,13 +1282,11 @@ impl AppState { else { return; }; - let Some(tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { + let Some(_tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { return; }; - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - self.focus_pane(target.pane_id); + self.focus_pane_in_workspace(ws_idx, target.pane_id); self.toast = None; self.mode = Mode::Terminal; } @@ -1738,6 +1728,14 @@ mod tests { assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); assert!(app.state.toast.is_none()); assert_eq!(app.state.mode, Mode::Terminal); + + app.state.last_pane(); + + assert_eq!(app.state.active, Some(0)); + assert_eq!( + app.state.workspaces[0].focused_pane_id(), + Some(app.state.workspaces[0].tabs[0].root_pane) + ); } #[test] diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 108fed8b..3b8435df 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -285,6 +285,7 @@ impl App { let Some(ws_idx) = self.state.active else { return Err(std::io::Error::other("no active workspace")); }; + let previous_focus_target = self.state.current_pane_focus_target(); let (rows, cols) = self.state.estimate_pane_size(); let new_rows = rows.max(4); let new_cols = cols.max(10); @@ -323,6 +324,13 @@ impl App { self.state .terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); + let new_focus_target = crate::app::state::PaneFocusTarget { + workspace_id: ws.id.clone(), + pane_id: new_pane_id, + }; + if previous_focus_target.as_ref() != Some(&new_focus_target) { + self.state.previous_pane_focus = previous_focus_target; + } ws.active_tab_mut() .expect("workspace must have an active tab") .layout @@ -499,6 +507,7 @@ pub(crate) enum NavigateAction { ToggleSidebar, CyclePaneNext, CyclePanePrevious, + LastPane, Help, Settings, ReloadConfig, @@ -589,6 +598,7 @@ fn action_for_key( (&kb.focus_pane_down, NavigateAction::FocusPaneDown), (&kb.focus_pane_up, NavigateAction::FocusPaneUp), (&kb.focus_pane_right, NavigateAction::FocusPaneRight), + (&kb.last_pane, NavigateAction::LastPane), (&kb.cycle_pane_next, NavigateAction::CyclePaneNext), (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious), (&kb.split_vertical, NavigateAction::SplitVertical), @@ -794,6 +804,10 @@ pub(super) fn execute_navigate_action_in_context( state.cycle_pane(true); leave_navigate_mode(state); } + NavigateAction::LastPane => { + state.last_pane(); + leave_navigate_mode(state); + } NavigateAction::Help => super::modal::open_keybind_help(state), NavigateAction::Settings => super::settings::open_settings(state), NavigateAction::ReloadConfig => { @@ -1453,6 +1467,40 @@ navigate_pane_right = "ctrl+l" assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); } + #[test] + fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l"); + + let action = terminal_direct_navigation_action( + &state, + TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT), + ); + + assert_eq!(action, Some(NavigateAction::LastPane)); + } + + #[test] + fn prefix_tab_override_can_map_to_last_pane() { + let config: Config = toml::from_str( + r#" +[keys] +last_pane = "prefix+tab" +"#, + ) + .unwrap(); + let mut state = state_with_workspaces(&["test"]); + state.keybinds = config.keybinds(); + + let pane_action = action_for_key( + &state, + TerminalKey::new(KeyCode::Tab, KeyModifiers::empty()), + BindingDispatch::Prefix, + ); + + assert_eq!(pane_action, Some(NavigateAction::LastPane)); + } + #[test] fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); @@ -1747,6 +1795,7 @@ navigate_pane_right = "ctrl+l" app.render_dirty.clone(), ) .expect("workspace should spawn"); + let root_pane = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); @@ -1774,6 +1823,19 @@ navigate_pane_right = "ctrl+l" assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); assert_eq!(app.terminal_runtimes.len(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); + let overlay_pane = app.state.workspaces[0].focused_pane_id().unwrap(); + assert_ne!(overlay_pane, root_pane); + + app.state.last_pane(); + + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root_pane)); + + app.state.last_pane(); + + assert_eq!( + app.state.workspaces[0].focused_pane_id(), + Some(overlay_pane) + ); let _ = wait_for_file(&output_path); let deadline = std::time::Instant::now() + Duration::from_secs(2); -- cgit v1.2.3