diff options
| author | David Heinemeier Hansson <david@hey.com> | 2026-08-10 00:55:28 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-10 01:55:28 +0300 |
| commit | f5067ed829ae5743499916b941b2310c50681f29 (patch) | |
| tree | 1695386cd3256ea9be1916e4385d1bbbe90d8748 /src/app/input | |
| parent | e48d83067a5a9b05dfd0a82761068866ab2d294d (diff) | |
feat: add move tab keybind actions (#2561)
* feat: add move tab keybind actions
Add optional keys.move_tab_previous/move_tab_next actions that reorder
the active tab one position, wrapping at either end. Reuses the
existing tab.move runtime path that mouse drag reordering already
drives.
* fix: exit navigate mode on single-tab move attempts
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/navigate.rs | 144 |
1 files changed, 144 insertions, 0 deletions
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index d4d714a9..fd6ef273 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -320,6 +320,18 @@ impl App { leave_navigate_mode(&mut self.state); } } + NavigateAction::MoveTabPrevious => { + if let Some((ws_idx, source, insert)) = self.active_tab_move(-1) { + self.move_tab_via_api(ws_idx, source, insert); + } + leave_navigate_mode(&mut self.state); + } + NavigateAction::MoveTabNext => { + if let Some((ws_idx, source, insert)) = self.active_tab_move(1) { + self.move_tab_via_api(ws_idx, source, insert); + } + leave_navigate_mode(&mut self.state); + } NavigateAction::CloseTab => { if !self.close_active_tab_via_api_requires_confirmation() { leave_navigate_mode(&mut self.state); @@ -749,6 +761,14 @@ impl App { order.get(next).copied() } + fn active_tab_move(&self, delta: isize) -> Option<(usize, usize, usize)> { + let ws_idx = self.state.active?; + let ws = self.state.workspaces.get(ws_idx)?; + let source = ws.active_tab; + let insert = tab_move_insert_index(ws.tabs.len(), source, delta)?; + Some((ws_idx, source, insert)) + } + fn relative_tab(&self, delta: isize) -> Option<usize> { let ws = self .state @@ -1377,6 +1397,8 @@ pub(crate) enum NavigateAction { RenameTab, PreviousTab, NextTab, + MoveTabPrevious, + MoveTabNext, CloseTab, RenamePane, FocusPaneLeft, @@ -1522,6 +1544,8 @@ fn non_indexed_action_for_key( (&kb.rename_tab, NavigateAction::RenameTab), (&kb.previous_tab, NavigateAction::PreviousTab), (&kb.next_tab, NavigateAction::NextTab), + (&kb.move_tab_previous, NavigateAction::MoveTabPrevious), + (&kb.move_tab_next, NavigateAction::MoveTabNext), (&kb.close_tab, NavigateAction::CloseTab), (&kb.rename_pane, NavigateAction::RenamePane), (&kb.edit_scrollback, NavigateAction::EditScrollback), @@ -1723,6 +1747,14 @@ pub(super) fn execute_navigate_action_in_context( state.next_tab(); leave_navigate_mode(state); } + NavigateAction::MoveTabPrevious => { + move_active_tab_relative(state, -1); + leave_navigate_mode(state); + } + NavigateAction::MoveTabNext => { + move_active_tab_relative(state, 1); + leave_navigate_mode(state); + } NavigateAction::CloseTab => { if !state.close_tab() { leave_navigate_mode(state); @@ -1861,6 +1893,40 @@ fn workspace_can_start_worktree_action( !git_space.is_some_and(|space| space.is_linked_worktree) } +// Translate a one-step move into the pre-removal insertion slot that +// Workspace::move_tab expects, wrapping at either end. None when there is +// nothing to move. +fn tab_move_insert_index(len: usize, source: usize, delta: isize) -> Option<usize> { + if len <= 1 { + return None; + } + Some(if delta > 0 { + if source + 1 >= len { + 0 + } else { + source + 2 + } + } else if source == 0 { + len + } else { + source - 1 + }) +} + +#[cfg(test)] +fn move_active_tab_relative(state: &mut AppState, delta: isize) { + let Some(ws) = state + .active + .and_then(|ws_idx| state.workspaces.get_mut(ws_idx)) + else { + return; + }; + let source = ws.active_tab; + if let Some(insert) = tab_move_insert_index(ws.tabs.len(), source, delta) { + ws.move_tab(source, insert); + } +} + fn leave_navigate_mode(state: &mut AppState) { if state.active.is_some() { state.mode = Mode::Terminal; @@ -2659,6 +2725,84 @@ resize_pane_left = "prefix+shift+left" } #[test] + fn terminal_direct_move_tab_shortcut_maps_to_navigation_action() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.move_tab_next = crate::config::ActionKeybinds::direct("alt+shift+right"); + + let action = terminal_direct_navigation_action( + &state, + TerminalKey::new(KeyCode::Right, KeyModifiers::ALT | KeyModifiers::SHIFT), + ); + + assert_eq!(action, Some(NavigateAction::MoveTabNext)); + } + + fn tab_labels(state: &AppState) -> Vec<String> { + let ws = &state.workspaces[0]; + (0..ws.tabs.len()) + .map(|tab_idx| ws.tab_display_name(tab_idx).unwrap()) + .collect() + } + + #[test] + fn move_tab_actions_reorder_and_wrap_the_active_tab() { + let mut state = state_with_workspaces(&["test"]); + { + let ws = &mut state.workspaces[0]; + ws.tabs[0].set_custom_name("a".into()); + ws.test_add_tab(Some("b")); + ws.test_add_tab(Some("c")); + ws.switch_tab(1); + } + + execute_navigate_action(&mut state, NavigateAction::MoveTabNext); + assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); + assert_eq!(state.workspaces[0].active_tab, 2); + + execute_navigate_action(&mut state, NavigateAction::MoveTabNext); + assert_eq!(tab_labels(&state), vec!["b", "a", "c"]); + assert_eq!(state.workspaces[0].active_tab, 0); + + execute_navigate_action(&mut state, NavigateAction::MoveTabPrevious); + assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); + assert_eq!(state.workspaces[0].active_tab, 2); + state.workspaces[0].assert_invariants_for_test(); + } + + #[test] + fn move_tab_is_a_noop_with_a_single_tab() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].tabs[0].set_custom_name("only".into()); + + execute_navigate_action(&mut state, NavigateAction::MoveTabNext); + + assert_eq!(tab_labels(&state), vec!["only"]); + assert_eq!(state.workspaces[0].active_tab, 0); + } + + #[test] + fn move_tab_with_a_single_tab_still_exits_navigate_mode() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub, + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("solo")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + + app.execute_tui_navigate_action(NavigateAction::MoveTabNext, ActionContext::Navigate); + + assert_eq!(app.state.workspaces[0].tabs.len(), 1); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[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"); |
