Skip to content

Commit b24fce3

Browse files
authored
Support multi select grouping actions for vertical tabs (#12229)
## Description Adds group operations to the vertical tabs multi-selection from [the previous PR](#12200). When the user has multiple tabs selected (via shift-click or cmd-click), right-clicking any selected tab opens a new multi-tab menu with three actions: Create group from tabs, Move to group, and Remove from group. Create group from tabs turns the selection into a new tab group at the top of the tab list. Move to group folds the selection into an existing group, anchored at the group's first member so the group doesn't visually move. Remove from group is available only when every selected tab shares the same group; the removed tabs land just below the group's remaining members, or in the same position if no remaining members existing. NOTE: UI is not polished here and is going to be updated in a follow up PR. ## How it works Added new workspace actions for creating group, moving to group and removing from group when several tabs are selected. Helper functions containing the logic can be found in `app/src/workspace/view/tab_grouping.rs`. Important note: - Moving to group is possible if there is another group available for at least one of the selected tabs (ie Group 1 contains members A,B,C, tab list contains A,B,C,D. Selecting all 4 tabs allows move to group 1, since tab D can be moved to group 1). All other behavior for these actions should be as expected. Empty groups are deleted after any of these actions. Added two new actions for clearing all selected tabs, and for toggling the multi-tab selection right click menu, which displays the "Move to group", "Remove from group" and "Create group from tabs" options. This menu is displayed on right click of a `selected` tab, otherwise the regular single-tab menu appears. Right click on a tab or pane branches on `is_in_multi_tab_selection` to determine which menu to display. The "move to group" sidecar menu is now used for both the single-tab menu as well as the multi-tab selection menu, rendering is extracted into a shared function `add_move_to_group_sidecar_overlay`. The only other thing to note here: Since we support cmd + click to select any individual tab, the active tab is always assumed to be selected when any other member is selected, but is only marked as selected for a range selection. This is important when determining which menu to display when right clicking the active tab, without needing to do additional bookkeeping on every cmd + click that toggles selected tabs. ## Linked Issue https://linear.app/warpdotdev/issue/APP-4661/group-creation-from-multi-select-for-vertical-tabs ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. If your change can be manually tested, please include screenshots or a screen recording that show it working end to end. You can run the app locally using `./script/run` - see WARP.md for more details on how to get set up. --> - [x] I have manually tested my changes locally with `./script/run` ### Screenshots / Videos [Demo video](https://www.loom.com/share/adbfa7c9fd794863995655316c4cdc4b) This demo video shows a mix of shift + click range selection, cmd + click individual tab selection and a mix of different actions from the multi tab selection menu
1 parent a44fbf1 commit b24fce3

4 files changed

Lines changed: 551 additions & 97 deletions

File tree

app/src/workspace/action.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ pub enum WorkspaceAction {
153153
tab_index: usize,
154154
anchor: TabContextMenuAnchor,
155155
},
156+
/// Toggles the multi-tab selection right-click menu.
157+
/// Dispatched by the UI when the right-clicked tab is part of a multi-tab
158+
/// selection (cmd-click or shift-click).
159+
ToggleTabSelectionRightClickMenu {
160+
tab_index: usize,
161+
anchor: TabContextMenuAnchor,
162+
},
156163
ToggleVerticalTabsPaneContextMenu {
157164
tab_index: usize,
158165
target: VerticalTabsPaneContextMenuTarget,
@@ -195,6 +202,17 @@ pub enum WorkspaceAction {
195202
ToggleTabMultiSelection {
196203
locator: PaneViewLocator,
197204
},
205+
/// Clears the tab multi-selection. Dispatched from the UI when the user takes
206+
/// an action that should cancel any active selections.
207+
ClearTabMultiSelection,
208+
/// Creates a new tab group from the current tab multi-selection.
209+
NewTabGroupFromSelectedTabs,
210+
/// Moves every selected tab into `group_id`.
211+
MoveSelectedTabsToGroup {
212+
group_id: TabGroupId,
213+
},
214+
/// Removes every selected tab from its group (requires a single shared group).
215+
RemoveSelectedTabsFromGroup,
198216
ToggleTabGroupRightClickMenu {
199217
group_id: TabGroupId,
200218
anchor: TabContextMenuAnchor,
@@ -841,6 +859,9 @@ impl WorkspaceAction {
841859
| NewTabGroupFromTab(_)
842860
| MoveTabToGroup { .. }
843861
| RemoveTabFromGroup(_)
862+
| NewTabGroupFromSelectedTabs
863+
| MoveSelectedTabsToGroup { .. }
864+
| RemoveSelectedTabsFromGroup
844865
| UngroupTabs(_)
845866
| NewTabInGroup(_)
846867
| MoveTabGroupUp(_)
@@ -907,6 +928,7 @@ impl WorkspaceAction {
907928
| ToggleSyntaxHighlighting
908929
| OpenLaunchConfigSaveModal
909930
| ToggleTabRightClickMenu { .. }
931+
| ToggleTabSelectionRightClickMenu { .. }
910932
| ToggleTabGroupRightClickMenu { .. }
911933
| ToggleVerticalTabsPaneContextMenu { .. }
912934
| OpenNewSessionMenu { .. }
@@ -1023,6 +1045,7 @@ impl WorkspaceAction {
10231045
| FocusPane(..)
10241046
| ShiftSelectTabRange { .. }
10251047
| ToggleTabMultiSelection { .. }
1048+
| ClearTabMultiSelection
10261049
| StartNewConversation { .. }
10271050
| UndoRevertInCodeReviewPane { .. }
10281051
| JumpToLatestToast

app/src/workspace/view.rs

Lines changed: 95 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,8 @@ pub struct Workspace {
988988
show_tab_right_click_menu: Option<(usize, TabContextMenuAnchor)>,
989989
/// Open tab group more-options menu; reuses the `tab_right_click_menu` view.
990990
show_tab_group_right_click_menu: Option<(TabGroupId, TabContextMenuAnchor)>,
991+
/// Open multi-tab selection menu (right-click on any tab in a multi-tab selection).
992+
show_tab_selection_right_click_menu: Option<(usize, TabContextMenuAnchor)>,
991993
// TODO(CORE-2300): this used to be add_tab_dropdown_menu.
992994
// Because we are rolling out the change behind a feature flag,
993995
// keep this comment here until the feature flag is removed.
@@ -3245,6 +3247,7 @@ impl Workspace {
32453247
tab_right_click_menu,
32463248
show_tab_right_click_menu: None,
32473249
show_tab_group_right_click_menu: None,
3250+
show_tab_selection_right_click_menu: None,
32483251
new_session_dropdown_menu,
32493252
show_new_session_dropdown_menu: None,
32503253
changelog_model,
@@ -9341,6 +9344,7 @@ impl Workspace {
93419344
MenuEvent::Close { via_select_item: _ } => {
93429345
self.show_tab_right_click_menu = None;
93439346
self.show_tab_group_right_click_menu = None;
9347+
self.show_tab_selection_right_click_menu = None;
93449348
self.hide_move_to_group_sidecar(ctx);
93459349
ctx.notify();
93469350
}
@@ -9458,53 +9462,15 @@ impl Workspace {
94589462
menu_items
94599463
}
94609464

9461-
/// Builds the sidecar rows: every group except the tab's current one,
9462-
/// ordered by first member's tab index to match the tabs panel.
9463-
fn build_move_to_group_sidecar_items(
9464-
&self,
9465-
tab_index: usize,
9466-
) -> Vec<MenuItem<WorkspaceAction>> {
9467-
let Some(tab) = self.tabs.get(tab_index) else {
9468-
return vec![];
9469-
};
9470-
let current_group_id = tab.group_id;
9471-
9472-
// Other groups paired with their first member's tab index, sorted so the menu
9473-
// matches panel order.
9474-
let sorted_other_groups = self
9475-
.tab_groups
9476-
.keys()
9477-
.copied()
9478-
.filter(|gid| Some(*gid) != current_group_id)
9479-
.filter_map(|gid| {
9480-
group_member_indices(&self.tabs, gid)
9481-
.next()
9482-
.map(|idx| (gid, idx))
9483-
})
9484-
.sorted_by_key(|(_, idx)| *idx);
9485-
9486-
sorted_other_groups
9487-
.map(|(group_id, _)| {
9488-
let label = self
9489-
.tab_groups
9490-
.get(&group_id)
9491-
.and_then(|g| g.name.clone())
9492-
.unwrap_or_else(|| "Untitled group".to_string());
9493-
MenuItemFields::new(label)
9494-
.with_on_select_action(WorkspaceAction::MoveTabToGroup {
9495-
tab_index,
9496-
group_id,
9497-
})
9498-
.into_item()
9499-
})
9500-
.collect()
9501-
}
9502-
95039465
/// Opens the sidecar when "Move to group" is hovered, hides it otherwise.
9466+
/// Handles both the single-tab pane menu and the multi-tab selection menu.
95049467
fn update_move_to_group_sidecar(&mut self, ctx: &mut ViewContext<Self>) {
9505-
let Some((tab_index, _)) = self.show_tab_right_click_menu else {
9468+
// If neither menu is open, nothing to update.
9469+
if self.show_tab_right_click_menu.is_none()
9470+
&& self.show_tab_selection_right_click_menu.is_none()
9471+
{
95069472
return;
9507-
};
9473+
}
95089474
// No hovered index = cursor left the menu (possibly onto the sidecar);
95099475
// no label = hovered a non-label row (e.g. separator).
95109476
let hovered = self.tab_right_click_menu.read(ctx, |menu, _| {
@@ -9528,7 +9494,13 @@ impl Workspace {
95289494
};
95299495

95309496
if label == MOVE_TO_GROUP_LABEL {
9531-
let items = self.build_move_to_group_sidecar_items(tab_index);
9497+
// Single-tab pane menu carries a `tab_index`; the multi-tab
9498+
// selection menu has no single source tab so we pass `None` and
9499+
// the sidecar builder infers the multi-tab selection.
9500+
let source_tab_index = self
9501+
.show_tab_right_click_menu
9502+
.map(|(tab_index, _)| tab_index);
9503+
let items = self.build_move_to_group_sidecar_items(source_tab_index);
95329504
if items.is_empty() {
95339505
self.hide_move_to_group_sidecar(ctx);
95349506
return;
@@ -9565,17 +9537,60 @@ impl Workspace {
95659537
ctx.notify();
95669538
}
95679539

9540+
/// Shared between the single-tab pane menu and the multi-tab selection
9541+
/// menu render paths so the two parent menus can't drift apart in how
9542+
/// they position the "move to group" sidecar.
9543+
fn add_move_to_group_sidecar_overlay(&self, stack: &mut Stack, app: &AppContext) {
9544+
if !self.show_move_to_group_sidecar {
9545+
return;
9546+
}
9547+
let sidecar_element = SavePosition::new(
9548+
ChildView::new(&self.move_to_group_sidecar_menu).finish(),
9549+
MOVE_TO_GROUP_SIDECAR_POSITION_ID,
9550+
)
9551+
.finish();
9552+
9553+
// Flip the anchor side when the sidecar would overflow the window.
9554+
let render_left =
9555+
self.should_render_sidecar_left(MOVE_TO_GROUP_LABEL, MOVE_TO_GROUP_SIDECAR_WIDTH, app);
9556+
let (offset, parent_anchor, child_anchor) = if render_left {
9557+
(
9558+
vec2f(-4., 0.),
9559+
PositionedElementAnchor::TopLeft,
9560+
ChildAnchor::TopRight,
9561+
)
9562+
} else {
9563+
(
9564+
vec2f(4., 0.),
9565+
PositionedElementAnchor::TopRight,
9566+
ChildAnchor::TopLeft,
9567+
)
9568+
};
9569+
9570+
stack.add_positioned_overlay_child(
9571+
sidecar_element,
9572+
OffsetPositioning::offset_from_save_position_element(
9573+
MOVE_TO_GROUP_LABEL,
9574+
offset,
9575+
PositionedElementOffsetBounds::WindowByPosition,
9576+
parent_anchor,
9577+
child_anchor,
9578+
),
9579+
);
9580+
}
9581+
95689582
fn handle_move_to_group_sidecar_event(
95699583
&mut self,
95709584
event: &MenuEvent,
95719585
ctx: &mut ViewContext<Self>,
95729586
) {
95739587
match event {
95749588
MenuEvent::Close { via_select_item } => {
9575-
// Item dispatch fires `MoveTabToGroup` itself; we just tear
9576-
// down the parent menu on a real pick.
9589+
// Item dispatch fires the move action itself; we just tear down
9590+
// the parent menu (single-tab or multi-tab selection) on a pick.
95779591
if *via_select_item {
95789592
self.show_tab_right_click_menu = None;
9593+
self.show_tab_selection_right_click_menu = None;
95799594
}
95809595
self.show_move_to_group_sidecar = false;
95819596
self.tab_right_click_menu.update(ctx, |menu, _| {
@@ -22646,6 +22661,9 @@ impl TypedActionView for Workspace {
2264622661
ToggleTabRightClickMenu { tab_index, anchor } => {
2264722662
self.toggle_tab_right_click_menu(*tab_index, *anchor, ctx)
2264822663
}
22664+
ToggleTabSelectionRightClickMenu { tab_index, anchor } => {
22665+
self.toggle_tab_selection_right_click_menu(*tab_index, *anchor, ctx)
22666+
}
2264922667
ToggleVerticalTabsPaneContextMenu {
2265022668
tab_index,
2265122669
target,
@@ -22675,6 +22693,12 @@ impl TypedActionView for Workspace {
2267522693
RemoveTabFromGroup(tab_index) => self.remove_tab_from_group(*tab_index, ctx),
2267622694
ShiftSelectTabRange { locator } => self.shift_select_tab_range(*locator, ctx),
2267722695
ToggleTabMultiSelection { locator } => self.toggle_tab_multi_selection(*locator, ctx),
22696+
ClearTabMultiSelection => self.clear_tab_multi_selection(ctx),
22697+
NewTabGroupFromSelectedTabs => self.new_tab_group_from_selected_tabs(ctx),
22698+
MoveSelectedTabsToGroup { group_id } => {
22699+
self.move_selected_tabs_to_group(*group_id, ctx)
22700+
}
22701+
RemoveSelectedTabsFromGroup => self.remove_selected_tabs_from_group(ctx),
2267822702
ToggleTabGroupRightClickMenu { group_id, anchor } => {
2267922703
self.toggle_tab_group_right_click_menu(*group_id, *anchor, ctx)
2268022704
}
@@ -25267,45 +25291,32 @@ impl View for Workspace {
2526725291
);
2526825292
}
2526925293

25270-
// Sidecar menu for the "Move to group" submenu parent. Mirrors
25271-
// the new-session sidecar's overflow-aware left/right anchoring.
25272-
if self.show_move_to_group_sidecar {
25273-
let sidecar_element = SavePosition::new(
25274-
ChildView::new(&self.move_to_group_sidecar_menu).finish(),
25275-
MOVE_TO_GROUP_SIDECAR_POSITION_ID,
25276-
)
25277-
.finish();
25294+
self.add_move_to_group_sidecar_overlay(&mut stack, app);
25295+
}
25296+
}
2527825297

25279-
let render_left = self.should_render_sidecar_left(
25280-
MOVE_TO_GROUP_LABEL,
25281-
MOVE_TO_GROUP_SIDECAR_WIDTH,
25282-
app,
25283-
);
25284-
let (offset, parent_anchor, child_anchor) = if render_left {
25285-
(
25286-
vec2f(-4., 0.),
25287-
PositionedElementAnchor::TopLeft,
25288-
ChildAnchor::TopRight,
25289-
)
25290-
} else {
25291-
(
25292-
vec2f(4., 0.),
25293-
PositionedElementAnchor::TopRight,
25294-
ChildAnchor::TopLeft,
25295-
)
25296-
};
25298+
// Multi-tab selection menu (reuses the `tab_right_click_menu` view).
25299+
if let Some((_tab_idx, anchor)) = self.show_tab_selection_right_click_menu {
25300+
let is_vertical = FeatureFlag::VerticalTabs.is_enabled()
25301+
&& *TabSettings::as_ref(app).use_vertical_tabs
25302+
&& self.vertical_tabs_panel_open;
25303+
if is_vertical {
25304+
let position = match anchor {
25305+
TabContextMenuAnchor::Pointer(position) => position,
25306+
// The selection menu is never opened via the kebab button.
25307+
TabContextMenuAnchor::VerticalTabsKebab => Vector2F::zero(),
25308+
};
25309+
stack.add_positioned_overlay_child(
25310+
ChildView::new(&self.tab_right_click_menu).finish(),
25311+
OffsetPositioning::offset_from_parent(
25312+
position,
25313+
ParentOffsetBounds::WindowByPosition,
25314+
ParentAnchor::TopLeft,
25315+
ChildAnchor::TopLeft,
25316+
),
25317+
);
2529725318

25298-
stack.add_positioned_overlay_child(
25299-
sidecar_element,
25300-
OffsetPositioning::offset_from_save_position_element(
25301-
MOVE_TO_GROUP_LABEL,
25302-
offset,
25303-
PositionedElementOffsetBounds::WindowByPosition,
25304-
parent_anchor,
25305-
child_anchor,
25306-
),
25307-
);
25308-
}
25319+
self.add_move_to_group_sidecar_overlay(&mut stack, app);
2530925320
}
2531025321
}
2531125322

0 commit comments

Comments
 (0)