Ensure pane: swap item right does not panic (#36765)

This fixes a panic I randomly ran into whilst mistyping in the command
palette: I accidentally ran `pane: swap item right`in a state where no
items were opened in my active pane. We were checking for `index + 1 ==
self.items.len()` there when it really should be `>=`, as otherwise in
the case of no items this panics.

This PR fixes the bug, adds a test for both the panic as well as the
actions themselves (they were untested previously). Lastly (and mostly),
this also cleans up a bit around existing actions to update them with
how we generally handle actions now.

Release Notes:

- Fixed a panic that could occur with the `pane: swap item right`
action.
This commit is contained in:
Finn Evers 2025-08-22 23:28:55 +02:00 committed by GitHub
parent f649c31bf9
commit e6267c42f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 57 deletions

View file

@ -970,7 +970,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
// the follow. // the follow.
workspace_b.update_in(cx_b, |workspace, window, cx| { workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| { workspace.active_pane().update(cx, |pane, cx| {
pane.activate_prev_item(true, window, cx); pane.activate_previous_item(&Default::default(), window, cx);
}); });
}); });
executor.run_until_parked(); executor.run_until_parked();
@ -1073,7 +1073,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
// Client A cycles through some tabs. // Client A cycles through some tabs.
workspace_a.update_in(cx_a, |workspace, window, cx| { workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| { workspace.active_pane().update(cx, |pane, cx| {
pane.activate_prev_item(true, window, cx); pane.activate_previous_item(&Default::default(), window, cx);
}); });
}); });
executor.run_until_parked(); executor.run_until_parked();
@ -1117,7 +1117,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
workspace_a.update_in(cx_a, |workspace, window, cx| { workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| { workspace.active_pane().update(cx, |pane, cx| {
pane.activate_prev_item(true, window, cx); pane.activate_previous_item(&Default::default(), window, cx);
}); });
}); });
executor.run_until_parked(); executor.run_until_parked();
@ -1164,7 +1164,7 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
workspace_a.update_in(cx_a, |workspace, window, cx| { workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| { workspace.active_pane().update(cx, |pane, cx| {
pane.activate_prev_item(true, window, cx); pane.activate_previous_item(&Default::default(), window, cx);
}); });
}); });
executor.run_until_parked(); executor.run_until_parked();

View file

@ -22715,7 +22715,7 @@ async fn test_invisible_worktree_servers(cx: &mut TestAppContext) {
.await .await
.unwrap(); .unwrap();
pane.update_in(cx, |pane, window, cx| { pane.update_in(cx, |pane, window, cx| {
pane.navigate_backward(window, cx); pane.navigate_backward(&Default::default(), window, cx);
}); });
cx.run_until_parked(); cx.run_until_parked();
pane.update(cx, |pane, cx| { pane.update(cx, |pane, cx| {
@ -24302,7 +24302,7 @@ async fn test_document_colors(cx: &mut TestAppContext) {
workspace workspace
.update(cx, |workspace, window, cx| { .update(cx, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| { workspace.active_pane().update(cx, |pane, cx| {
pane.navigate_backward(window, cx); pane.navigate_backward(&Default::default(), window, cx);
}) })
}) })
.unwrap(); .unwrap();

View file

@ -3905,7 +3905,7 @@ pub mod tests {
assert_eq!(workspace.active_pane(), &second_pane); assert_eq!(workspace.active_pane(), &second_pane);
second_pane.update(cx, |this, cx| { second_pane.update(cx, |this, cx| {
assert_eq!(this.active_item_index(), 1); assert_eq!(this.active_item_index(), 1);
this.activate_prev_item(false, window, cx); this.activate_previous_item(&Default::default(), window, cx);
assert_eq!(this.active_item_index(), 0); assert_eq!(this.active_item_index(), 0);
}); });
workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx); workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
@ -3940,7 +3940,9 @@ pub mod tests {
// Focus the second pane's non-search item // Focus the second pane's non-search item
window window
.update(cx, |_workspace, window, cx| { .update(cx, |_workspace, window, cx| {
second_pane.update(cx, |pane, cx| pane.activate_next_item(true, window, cx)); second_pane.update(cx, |pane, cx| {
pane.activate_next_item(&Default::default(), window, cx)
});
}) })
.unwrap(); .unwrap();

View file

@ -514,7 +514,7 @@ impl Pane {
} }
} }
fn alternate_file(&mut self, window: &mut Window, cx: &mut Context<Pane>) { fn alternate_file(&mut self, _: &AlternateFile, window: &mut Window, cx: &mut Context<Pane>) {
let (_, alternative) = &self.alternate_file_items; let (_, alternative) = &self.alternate_file_items;
if let Some(alternative) = alternative { if let Some(alternative) = alternative {
let existing = self let existing = self
@ -788,7 +788,7 @@ impl Pane {
!self.nav_history.0.lock().forward_stack.is_empty() !self.nav_history.0.lock().forward_stack.is_empty()
} }
pub fn navigate_backward(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn navigate_backward(&mut self, _: &GoBack, window: &mut Window, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() { if let Some(workspace) = self.workspace.upgrade() {
let pane = cx.entity().downgrade(); let pane = cx.entity().downgrade();
window.defer(cx, move |window, cx| { window.defer(cx, move |window, cx| {
@ -799,7 +799,7 @@ impl Pane {
} }
} }
fn navigate_forward(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn navigate_forward(&mut self, _: &GoForward, window: &mut Window, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() { if let Some(workspace) = self.workspace.upgrade() {
let pane = cx.entity().downgrade(); let pane = cx.entity().downgrade();
window.defer(cx, move |window, cx| { window.defer(cx, move |window, cx| {
@ -1283,9 +1283,9 @@ impl Pane {
} }
} }
pub fn activate_prev_item( pub fn activate_previous_item(
&mut self, &mut self,
activate_pane: bool, _: &ActivatePreviousItem,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
@ -1295,12 +1295,12 @@ impl Pane {
} else if !self.items.is_empty() { } else if !self.items.is_empty() {
index = self.items.len() - 1; index = self.items.len() - 1;
} }
self.activate_item(index, activate_pane, activate_pane, window, cx); self.activate_item(index, true, true, window, cx);
} }
pub fn activate_next_item( pub fn activate_next_item(
&mut self, &mut self,
activate_pane: bool, _: &ActivateNextItem,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
@ -1310,10 +1310,15 @@ impl Pane {
} else { } else {
index = 0; index = 0;
} }
self.activate_item(index, activate_pane, activate_pane, window, cx); self.activate_item(index, true, true, window, cx);
} }
pub fn swap_item_left(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn swap_item_left(
&mut self,
_: &SwapItemLeft,
window: &mut Window,
cx: &mut Context<Self>,
) {
let index = self.active_item_index; let index = self.active_item_index;
if index == 0 { if index == 0 {
return; return;
@ -1323,9 +1328,14 @@ impl Pane {
self.activate_item(index - 1, true, true, window, cx); self.activate_item(index - 1, true, true, window, cx);
} }
pub fn swap_item_right(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn swap_item_right(
&mut self,
_: &SwapItemRight,
window: &mut Window,
cx: &mut Context<Self>,
) {
let index = self.active_item_index; let index = self.active_item_index;
if index + 1 == self.items.len() { if index + 1 >= self.items.len() {
return; return;
} }
@ -1333,6 +1343,16 @@ impl Pane {
self.activate_item(index + 1, true, true, window, cx); self.activate_item(index + 1, true, true, window, cx);
} }
pub fn activate_last_item(
&mut self,
_: &ActivateLastItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
let index = self.items.len().saturating_sub(1);
self.activate_item(index, true, true, window, cx);
}
pub fn close_active_item( pub fn close_active_item(
&mut self, &mut self,
action: &CloseActiveItem, action: &CloseActiveItem,
@ -2881,7 +2901,9 @@ impl Pane {
.on_click({ .on_click({
let entity = cx.entity(); let entity = cx.entity();
move |_, window, cx| { move |_, window, cx| {
entity.update(cx, |pane, cx| pane.navigate_backward(window, cx)) entity.update(cx, |pane, cx| {
pane.navigate_backward(&Default::default(), window, cx)
})
} }
}) })
.disabled(!self.can_navigate_backward()) .disabled(!self.can_navigate_backward())
@ -2896,7 +2918,11 @@ impl Pane {
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.on_click({ .on_click({
let entity = cx.entity(); let entity = cx.entity();
move |_, window, cx| entity.update(cx, |pane, cx| pane.navigate_forward(window, cx)) move |_, window, cx| {
entity.update(cx, |pane, cx| {
pane.navigate_forward(&Default::default(), window, cx)
})
}
}) })
.disabled(!self.can_navigate_forward()) .disabled(!self.can_navigate_forward())
.tooltip({ .tooltip({
@ -3528,9 +3554,6 @@ impl Render for Pane {
.size_full() .size_full()
.flex_none() .flex_none()
.overflow_hidden() .overflow_hidden()
.on_action(cx.listener(|pane, _: &AlternateFile, window, cx| {
pane.alternate_file(window, cx);
}))
.on_action( .on_action(
cx.listener(|pane, _: &SplitLeft, _, cx| pane.split(SplitDirection::Left, cx)), cx.listener(|pane, _: &SplitLeft, _, cx| pane.split(SplitDirection::Left, cx)),
) )
@ -3547,12 +3570,6 @@ impl Render for Pane {
.on_action( .on_action(
cx.listener(|pane, _: &SplitDown, _, cx| pane.split(SplitDirection::Down, cx)), cx.listener(|pane, _: &SplitDown, _, cx| pane.split(SplitDirection::Down, cx)),
) )
.on_action(
cx.listener(|pane, _: &GoBack, window, cx| pane.navigate_backward(window, cx)),
)
.on_action(
cx.listener(|pane, _: &GoForward, window, cx| pane.navigate_forward(window, cx)),
)
.on_action(cx.listener(|_, _: &JoinIntoNext, _, cx| { .on_action(cx.listener(|_, _: &JoinIntoNext, _, cx| {
cx.emit(Event::JoinIntoNext); cx.emit(Event::JoinIntoNext);
})) }))
@ -3560,6 +3577,8 @@ impl Render for Pane {
cx.emit(Event::JoinAll); cx.emit(Event::JoinAll);
})) }))
.on_action(cx.listener(Pane::toggle_zoom)) .on_action(cx.listener(Pane::toggle_zoom))
.on_action(cx.listener(Self::navigate_backward))
.on_action(cx.listener(Self::navigate_forward))
.on_action( .on_action(
cx.listener(|pane: &mut Pane, action: &ActivateItem, window, cx| { cx.listener(|pane: &mut Pane, action: &ActivateItem, window, cx| {
pane.activate_item( pane.activate_item(
@ -3571,33 +3590,14 @@ impl Render for Pane {
); );
}), }),
) )
.on_action( .on_action(cx.listener(Self::alternate_file))
cx.listener(|pane: &mut Pane, _: &ActivateLastItem, window, cx| { .on_action(cx.listener(Self::activate_last_item))
pane.activate_item(pane.items.len().saturating_sub(1), true, true, window, cx); .on_action(cx.listener(Self::activate_previous_item))
}), .on_action(cx.listener(Self::activate_next_item))
) .on_action(cx.listener(Self::swap_item_left))
.on_action( .on_action(cx.listener(Self::swap_item_right))
cx.listener(|pane: &mut Pane, _: &ActivatePreviousItem, window, cx| { .on_action(cx.listener(Self::toggle_pin_tab))
pane.activate_prev_item(true, window, cx); .on_action(cx.listener(Self::unpin_all_tabs))
}),
)
.on_action(
cx.listener(|pane: &mut Pane, _: &ActivateNextItem, window, cx| {
pane.activate_next_item(true, window, cx);
}),
)
.on_action(
cx.listener(|pane, _: &SwapItemLeft, window, cx| pane.swap_item_left(window, cx)),
)
.on_action(
cx.listener(|pane, _: &SwapItemRight, window, cx| pane.swap_item_right(window, cx)),
)
.on_action(cx.listener(|pane, action, window, cx| {
pane.toggle_pin_tab(action, window, cx);
}))
.on_action(cx.listener(|pane, action, window, cx| {
pane.unpin_all_tabs(action, window, cx);
}))
.when(PreviewTabsSettings::get_global(cx).enabled, |this| { .when(PreviewTabsSettings::get_global(cx).enabled, |this| {
this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, _, cx| { this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, _, cx| {
if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) {
@ -6452,6 +6452,57 @@ mod tests {
.unwrap(); .unwrap();
} }
#[gpui::test]
async fn test_item_swapping_actions(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, window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
assert_item_labels(&pane, [], cx);
// Test that these actions do not panic
pane.update_in(cx, |pane, window, cx| {
pane.swap_item_right(&Default::default(), window, cx);
});
pane.update_in(cx, |pane, window, cx| {
pane.swap_item_left(&Default::default(), window, cx);
});
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.swap_item_right(&Default::default(), window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.swap_item_left(&Default::default(), window, cx);
});
assert_item_labels(&pane, ["A", "C*", "B"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.swap_item_left(&Default::default(), window, cx);
});
assert_item_labels(&pane, ["C*", "A", "B"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.swap_item_left(&Default::default(), window, cx);
});
assert_item_labels(&pane, ["C*", "A", "B"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.swap_item_right(&Default::default(), window, cx);
});
assert_item_labels(&pane, ["A", "C*", "B"], cx);
}
fn init_test(cx: &mut TestAppContext) { fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| { cx.update(|cx| {
let settings_store = SettingsStore::test(cx); let settings_store = SettingsStore::test(cx);