aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
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/app/input
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/app/input')
-rw-r--r--src/app/input/navigate.rs144
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");