diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index bcb3488c50..5f13a4a645 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - The desktop tab bar now has configurable right-aligned status entries for zoom state, hostname, date/time, literal text, and asynchronously refreshed command output. +- Optional `keys.move_tab_previous` and `keys.move_tab_next` bindings now reorder the active tab in place, wrapping at either end. - Optional `keys.resize_pane_left`, `keys.resize_pane_down`, `keys.resize_pane_up`, and `keys.resize_pane_right` bindings now resize the focused pane in one keystroke without entering resize mode. - Devin CLI, Cursor Agent CLI, MastraCode, Hermes Agent, and Grok CLI integrations now install and run natively on Windows. - Panes can now route normal right-click gestures to mouse-reporting applications through the pane menu, `herdr pane input`, `pane.input.set`, or the `pane split --right-click pane` launch option. diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 700e28ec71..6024aaab72 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -391,6 +391,18 @@ "default": "\"prefix+n\"", "description": "Select the next tab." }, + { + "key": "keys.move_tab_previous", + "type": "keybinding", + "default": "unset", + "description": "Move the active tab one position toward the front. Unset by default." + }, + { + "key": "keys.move_tab_next", + "type": "keybinding", + "default": "unset", + "description": "Move the active tab one position toward the back. Unset by default." + }, { "key": "keys.switch_tab", "type": "keybinding", diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index d4d714a908..fd6ef2732b 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 { 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 { + 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; @@ -2658,6 +2724,84 @@ resize_pane_left = "prefix+shift+left" assert_eq!(action, Some(NavigateAction::ResizePaneLeft)); } + #[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 { + 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"]); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index de7321214c..79dc87e3e5 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, pub switch_workspace: Vec, 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 16aa1d7db7..0b237376de 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, #[serde(skip_serializing_if = "Option::is_none")] + move_tab_previous: Option, + #[serde(skip_serializing_if = "Option::is_none")] + move_tab_next: Option, + #[serde(skip_serializing_if = "Option::is_none")] switch_tab: Option, #[serde(skip_serializing_if = "Option::is_none")] switch_workspace: Option, @@ -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 f70ee8b4f1..c32cfe537c 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 b390ab86bf..1f01c60c1c 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 { 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"), ];