aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDavid Heinemeier Hansson <david@hey.com>2026-08-10 00:55:28 +0200
committerGitHub <noreply@github.com>2026-08-10 01:55:28 +0300
commitf5067ed829ae5743499916b941b2310c50681f29 (patch)
tree1695386cd3256ea9be1916e4385d1bbbe90d8748 /src
parente48d83067a5a9b05dfd0a82761068866ab2d294d (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.rs144
-rw-r--r--src/config/keybinds.rs6
-rw-r--r--src/config/model.rs14
-rw-r--r--src/main.rs2
-rw-r--r--src/ui/keybind_help.rs2
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"),
];