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 | |
| 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')
| -rw-r--r-- | src/app/input/navigate.rs | 144 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 6 | ||||
| -rw-r--r-- | src/config/model.rs | 14 | ||||
| -rw-r--r-- | src/main.rs | 2 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 2 |
5 files changed, 168 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"); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index de732121..79dc87e3 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -328,6 +328,8 @@ pub struct Keybinds { pub rename_tab: ActionKeybinds, pub previous_tab: ActionKeybinds, pub next_tab: ActionKeybinds, + pub move_tab_previous: ActionKeybinds, + pub move_tab_next: ActionKeybinds, pub switch_tab: Vec<IndexedKeybind>, pub switch_workspace: Vec<IndexedKeybind>, pub close_tab: ActionKeybinds, @@ -494,6 +496,8 @@ impl Config { rename_tab: empty_action!(), previous_tab: empty_action!(), next_tab: empty_action!(), + move_tab_previous: empty_action!(), + move_tab_next: empty_action!(), switch_tab: Vec::new(), switch_workspace: Vec::new(), close_tab: empty_action!(), @@ -629,6 +633,8 @@ impl Config { apply_action!(keybinds.rename_tab, rename_tab, source); apply_action!(keybinds.previous_tab, previous_tab, source); apply_action!(keybinds.next_tab, next_tab, source); + apply_action!(keybinds.move_tab_previous, move_tab_previous, source); + apply_action!(keybinds.move_tab_next, move_tab_next, source); apply_indexed!( keybinds.switch_tab, switch_tab, diff --git a/src/config/model.rs b/src/config/model.rs index 16aa1d7d..0b237376 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -393,6 +393,10 @@ pub struct KeysConfig { pub previous_tab: BindingConfig, /// Select the next tab. Default: "prefix+n". pub next_tab: BindingConfig, + /// Move the active tab one position toward the front. Unset by default. + pub move_tab_previous: BindingConfig, + /// Move the active tab one position toward the back. Unset by default. + pub move_tab_next: BindingConfig, /// Switch to tab 1-9. Default: "prefix+1..9". pub switch_tab: BindingConfig, /// Switch to workspace 1-9 from prefix mode. Unset by default. @@ -521,6 +525,10 @@ pub(crate) struct KeysConfigOverlay { #[serde(skip_serializing_if = "Option::is_none")] next_tab: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] + move_tab_previous: Option<BindingConfig>, + #[serde(skip_serializing_if = "Option::is_none")] + move_tab_next: Option<BindingConfig>, + #[serde(skip_serializing_if = "Option::is_none")] switch_tab: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] switch_workspace: Option<BindingConfig>, @@ -627,6 +635,8 @@ impl<'de> Deserialize<'de> for KeysConfig { apply_field!(rename_tab); apply_field!(previous_tab); apply_field!(next_tab); + apply_field!(move_tab_previous); + apply_field!(move_tab_next); apply_field!(switch_tab); apply_field!(switch_workspace); apply_field!(close_tab); @@ -729,6 +739,8 @@ impl KeysConfig { copy_effective_action_field!(rename_tab, keybinds.rename_tab); copy_effective_action_field!(previous_tab, keybinds.previous_tab); copy_effective_action_field!(next_tab, keybinds.next_tab); + copy_effective_action_field!(move_tab_previous, keybinds.move_tab_previous); + copy_effective_action_field!(move_tab_next, keybinds.move_tab_next); copy_effective_indexed_field!(switch_tab, keybinds.switch_tab); copy_effective_indexed_field!(switch_workspace, keybinds.switch_workspace); copy_effective_action_field!(close_tab, keybinds.close_tab); @@ -1025,6 +1037,8 @@ impl Default for KeysConfig { rename_tab: BindingConfig::one("prefix+shift+t"), previous_tab: BindingConfig::one("prefix+p"), next_tab: BindingConfig::one("prefix+n"), + move_tab_previous: BindingConfig::empty(), + move_tab_next: BindingConfig::empty(), switch_tab: BindingConfig::one("prefix+1..9"), switch_workspace: BindingConfig::empty(), close_tab: BindingConfig::one("prefix+shift+x"), diff --git a/src/main.rs b/src/main.rs index f70ee8b4..c32cfe53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -196,6 +196,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # rename_tab = "prefix+shift+t" # previous_tab = "prefix+p" # next_tab = "prefix+n" +# move_tab_previous = "" # optional, e.g. "alt+shift+left" moves the tab toward the front +# move_tab_next = "" # optional, e.g. "alt+shift+right" moves the tab toward the back # switch_tab = "prefix+1..9" # switch_workspace = "" # optional indexed binding, e.g. "prefix+shift+1..9" # close_tab = "prefix+shift+x" diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index b390ab86..1f01c60c 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -131,6 +131,8 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec<HelpGroup> { help_entry(keybind_label(&kb.rename_tab), "rename tab"), help_entry(keybind_label(&kb.previous_tab), "previous tab"), help_entry(keybind_label(&kb.next_tab), "next tab"), + help_entry(keybind_label(&kb.move_tab_previous), "move tab left"), + help_entry(keybind_label(&kb.move_tab_next), "move tab right"), help_entry(indexed_label(&kb.switch_tab), "switch tab 1-9"), help_entry(keybind_label(&kb.close_tab), "close tab"), ]; |
