workspace: Allow disabling tab cycles when using keyboard navigation (#51253)

Add a `wrap_around` field, defaulting to `true`, to both
`pane::ActivatePreviousItem` and `pane::ActivateNextItem` actions to
optionally disable wrapping when cycling past the first or last tab.

Release Notes:

- Added a `wrap_around` option to both `pane::ActivatePreviousItem` and
`pane::ActivateNextItem` actions to optionally disable wrapping when
cycling past the first or last tab.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: dino <dinojoaocosta@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-31 11:19:45 +00:00
1 parent f8e871c7b0
commit 18cf2ce256
6 files changed
+122 -24

No files matched your search

@@ -223,8 +223,8 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) {
// 1.txt | [3.txt] | 2.txt | 4.txt
//
// With 3.txt being the active item in the pane.
cx.dispatch_action(ActivatePreviousItem);
cx.dispatch_action(ActivatePreviousItem);
cx.dispatch_action(ActivatePreviousItem::default());
cx.dispatch_action(ActivatePreviousItem::default());
cx.run_until_parked();
cx.simulate_modifiers_change(Modifiers::control());
+14 -6
View File
@@ -1660,9 +1660,13 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
action.range.replace(range.clone());
Some(Box::new(action))
}),
VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("bn", "ext"), workspace::ActivateNextItem::default()).count(),
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem::default()).count(),
VimCommand::new(
("bp", "revious"),
workspace::ActivatePreviousItem::default(),
)
.count(),
VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
@@ -1670,9 +1674,13 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"),
VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem::default()).count(),
VimCommand::new(
("tabp", "revious"),
workspace::ActivatePreviousItem::default(),
)
.count(),
VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem::default()).count(),
VimCommand::new(
("tabc", "lose"),
workspace::CloseActiveItem {
+8 -2
View File
@@ -449,7 +449,10 @@ pub fn init(cx: &mut App) {
);
} else {
// If no count is provided, go to the next tab.
window.dispatch_action(workspace::pane::ActivateNextItem.boxed_clone(), cx);
window.dispatch_action(
workspace::pane::ActivateNextItem::default().boxed_clone(),
cx,
);
}
});
@@ -473,7 +476,10 @@ pub fn init(cx: &mut App) {
}
} else {
// No count provided, go to the previous tab.
window.dispatch_action(workspace::pane::ActivatePreviousItem.boxed_clone(), cx);
window.dispatch_action(
workspace::pane::ActivatePreviousItem::default().boxed_clone(),
cx,
);
}
});
})
+84 -9
View File
@@ -48,7 +48,9 @@ use ui::{
IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition,
Tooltip, prelude::*, right_click_menu,
};
use util::{ResultExt, debug_panic, maybe, paths::PathStyle, truncate_and_remove_front};
use util::{
ResultExt, debug_panic, maybe, paths::PathStyle, serde::default_true, truncate_and_remove_front,
};
/// A selected entry in e.g. project panel.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -229,13 +231,41 @@ split_structs!(
SplitVertical => "Splits the pane vertically."
);
/// Activates the previous item in the pane.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields, default)]
pub struct ActivatePreviousItem {
/// Whether to wrap from the first item to the last item.
#[serde(default = "default_true")]
pub wrap_around: bool,
}
impl Default for ActivatePreviousItem {
fn default() -> Self {
Self { wrap_around: true }
}
}
/// Activates the next item in the pane.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields, default)]
pub struct ActivateNextItem {
/// Whether to wrap from the last item to the first item.
#[serde(default = "default_true")]
pub wrap_around: bool,
}
impl Default for ActivateNextItem {
fn default() -> Self {
Self { wrap_around: true }
}
}
actions!(
pane,
[
/// Activates the previous item in the pane.
ActivatePreviousItem,
/// Activates the next item in the pane.
ActivateNextItem,
/// Activates the last item in the pane.
ActivateLastItem,
/// Switches to the alternate file.
@@ -1477,14 +1507,14 @@ impl Pane {
pub fn activate_previous_item(
&mut self,
_: &ActivatePreviousItem,
action: &ActivatePreviousItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut index = self.active_item_index;
if index > 0 {
index -= 1;
} else if !self.items.is_empty() {
} else if action.wrap_around && !self.items.is_empty() {
index = self.items.len() - 1;
}
self.activate_item(index, true, true, window, cx);
@@ -1492,14 +1522,14 @@ impl Pane {
pub fn activate_next_item(
&mut self,
_: &ActivateNextItem,
action: &ActivateNextItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut index = self.active_item_index;
if index + 1 < self.items.len() {
index += 1;
} else {
} else if action.wrap_around {
index = 0;
}
self.activate_item(index, true, true, window, cx);
@@ -8585,6 +8615,51 @@ mod tests {
);
}
#[gpui::test]
async fn test_activate_item_with_wrap_around(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_next_item(&ActivateNextItem { wrap_around: false }, window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_next_item(&ActivateNextItem::default(), window, cx);
});
assert_item_labels(&pane, ["A*", "B", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_previous_item(&ActivatePreviousItem { wrap_around: false }, window, cx);
});
assert_item_labels(&pane, ["A*", "B", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_previous_item(&ActivatePreviousItem::default(), window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_previous_item(&ActivatePreviousItem { wrap_around: false }, window, cx);
});
assert_item_labels(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_next_item(&ActivateNextItem { wrap_around: false }, window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
+10 -4
View File
@@ -7083,7 +7083,7 @@ impl Workspace {
))
.on_action(cx.listener(Workspace::toggle_centered_layout))
.on_action(cx.listener(
|workspace: &mut Workspace, _action: &pane::ActivateNextItem, window, cx| {
|workspace: &mut Workspace, action: &pane::ActivateNextItem, window, cx| {
if let Some(active_dock) = workspace.active_dock(window, cx) {
let dock = active_dock.read(cx);
if let Some(active_panel) = dock.active_panel() {
@@ -7101,14 +7101,17 @@ impl Workspace {
}
if let Some(pane) = recent_pane {
let wrap_around = action.wrap_around;
pane.update(cx, |pane, cx| {
let current_index = pane.active_item_index();
let items_len = pane.items_len();
if items_len > 0 {
let next_index = if current_index + 1 < items_len {
current_index + 1
} else {
} else if wrap_around {
0
} else {
return;
};
pane.activate_item(
next_index, false, false, window, cx,
@@ -7124,7 +7127,7 @@ impl Workspace {
},
))
.on_action(cx.listener(
|workspace: &mut Workspace, _action: &pane::ActivatePreviousItem, window, cx| {
|workspace: &mut Workspace, action: &pane::ActivatePreviousItem, window, cx| {
if let Some(active_dock) = workspace.active_dock(window, cx) {
let dock = active_dock.read(cx);
if let Some(active_panel) = dock.active_panel() {
@@ -7142,14 +7145,17 @@ impl Workspace {
}
if let Some(pane) = recent_pane {
let wrap_around = action.wrap_around;
pane.update(cx, |pane, cx| {
let current_index = pane.active_item_index();
let items_len = pane.items_len();
if items_len > 0 {
let prev_index = if current_index > 0 {
current_index - 1
} else {
} else if wrap_around {
items_len.saturating_sub(1)
} else {
return;
};
pane.activate_item(
prev_index, false, false, window, cx,
+4 -1
View File
@@ -4579,7 +4579,10 @@ mod tests {
assert_key_bindings_for(
window.into(),
cx,
vec![("backspace", &ActionB), ("{", &ActivatePreviousItem)],
vec![
("backspace", &ActionB),
("{", &ActivatePreviousItem::default()),
],
line!(),
);
}