From 63f024be239069998312061755ecb6fdb0f13250 Mon Sep 17 00:00:00 2001 From: rgatx <48363610+rgatx@users.noreply.github.com> Date: Fri, 1 May 2026 11:29:51 -0500 Subject: [PATCH] Group vertical tabs by project --- app/src/code/view.rs | 38 +- app/src/code/wasm.rs | 4 + app/src/pane_group/mod.rs | 7 +- app/src/pane_group/pane/view/header/mod.rs | 20 +- app/src/pane_group/pane/view/mod.rs | 11 +- app/src/tab.rs | 1 + app/src/workspace/action.rs | 12 + app/src/workspace/mod.rs | 3 +- app/src/workspace/tab_settings.rs | 9 + app/src/workspace/view.rs | 347 ++++++++++++++- app/src/workspace/view/vertical_tabs.rs | 406 ++++++++++++++++-- .../view/vertical_tabs/project_grouping.rs | 371 ++++++++++++++++ .../workspace/view/vertical_tabs/telemetry.rs | 3 + app/src/workspace/view/vertical_tabs_tests.rs | 44 +- app/src/workspace/view_test.rs | 54 +++ 15 files changed, 1268 insertions(+), 62 deletions(-) create mode 100644 app/src/workspace/view/vertical_tabs/project_grouping.rs diff --git a/app/src/code/view.rs b/app/src/code/view.rs index 8e74510d9..3001e2b34 100644 --- a/app/src/code/view.rs +++ b/app/src/code/view.rs @@ -20,7 +20,7 @@ use crate::terminal::cli_agent::{ }; use crate::terminal::view::CliAgentRouting; use crate::workspace::util::get_context_target_terminal_view; -use crate::workspace::TabBarDropTargetData; +use crate::workspace::{TabBarDropTargetData, VerticalTabsPaneDropTargetData}; use crate::{code::EditorTabBarDropTargetData, pane_group::pane::ActionOrigin}; use lsp::LspManagerModel; use pathfinder_color::ColorU; @@ -634,6 +634,10 @@ impl CodeView { }) } + pub fn local_paths(&self) -> Vec { + self.tab_group.iter().filter_map(TabData::path).collect() + } + pub fn pane_configuration(&self) -> ModelHandle { self.pane_configuration.clone() } @@ -1581,6 +1585,26 @@ impl CodeView { precomputed_tab_hover_index: None, }, ); + } else if let Some(data) = data.and_then(|data| { + data.as_any() + .downcast_ref::() + }) { + // If an editor tab is dragged over the vertical workspace tab bar, we should clear + // all drag indicators on the editor tab group. + ctx.dispatch_typed_action( + PaneHeaderAction::::CustomAction( + CodeViewAction::ClearEditorTabGroupDragPositions, + ), + ); + + ctx.dispatch_typed_action( + PaneHeaderAction::::PaneHeaderDragged { + origin: ActionOrigin::EditorTab(index), + drag_location: PaneDragDropLocation::TabBar(data.tab_bar_location), + drag_position, + precomputed_tab_hover_index: Some(data.tab_hover_index), + }, + ); } else { // If an editor tab is dragged anywhere else, we should clear all drag indicators on the editor and workspace tab groups. ctx.dispatch_typed_action( @@ -1615,6 +1639,18 @@ impl CodeView { PaneHeaderAction::::PaneHeaderDropped { origin: ActionOrigin::EditorTab(index), drop_location: PaneDragDropLocation::TabBar(data.tab_bar_location), + visual_tab_order: None, + }, + ); + } else if let Some(data) = data.and_then(|data| { + data.as_any() + .downcast_ref::() + }) { + ctx.dispatch_typed_action( + PaneHeaderAction::::PaneHeaderDropped { + origin: ActionOrigin::EditorTab(index), + drop_location: PaneDragDropLocation::TabBar(data.tab_bar_location), + visual_tab_order: data.visual_tab_order.clone(), }, ); } diff --git a/app/src/code/wasm.rs b/app/src/code/wasm.rs index a8a4b5329..ecb27916d 100644 --- a/app/src/code/wasm.rs +++ b/app/src/code/wasm.rs @@ -153,6 +153,10 @@ impl CodeView { None } + pub fn local_paths(&self) -> Vec { + Vec::new() + } + pub fn focus(&self, _ctx: &mut ViewContext) {} pub fn pane_configuration(&self) -> ModelHandle { diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 3cc4772ae..66020a411 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -580,6 +580,7 @@ pub enum Event { DroppedOnTabBar { origin: ActionOrigin, pane_id: PaneId, + visual_tab_order: Option>, }, /// Switches the focus to the specified tab and moves the given /// pane_id into the tab as a hidden pane. This will insert it into the pane @@ -1147,10 +1148,14 @@ impl PaneGroup { ctx.emit(Event::ClearHoveredTabIndex); self.move_pane(pane_id, *target_id, *direction, ctx); } - PaneViewEvent::DroppedOnTabBar { origin } => { + PaneViewEvent::DroppedOnTabBar { + origin, + visual_tab_order, + } => { ctx.emit(Event::DroppedOnTabBar { origin: *origin, pane_id, + visual_tab_order: visual_tab_order.clone(), }); ctx.emit(Event::ClearHoveredTabIndex); } diff --git a/app/src/pane_group/pane/view/header/mod.rs b/app/src/pane_group/pane/view/header/mod.rs index 24793ae14..553f5611b 100644 --- a/app/src/pane_group/pane/view/header/mod.rs +++ b/app/src/pane_group/pane/view/header/mod.rs @@ -84,6 +84,7 @@ pub enum Event { /// A pane or file tab was dropped on the workspace tab bar. DroppedOnTabBar { origin: ActionOrigin, + visual_tab_order: Option>, }, // This header was dropped on a place outside of the pane group or tab bar PaneDroppedOutsideofTabBarOrPaneGroup, @@ -116,6 +117,7 @@ pub enum PaneHeaderAction { PaneHeaderDropped { origin: ActionOrigin, drop_location: PaneDragDropLocation, // Represents what kind of drop target the pane was dropped over + visual_tab_order: Option>, }, PaneHeaderClicked, } @@ -913,11 +915,9 @@ impl TypedActionView for PaneHeader

{ ctx, ) }), - hidden_pane_preview_direction: if precomputed_tab_hover_index.is_some() { - Direction::Up - } else { - Direction::Left - }, + // Precomputed hover indices are supplied by vertical tabs, whose over-tab + // drop path commits the same left root split. + hidden_pane_preview_direction: Direction::Left, }); } PaneDragDropLocation::PaneGroup(target_id) => { @@ -945,11 +945,15 @@ impl TypedActionView for PaneHeader

{ PaneHeaderAction::PaneHeaderDropped { origin, drop_location, + visual_tab_order, } => { match drop_location { PaneDragDropLocation::TabBar(_) => { self.is_visible_in_pane_group = true; - ctx.emit(Event::DroppedOnTabBar { origin: *origin }) + ctx.emit(Event::DroppedOnTabBar { + origin: *origin, + visual_tab_order: visual_tab_order.clone(), + }) } PaneDragDropLocation::PaneGroup(_) => { ctx.emit(Event::PaneDroppedWithinPaneGroup) @@ -1093,6 +1097,7 @@ pub fn render_pane_header_draggable( >::PaneHeaderDropped { origin: ActionOrigin::Pane, drop_location: PaneDragDropLocation::TabBar(data.tab_bar_location), + visual_tab_order: None, }) } else if let Some(data) = data.and_then(|data| { data.as_any() @@ -1104,6 +1109,7 @@ pub fn render_pane_header_draggable( >::PaneHeaderDropped { origin: ActionOrigin::Pane, drop_location: PaneDragDropLocation::TabBar(data.tab_bar_location), + visual_tab_order: data.visual_tab_order.clone(), }) } else if let Some(data) = data.and_then(|data| data.as_any().downcast_ref::()) @@ -1114,6 +1120,7 @@ pub fn render_pane_header_draggable( >::PaneHeaderDropped { origin: ActionOrigin::Pane, drop_location: PaneDragDropLocation::PaneGroup(data.id), + visual_tab_order: None, }) } else { ctx.dispatch_typed_action(PaneHeaderAction::< @@ -1122,6 +1129,7 @@ pub fn render_pane_header_draggable( >::PaneHeaderDropped { origin: ActionOrigin::Pane, drop_location: PaneDragDropLocation::Other, + visual_tab_order: None, }) } }) diff --git a/app/src/pane_group/pane/view/mod.rs b/app/src/pane_group/pane/view/mod.rs index 70c8486d6..b1120e192 100644 --- a/app/src/pane_group/pane/view/mod.rs +++ b/app/src/pane_group/pane/view/mod.rs @@ -55,6 +55,7 @@ pub enum PaneViewEvent { }, DroppedOnTabBar { origin: ActionOrigin, + visual_tab_order: Option>, }, DraggedOntoTabBar { origin: ActionOrigin, @@ -301,13 +302,19 @@ impl PaneView

{ self.is_being_dragged = false; ctx.notify(); } - header::Event::DroppedOnTabBar { origin } => { + header::Event::DroppedOnTabBar { + origin, + visual_tab_order, + } => { // If we're handling a drop event for a workspace pane, we want to get rid of the neutral background that obscures it. if matches!(origin, ActionOrigin::Pane) { self.is_being_dragged = false; } - ctx.emit(PaneViewEvent::DroppedOnTabBar { origin: *origin }); + ctx.emit(PaneViewEvent::DroppedOnTabBar { + origin: *origin, + visual_tab_order: visual_tab_order.clone(), + }); ctx.notify(); } header::Event::DraggedOverTabBar { diff --git a/app/src/tab.rs b/app/src/tab.rs index 92735f63d..c897a6d41 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -1698,6 +1698,7 @@ impl UiComponent for TabComponent<'_> { ctx.dispatch_typed_action(WorkspaceAction::DragTab { tab_index, tab_position: rect, + vertical_context: None, }); }) .on_drop(|ctx, _, _, _| ctx.dispatch_typed_action(WorkspaceAction::DropTab)); diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index 0bb157282..874ca1e45 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -81,6 +81,15 @@ pub enum TabContextMenuAnchor { VerticalTabsKebab, } +#[derive(Clone, Debug)] +pub struct VerticalTabDragContext { + pub visual_above_index: Option, + pub visual_below_index: Option, + pub current_group_indices: Vec, + pub visual_above_group_indices: Option>, + pub visual_below_group_indices: Option>, +} + #[derive(Debug, Clone, Copy)] pub enum VerticalTabsPaneContextMenuTarget { ClickedPane(PaneViewLocator), @@ -245,6 +254,7 @@ pub enum WorkspaceAction { DragTab { tab_index: usize, tab_position: RectF, + vertical_context: Option, }, DropTab, /// Toggles the left panel. In Code Mode V1 this toggles Warp Drive. @@ -271,6 +281,7 @@ pub enum WorkspaceAction { SetVerticalTabsCompactSubtitle(VerticalTabsCompactSubtitle), ToggleVerticalTabsShowPrLink, ToggleVerticalTabsShowDiffStats, + ToggleVerticalTabsGroupByProject, ToggleVerticalTabsShowDetailsOnHover, /// Closes the focused panel. This happens as an explicit action from the user. ClosePanel, @@ -833,6 +844,7 @@ impl WorkspaceAction { | SetVerticalTabsCompactSubtitle(_) | ToggleVerticalTabsShowPrLink | ToggleVerticalTabsShowDiffStats + | ToggleVerticalTabsGroupByProject | ToggleVerticalTabsShowDetailsOnHover | ToggleWelcomeTips | CopyTextToClipboard(_) diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index f0dde6ca9..17be0a256 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -1535,10 +1535,11 @@ pub struct TabBarDropTargetData { pub tab_bar_location: TabBarLocation, } -#[derive(PartialEq, Copy, Clone, Debug)] +#[derive(PartialEq, Clone, Debug)] pub struct VerticalTabsPaneDropTargetData { pub tab_bar_location: TabBarLocation, pub tab_hover_index: TabBarHoverIndex, + pub visual_tab_order: Option>, } #[derive(PartialEq, Copy, Clone, Debug, Serialize, Deserialize)] diff --git a/app/src/workspace/tab_settings.rs b/app/src/workspace/tab_settings.rs index 4ef9eb061..fe95c9f02 100644 --- a/app/src/workspace/tab_settings.rs +++ b/app/src/workspace/tab_settings.rs @@ -507,6 +507,15 @@ define_settings_group!(TabSettings, settings: [ vertical_tabs_view_mode: VerticalTabsViewMode, vertical_tabs_primary_info: VerticalTabsPrimaryInfo, vertical_tabs_compact_subtitle: VerticalTabsCompactSubtitle, + vertical_tabs_group_by_project: VerticalTabsGroupByProject { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::ALL, + sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes), + private: false, + toml_path: "appearance.vertical_tabs.group_by_project", + description: "Group vertical tabs by project / git repo root.", + }, vertical_tabs_show_pr_link: VerticalTabsShowPrLink { type: bool, default: true, diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 3b2b93c56..fbc6bdc41 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -418,7 +418,7 @@ use crate::editor::{ use crate::persistence::ModelEvent; use super::action::{ - InitContent, RestoreConversationLayout, TabContextMenuAnchor, + InitContent, RestoreConversationLayout, TabContextMenuAnchor, VerticalTabDragContext, VerticalTabsPaneContextMenuTarget, WorkspaceAction, }; use super::close_session_confirmation_dialog::{ @@ -593,6 +593,65 @@ const NEW_SESSION_SIDECAR_SEARCH_BOX_HORIZONTAL_PADDING: f32 = 12.; const NEW_SESSION_SIDECAR_SEARCH_BOX_VERTICAL_PADDING: f32 = 6.; const NEW_SESSION_SIDECAR_FOOTER_HORIZONTAL_PADDING: f32 = 16.; const NEW_SESSION_SIDECAR_FOOTER_VERTICAL_PADDING: f32 = 8.; + +#[derive(Clone, Copy)] +enum TabDragDirection { + Above, + Below, +} + +fn reordered_group_indices( + group_indices: &[usize], + current_index: usize, + target_index: usize, + direction: TabDragDirection, +) -> Vec { + let mut reordered: Vec = group_indices + .iter() + .copied() + .filter(|index| *index != current_index) + .collect(); + let target_position = reordered + .iter() + .position(|index| *index == target_index) + .unwrap_or(reordered.len()); + let insertion_position = match direction { + TabDragDirection::Above => target_position, + TabDragDirection::Below => target_position.saturating_add(1), + }; + reordered.insert(insertion_position.min(reordered.len()), current_index); + reordered +} + +fn is_valid_tab_permutation(order: &[usize], len: usize) -> bool { + if order.len() != len { + return false; + } + let mut seen = vec![false; len]; + for &index in order { + if index >= len || seen[index] { + return false; + } + seen[index] = true; + } + true +} + +fn visual_tab_order_rewrite( + order: &[usize], + len: usize, + active_index: usize, +) -> Option<(Vec, usize)> { + if !is_valid_tab_permutation(order, len) { + return None; + } + let new_active_index = order + .iter() + .position(|index| *index == active_index) + .unwrap_or(active_index.min(len.saturating_sub(1))); + Some((order.to_vec(), new_active_index)) +} + const SESSION_CONFIG_TAB_CONFIG_CHIP_TEXT: &str = "Access your tab configs here."; const SESSION_CONFIG_TAB_CONFIG_CHIP_WIDTH: f32 = 206.; const SHOW_SETTINGS_KEYBINDING_NAME: &str = "workspace:show_settings"; @@ -3494,7 +3553,8 @@ impl Workspace { .. } | TabSettingsChangedEvent::VerticalTabsShowPrLink { .. } - | TabSettingsChangedEvent::VerticalTabsShowDiffStats { .. } => { + | TabSettingsChangedEvent::VerticalTabsShowDiffStats { .. } + | TabSettingsChangedEvent::VerticalTabsGroupByProject { .. } => { ctx.notify(); } TabSettingsChangedEvent::VerticalTabsShowDetailsOnHover { .. } => { @@ -10899,6 +10959,46 @@ impl Workspace { } } + fn normalize_vertical_tab_drop_insertion_index( + &mut self, + insertion_index: usize, + visual_tab_order: Option<&[usize]>, + ctx: &mut ViewContext, + ) -> usize { + let Some(visual_tab_order) = visual_tab_order else { + return insertion_index.min(self.tabs.len()); + }; + let tabs_len = self.tabs.len(); + if !FeatureFlag::VerticalTabs.is_enabled() + || !*TabSettings::as_ref(ctx).use_vertical_tabs + || !*TabSettings::as_ref(ctx) + .vertical_tabs_group_by_project + .value() + { + return insertion_index.min(tabs_len); + } + let Some((visual_tab_order, new_active_index)) = + visual_tab_order_rewrite(visual_tab_order, tabs_len, self.active_tab_index) + else { + return insertion_index.min(tabs_len); + }; + + let active_index = self.active_tab_index; + let tabs = std::mem::take(&mut self.tabs); + let mut tabs_by_old_index: Vec> = tabs.into_iter().map(Some).collect(); + self.tabs = visual_tab_order + .iter() + .filter_map(|index| tabs_by_old_index[*index].take()) + .collect(); + debug_assert_eq!(self.tabs.len(), tabs_len); + if new_active_index != active_index { + self.set_active_tab_index(new_active_index, ctx); + } + ctx.notify(); + + insertion_index.min(tabs_len) + } + pub fn add_tab_for_cloud_notebook( &mut self, notebook_id: SyncId, @@ -11861,6 +11961,195 @@ impl Workspace { }); } + fn move_tabs_to_insertion_index( + &mut self, + mut moving_indices: Vec, + insertion_index: usize, + ctx: &mut ViewContext, + ) { + let tabs_len = self.tabs.len(); + let mut moving_order = Vec::new(); + for index in moving_indices.drain(..) { + if index < tabs_len && !moving_order.contains(&index) { + moving_order.push(index); + } + } + let mut moving_indices = moving_order.clone(); + moving_indices.sort_unstable(); + if moving_indices.is_empty() { + return; + } + + let insertion_index = insertion_index.min(tabs_len); + let active_index = self.active_tab_index; + let tabs = std::mem::take(&mut self.tabs); + let mut moving_tabs = Vec::new(); + let mut remaining_tabs = Vec::new(); + for (index, tab) in tabs.into_iter().enumerate() { + if moving_indices.binary_search(&index).is_ok() { + moving_tabs.push((index, tab)); + } else { + remaining_tabs.push((index, tab)); + } + } + let mut ordered_moving_tabs = Vec::new(); + for moving_index in moving_order { + if let Some(position) = moving_tabs + .iter() + .position(|(index, _)| *index == moving_index) + { + ordered_moving_tabs.push(moving_tabs.remove(position)); + } + } + + let adjusted_insertion_index = remaining_tabs + .iter() + .take_while(|(index, _)| *index < insertion_index) + .count(); + let mut reordered_tabs = remaining_tabs; + reordered_tabs.splice( + adjusted_insertion_index..adjusted_insertion_index, + ordered_moving_tabs, + ); + + let new_order: Vec = reordered_tabs.iter().map(|(index, _)| *index).collect(); + if new_order.iter().copied().eq(0..tabs_len) { + // `self.tabs` was moved out above; restore it before returning from a no-op reorder. + self.tabs = reordered_tabs.into_iter().map(|(_, tab)| tab).collect(); + return; + } + + let new_active_index = new_order + .iter() + .position(|index| *index == active_index) + .unwrap_or(active_index.min(tabs_len.saturating_sub(1))); + self.tabs = reordered_tabs.into_iter().map(|(_, tab)| tab).collect(); + if new_active_index != active_index { + self.set_active_tab_index(new_active_index, ctx); + } + ctx.notify(); + } + + fn calculate_updated_tab_insertion_vertical( + &self, + current_index: usize, + drag_position: RectF, + vertical_context: Option<&VerticalTabDragContext>, + ctx: &mut ViewContext, + ) -> Option<(Vec, usize)> { + if let Some(vertical_context) = vertical_context { + if vertical_context + .current_group_indices + .contains(¤t_index) + { + return self.calculate_updated_tab_insertion_from_visual_context( + current_index, + drag_position, + vertical_context, + ctx, + ); + } + debug_assert!( + false, + "vertical tab drag context must include the current tab index" + ); + } + + let new_index = + self.calculate_updated_tab_index_vertical(current_index, drag_position, ctx); + if new_index != current_index { + let insertion_index = if new_index > current_index { + new_index + 1 + } else { + new_index + }; + return Some((vec![current_index], insertion_index)); + } + None + } + + fn calculate_updated_tab_insertion_from_visual_context( + &self, + current_index: usize, + drag_position: RectF, + vertical_context: &VerticalTabDragContext, + ctx: &mut ViewContext, + ) -> Option<(Vec, usize)> { + let midpoint_drag_y = (drag_position.min_y() + drag_position.max_y()) / 2.; + + if let Some(tab_index) = vertical_context.visual_above_index { + if let Some(tab_position) = ctx.element_position_by_id(tab_position_id(tab_index)) { + let neighbor_midpoint_y = (tab_position.min_y() + tab_position.max_y()) / 2.; + if midpoint_drag_y < neighbor_midpoint_y { + let same_group = vertical_context.current_group_indices.contains(&tab_index); + let (moving_indices, insertion_index) = if same_group { + ( + reordered_group_indices( + &vertical_context.current_group_indices, + current_index, + tab_index, + TabDragDirection::Above, + ), + vertical_context + .current_group_indices + .iter() + .min() + .copied() + .unwrap_or(current_index), + ) + } else { + ( + vertical_context.current_group_indices.clone(), + vertical_context + .visual_above_group_indices + .as_ref() + .and_then(|indices| indices.iter().min().copied()) + .unwrap_or(tab_index), + ) + }; + return Some((moving_indices, insertion_index)); + } + } + } + + if let Some(tab_index) = vertical_context.visual_below_index { + if let Some(tab_position) = ctx.element_position_by_id(tab_position_id(tab_index)) { + let neighbor_midpoint_y = (tab_position.min_y() + tab_position.max_y()) / 2.; + if midpoint_drag_y > neighbor_midpoint_y { + let same_group = vertical_context.current_group_indices.contains(&tab_index); + let (moving_indices, insertion_index) = if same_group { + ( + reordered_group_indices( + &vertical_context.current_group_indices, + current_index, + tab_index, + TabDragDirection::Below, + ), + vertical_context + .current_group_indices + .iter() + .min() + .copied() + .unwrap_or(current_index), + ) + } else { + ( + vertical_context.current_group_indices.clone(), + vertical_context + .visual_below_group_indices + .as_ref() + .and_then(|indices| indices.iter().max().copied()) + .map(|index| index + 1) + .unwrap_or(tab_index + 1), + ) + }; + return Some((moving_indices, insertion_index)); + } + } + } + + None + } // Move tab, given tab index, left or right fn move_tab(&mut self, index: usize, direction: TabMovement, ctx: &mut ViewContext) { let tabs_len = self.tabs.len(); @@ -13414,7 +13703,11 @@ impl Workspace { } #[cfg(not(feature = "local_fs"))] pane_group::Event::RemoteRepoNavigated { .. } => {} - pane_group::Event::DroppedOnTabBar { origin, pane_id } => { + pane_group::Event::DroppedOnTabBar { + origin, + pane_id, + visual_tab_order, + } => { if let Some(hovered_tab_index) = self.hovered_tab_index { match hovered_tab_index { TabBarHoverIndex::BeforeTab(workspace_tab_index) => { @@ -13436,6 +13729,12 @@ impl Workspace { }; if let Some(pane) = pane { + let workspace_tab_index = self + .normalize_vertical_tab_drop_insertion_index( + workspace_tab_index, + visual_tab_order.as_deref(), + ctx, + ); self.add_tab_from_existing_pane(pane, workspace_tab_index, ctx); // If the setting is enabled, preserve the color of the original pane's @@ -20517,6 +20816,22 @@ impl TypedActionView for Workspace { ); ctx.notify(); } + ToggleVerticalTabsGroupByProject => { + let new_value = TabSettings::handle(ctx).update(ctx, |settings, ctx| { + let new_value = !*settings.vertical_tabs_group_by_project.value(); + let _ = settings + .vertical_tabs_group_by_project + .set_value(new_value, ctx); + new_value + }); + send_telemetry_from_ctx!( + VerticalTabsTelemetryEvent::DisplayOptionChanged( + VerticalTabsDisplayOption::GroupByProject(new_value), + ), + ctx + ); + ctx.notify(); + } ToggleVerticalTabsShowDetailsOnHover => { let new_value = TabSettings::handle(ctx).update(ctx, |settings, ctx| { let new_value = !*settings.vertical_tabs_show_details_on_hover.value(); @@ -20659,7 +20974,8 @@ impl TypedActionView for Workspace { DragTab { tab_index, tab_position, - } => self.on_tab_drag(*tab_index, *tab_position, ctx), + vertical_context, + } => self.on_tab_drag(*tab_index, *tab_position, vertical_context.as_ref(), ctx), DropTab => { let is_cross_window = CrossWindowTabDrag::as_ref(ctx).is_active(); let handed_off_tab_index = @@ -23486,6 +23802,7 @@ impl Workspace { &mut self, current_index: usize, position: RectF, + vertical_context: Option<&VerticalTabDragContext>, ctx: &mut ViewContext, ) { const DETACH_SENSITIVITY: f32 = 10.0; @@ -23643,13 +23960,21 @@ impl Workspace { return; } - let new_index = if FeatureFlag::VerticalTabs.is_enabled() - && *TabSettings::as_ref(ctx).use_vertical_tabs - { - self.calculate_updated_tab_index_vertical(current_index, position, ctx) - } else { - self.calculate_updated_tab_index(current_index, position, ctx) - }; + if FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs { + if let Some((moving_indices, insertion_index)) = self + .calculate_updated_tab_insertion_vertical( + current_index, + position, + vertical_context, + ctx, + ) + { + self.move_tabs_to_insertion_index(moving_indices, insertion_index, ctx); + } + return; + } + + let new_index = self.calculate_updated_tab_index(current_index, position, ctx); if new_index != current_index { self.tabs.swap(new_index, current_index); diff --git a/app/src/workspace/view/vertical_tabs.rs b/app/src/workspace/view/vertical_tabs.rs index a22ae86f4..d87879360 100644 --- a/app/src/workspace/view/vertical_tabs.rs +++ b/app/src/workspace/view/vertical_tabs.rs @@ -1,3 +1,4 @@ +mod project_grouping; pub mod telemetry; use crate::ai::agent::conversation::ConversationStatus; @@ -38,7 +39,7 @@ use crate::ui_components::buttons::combo_inner_button; use crate::ui_components::icons::Icon as UiIcon; use crate::util::bindings::keybinding_name_to_display_string; use crate::util::color::Opacity; -use crate::workspace::action::WorkspaceAction; +use crate::workspace::action::{VerticalTabDragContext, WorkspaceAction}; use crate::workspace::cross_window_tab_drag::CrossWindowTabDrag; use crate::workspace::hoa_onboarding::HoaOnboardingStep; use crate::workspace::tab_settings::{ @@ -90,6 +91,7 @@ const DETAIL_SIDECAR_SECTION_GAP: f32 = 4.; const GROUP_HEADER_VERTICAL_PADDING: f32 = 4.; const GROUP_HORIZONTAL_PADDING: f32 = 8.; const GROUP_BODY_BOTTOM_PADDING: f32 = 8.; +const MULTI_PANE_NESTED_INDENT: f32 = 6.; const GROUP_ITEM_SPACING: f32 = 4.; const TABS_MODE_ITEM_SPACING: f32 = 4.; const GROUP_ACTION_BUTTON_ICON_SIZE: f32 = 12.; @@ -576,6 +578,7 @@ pub(super) struct VerticalTabsPanelState { show_pr_link_mouse_state: MouseStateHandle, show_pr_link_info_tooltip_mouse_state: MouseStateHandle, show_diff_stats_mouse_state: MouseStateHandle, + group_by_project_mouse_state: MouseStateHandle, show_details_on_hover_mouse_state: MouseStateHandle, pub(super) show_settings_popup: bool, } @@ -611,6 +614,7 @@ impl Default for VerticalTabsPanelState { show_pr_link_mouse_state: Default::default(), show_pr_link_info_tooltip_mouse_state: Default::default(), show_diff_stats_mouse_state: Default::default(), + group_by_project_mouse_state: Default::default(), show_details_on_hover_mouse_state: Default::default(), show_settings_popup: false, } @@ -797,11 +801,25 @@ struct GroupHeaderProps<'a> { header_mouse_state: MouseStateHandle, } -#[derive(Clone, Copy)] +#[derive(Clone)] struct TabGroupDragState { is_any_pane_dragging: bool, insert_before_index: usize, insert_after_index: Option, + visual_tab_order: Option>, + vertical_context: Option, +} + +fn grouped_insert_before_index(visual_position: Option, fallback_tab_index: usize) -> usize { + visual_position.unwrap_or(fallback_tab_index) +} + +fn grouped_insert_after_index(visual_tab_count: usize, is_last: bool) -> Option { + is_last.then_some(visual_tab_count) +} + +fn complete_visual_tab_order(rendered_tab_order: &[usize], tab_count: usize) -> Option> { + (rendered_tab_order.len() == tab_count).then(|| rendered_tab_order.to_vec()) } fn resolve_vertical_tabs_mode(app: &AppContext) -> VerticalTabsResolvedMode { @@ -1128,6 +1146,7 @@ fn render_vertical_tab_insertion_target( insert_index: usize, tab_count: usize, is_drag_target: bool, + visual_tab_order: Option<&[usize]>, theme: &WarpTheme, ) -> Box { let content = if is_drag_target { @@ -1141,6 +1160,7 @@ fn render_vertical_tab_insertion_target( VerticalTabsPaneDropTargetData { tab_bar_location: vertical_tabs_tab_bar_location(insert_index, tab_count), tab_hover_index: TabBarHoverIndex::BeforeTab(insert_index), + visual_tab_order: visual_tab_order.map(<[usize]>::to_vec), }, ) .finish() @@ -1151,17 +1171,23 @@ fn add_vertical_tab_insertion_target_overlay( insert_index: usize, tab_count: usize, is_drag_target: bool, - parent_anchor: ParentAnchor, - child_anchor: ChildAnchor, + visual_tab_order: Option<&[usize]>, + anchors: (ParentAnchor, ChildAnchor), theme: &WarpTheme, ) { stack.add_positioned_overlay_child( - render_vertical_tab_insertion_target(insert_index, tab_count, is_drag_target, theme), + render_vertical_tab_insertion_target( + insert_index, + tab_count, + is_drag_target, + visual_tab_order, + theme, + ), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::ParentBySize, - parent_anchor, - child_anchor, + anchors.0, + anchors.1, ), ); } @@ -1646,27 +1672,197 @@ fn render_groups( groups = groups.with_spacing(TABS_MODE_ITEM_SPACING); } - for (visible_tab_index, (tab_index, filtered_pane_ids)) in visible_tabs.iter().enumerate() { - // Insert ghost slot before this tab group if the drop would land here. - if ghost_insertion_index == Some(*tab_index) { - groups.add_child(render_ghost_vertical_tab_slot(workspace, app)); + let group_by_project = *TabSettings::as_ref(app) + .vertical_tabs_group_by_project + .value(); + + let project_groups: Option> = + if group_by_project && !visible_tabs.is_empty() { + Some(project_grouping::group_by_project_key( + visible_tabs.len(), + |i| { + let (tab_idx, _) = &visible_tabs[i]; + let pane_group = workspace.tabs[*tab_idx].pane_group.as_ref(app); + project_grouping::resolve_project_group_for_pane_group( + pane_group, None, *tab_idx, app, + ) + }, + )) + } else { + None + }; + + if let Some(project_groups) = project_groups.as_ref() { + // Keep each project member list in workspace-tab order. Drag insertion and header color + // use the first/last indices as visual top/bottom tabs. + let rendered_project_groups: Vec> = project_groups + .iter() + .map(|project_group| { + project_group + .member_indices + .iter() + .map(|&visible_tab_idx| visible_tabs[visible_tab_idx].0) + .collect() + }) + .collect(); + let rendered_tab_order: Vec = project_groups + .iter() + .flat_map(|project_group| { + project_group + .member_indices + .iter() + .map(|&visible_tab_idx| visible_tabs[visible_tab_idx].0) + }) + .collect(); + let last_visual = project_groups + .last() + .and_then(|g| g.member_indices.last().copied()); + + for project_group in project_groups { + let current_group_indices: Vec = project_group + .member_indices + .iter() + .map(|&visible_tab_idx| visible_tabs[visible_tab_idx].0) + .collect(); + let project_color = + project_group_top_tab_color(&workspace.tabs, ¤t_group_indices, theme); + let project_tab_count = project_group.member_indices.len(); + let project_pane_count: usize = project_group + .member_indices + .iter() + .map(|&i| { + let (tab_idx, filtered_pane_ids) = &visible_tabs[i]; + filtered_pane_ids.as_ref().map_or_else( + || { + workspace.tabs[*tab_idx] + .pane_group + .as_ref(app) + .visible_pane_ids() + .len() + }, + Vec::len, + ) + }) + .sum(); + let show_header = project_tab_count > 1 + || !matches!( + project_group.key, + project_grouping::ProjectGroupKey::Unknown(_) + ); + if show_header { + groups.add_child(render_project_section_header( + &project_group.key, + project_tab_count, + project_pane_count, + project_color, + app, + )); + } + for &visible_tab_idx in &project_group.member_indices { + let (tab_index, filtered_pane_ids) = &visible_tabs[visible_tab_idx]; + // Cross-window tab dragging reports insertion in workspace-tab indices. + if ghost_insertion_index == Some(*tab_index) { + groups.add_child(render_ghost_vertical_tab_slot(workspace, app)); + } + let visual_position = rendered_tab_order + .iter() + .position(|rendered_tab_index| rendered_tab_index == tab_index); + let visual_above_index = visual_position + .and_then(|position| position.checked_sub(1)) + .map(|position| rendered_tab_order[position]); + let visual_below_index = visual_position + .and_then(|position| rendered_tab_order.get(position + 1).copied()); + let is_last = last_visual == Some(visible_tab_idx); + let visual_above_group_indices = visual_above_index.and_then(|tab_index| { + rendered_project_groups + .iter() + .find(|group| group.contains(&tab_index)) + .cloned() + }); + let visual_below_group_indices = visual_below_index.and_then(|tab_index| { + rendered_project_groups + .iter() + .find(|group| group.contains(&tab_index)) + .cloned() + }); + let visual_tab_order = + complete_visual_tab_order(&rendered_tab_order, workspace.tabs.len()); + let insert_before_index = if visual_tab_order.is_some() { + grouped_insert_before_index(visual_position, *tab_index) + } else { + *tab_index + }; + let insert_after_index = if visual_tab_order.is_some() { + grouped_insert_after_index(rendered_tab_order.len(), is_last) + } else { + is_last.then_some(*tab_index + 1) + }; + let vertical_context = visual_tab_order.as_ref().map(|_| VerticalTabDragContext { + visual_above_index, + visual_below_index, + current_group_indices: current_group_indices.clone(), + visual_above_group_indices, + visual_below_group_indices, + }); + groups.add_child(render_tab_group( + state, + workspace, + *tab_index, + &workspace.tabs[*tab_index], + filtered_pane_ids.as_deref(), + TabGroupDragState { + is_any_pane_dragging, + insert_before_index, + insert_after_index, + visual_tab_order, + vertical_context, + }, + app, + )); + } + } + } else { + let rendered_tab_order: Vec = visible_tabs + .iter() + .map(|(tab_index, _)| *tab_index) + .collect(); + let has_complete_visual_order = rendered_tab_order.len() == workspace.tabs.len(); + for (visible_tab_index, (tab_index, filtered_pane_ids)) in visible_tabs.iter().enumerate() { + if ghost_insertion_index == Some(*tab_index) { + groups.add_child(render_ghost_vertical_tab_slot(workspace, app)); + } + let insert_before_index = *tab_index; + let insert_after_index = + (visible_tab_index == visible_tabs.len() - 1).then_some(*tab_index + 1); + let vertical_context = has_complete_visual_order.then(|| VerticalTabDragContext { + visual_above_index: visible_tab_index + .checked_sub(1) + .map(|position| rendered_tab_order[position]), + visual_below_index: rendered_tab_order.get(visible_tab_index + 1).copied(), + current_group_indices: vec![*tab_index], + visual_above_group_indices: visible_tab_index + .checked_sub(1) + .map(|position| vec![rendered_tab_order[position]]), + visual_below_group_indices: rendered_tab_order + .get(visible_tab_index + 1) + .map(|tab_index| vec![*tab_index]), + }); + groups.add_child(render_tab_group( + state, + workspace, + *tab_index, + &workspace.tabs[*tab_index], + filtered_pane_ids.as_deref(), + TabGroupDragState { + is_any_pane_dragging, + insert_before_index, + insert_after_index, + visual_tab_order: None, + vertical_context, + }, + app, + )); } - let insert_before_index = *tab_index; - let insert_after_index = - (visible_tab_index == visible_tabs.len() - 1).then_some(tab_index + 1); - groups.add_child(render_tab_group( - state, - workspace, - *tab_index, - &workspace.tabs[*tab_index], - filtered_pane_ids.as_deref(), - TabGroupDragState { - is_any_pane_dragging, - insert_before_index, - insert_after_index, - }, - app, - )); } // Ghost after all tab groups (fencepost). if ghost_insertion_index == Some(workspace.tabs.len()) { @@ -1702,6 +1898,65 @@ fn render_groups( } } +fn render_project_section_header( + key: &project_grouping::ProjectGroupKey, + tab_count: usize, + pane_count: usize, + project_color: Option, + app: &AppContext, +) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let font_family = appearance.ui_font_family(); + let label_color = theme.sub_text_color(theme.background()); + let rail_fill = project_color.unwrap_or_else(|| internal_colors::fg_overlay_2(theme)); + + let label_text = project_grouping::format_project_header_label(key, tab_count, pane_count); + + let label = Text::new_inline(label_text, font_family, 10.) + .with_clip(ClipConfig::ellipsis()) + .with_color(label_color.into()) + .finish(); + + let rail = ConstrainedBox::new( + Container::new(Empty::new().finish()) + .with_background(rail_fill) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(1.5))) + .finish(), + ) + .with_width(3.) + .with_height(12.) + .finish(); + + let row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(6.) + .with_child(rail) + .with_child(Shrinkable::new(1., label).finish()) + .finish(); + + Container::new(row) + .with_padding( + Padding::uniform(0.) + .with_left(GROUP_HORIZONTAL_PADDING) + .with_right(GROUP_HORIZONTAL_PADDING) + .with_top(GROUP_HEADER_VERTICAL_PADDING + 2.) + .with_bottom(GROUP_HEADER_VERTICAL_PADDING), + ) + .finish() +} + +fn project_group_top_tab_color( + tabs: &[TabData], + tab_indices: &[usize], + theme: &WarpTheme, +) -> Option { + let top_tab_index = *tab_indices.first()?; + let color = tabs.get(top_tab_index)?.color()?; + Some(color.to_ansi_color(&theme.terminal_colors().normal).into()) +} + fn render_tab_group( state: &VerticalTabsPanelState, workspace: &Workspace, @@ -1935,6 +2190,10 @@ fn render_tab_group_internal( let mut group = Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Stretch); + let show_project_grouping = *TabSettings::as_ref(app) + .vertical_tabs_group_by_project + .value(); + let is_multi_pane_in_panes_mode = show_project_grouping && pane_ids_to_render.len() > 1; if has_custom_title || is_being_renamed { group.add_child(render_group_header( GroupHeaderProps { @@ -1946,9 +2205,15 @@ fn render_tab_group_internal( }, app, )); + } else if is_multi_pane_in_panes_mode { + group.add_child(render_multi_pane_tab_marker( + pane_group.display_title(app), + pane_ids_to_render.len(), + app, + )); } - let show_header = has_custom_title || is_being_renamed; + let show_header = has_custom_title || is_being_renamed || is_multi_pane_in_panes_mode; let mut body_padding = Padding::uniform(0.) .with_left(GROUP_HORIZONTAL_PADDING) .with_right(GROUP_HORIZONTAL_PADDING) @@ -1956,8 +2221,20 @@ fn render_tab_group_internal( if !show_header { body_padding = body_padding.with_top(GROUP_BODY_BOTTOM_PADDING); } - group.add_child( + let rows_element = if is_multi_pane_in_panes_mode { Container::new(build_rows()) + .with_padding(Padding::uniform(0.).with_left(MULTI_PANE_NESTED_INDENT)) + .with_border( + Border::new(1.) + .with_sides(false, false, false, true) + .with_border_fill(internal_colors::fg_overlay_2(theme)), + ) + .finish() + } else { + build_rows() + }; + group.add_child( + Container::new(rows_element) .with_padding(body_padding) .finish(), ); @@ -2025,8 +2302,8 @@ fn render_tab_group_internal( workspace.tabs.len(), workspace.hovered_tab_index == Some(TabBarHoverIndex::BeforeTab(drag_state.insert_before_index)), - ParentAnchor::TopLeft, - ChildAnchor::TopLeft, + drag_state.visual_tab_order.as_deref(), + (ParentAnchor::TopLeft, ChildAnchor::TopLeft), theme, ); if let Some(insert_after_index) = drag_state.insert_after_index { @@ -2036,8 +2313,8 @@ fn render_tab_group_internal( workspace.tabs.len(), workspace.hovered_tab_index == Some(TabBarHoverIndex::BeforeTab(insert_after_index)), - ParentAnchor::BottomLeft, - ChildAnchor::BottomLeft, + drag_state.visual_tab_order.as_deref(), + (ParentAnchor::BottomLeft, ChildAnchor::BottomLeft), theme, ); } @@ -2079,6 +2356,7 @@ fn render_tab_group_internal( ctx.dispatch_typed_action(WorkspaceAction::DragTab { tab_index, tab_position: rect, + vertical_context: drag_state.vertical_context.clone(), }); }) .on_drop(|ctx, _, _, _| { @@ -2119,6 +2397,7 @@ fn render_tab_group_internal( VerticalTabsPaneDropTargetData { tab_bar_location: TabBarLocation::TabIndex(tab_index), tab_hover_index: TabBarHoverIndex::OverTab(tab_index), + visual_tab_order: None, }, ) .finish() @@ -2218,6 +2497,8 @@ pub(crate) fn render_tab_group_for_drag_ghost( is_any_pane_dragging: false, insert_before_index: 0, insert_after_index: None, + visual_tab_order: None, + vertical_context: None, }; render_tab_group_internal( &workspace.vertical_tabs_panel, @@ -2288,6 +2569,53 @@ fn render_group_header(props: GroupHeaderProps<'_>, app: &AppContext) -> Box Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let font_family = appearance.ui_font_family(); + let title = if title.is_empty() { + "Untitled tab".to_string() + } else { + title + }; + let text_color = theme.sub_text_color(theme.background()); + + let title = Text::new_inline(title, font_family, 10.) + .with_clip(ClipConfig::ellipsis()) + .with_color(text_color.into()) + .finish(); + let count = Text::new_inline(format!("{pane_count} panes"), font_family, 10.) + .with_color(text_color.into()) + .finish(); + let count = Container::new(count) + .with_background(internal_colors::fg_overlay_1(theme)) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_padding(Padding::uniform(0.).with_left(4.).with_right(4.)) + .finish(); + + let row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(6.) + .with_child(Shrinkable::new(1., title).finish()) + .with_child(count) + .finish(); + + Container::new(row) + .with_padding( + Padding::uniform(0.) + .with_left(GROUP_HORIZONTAL_PADDING) + .with_right(GROUP_HORIZONTAL_PADDING) + .with_top(GROUP_HEADER_VERTICAL_PADDING) + .with_bottom(GROUP_HEADER_VERTICAL_PADDING), + ) + .finish() +} + fn render_passive_terminal_diff_stats_badge( git_line_changes: &GitLineChanges, appearance: &Appearance, @@ -4396,6 +4724,9 @@ pub(super) fn render_settings_popup( let show_diff_stats = *TabSettings::as_ref(app) .vertical_tabs_show_diff_stats .value(); + let group_by_project = *TabSettings::as_ref(app) + .vertical_tabs_group_by_project + .value(); let show_details_on_hover = *TabSettings::as_ref(app) .vertical_tabs_show_details_on_hover .value(); @@ -4732,6 +5063,15 @@ pub(super) fn render_settings_popup( } popup_col.add_child(make_divider(theme)); + popup_col.add_child(render_show_toggle_option( + "Group by project", + group_by_project, + state.group_by_project_mouse_state.clone(), + WorkspaceAction::ToggleVerticalTabsGroupByProject, + None, + appearance, + theme, + )); popup_col.add_child(render_show_toggle_option( "Show details on hover", show_details_on_hover, diff --git a/app/src/workspace/view/vertical_tabs/project_grouping.rs b/app/src/workspace/view/vertical_tabs/project_grouping.rs new file mode 100644 index 000000000..12c672e15 --- /dev/null +++ b/app/src/workspace/view/vertical_tabs/project_grouping.rs @@ -0,0 +1,371 @@ +use crate::code::view::CodeView; +use crate::pane_group::{PaneGroup, PaneId}; +#[cfg(feature = "local_fs")] +use repo_metadata::repositories::DetectedRepositories; +use std::collections::HashMap; +use std::path::PathBuf; +use warpui::AppContext; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) enum ProjectGroupKey { + Root(PathBuf), + Cwd(PathBuf), + // Carries a caller-scoped identity so unresolved tabs do not collapse together. + Unknown(usize), +} + +impl ProjectGroupKey { + pub(super) fn label(&self) -> String { + match self { + ProjectGroupKey::Root(p) | ProjectGroupKey::Cwd(p) => p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| p.to_string_lossy().into_owned()), + ProjectGroupKey::Unknown(_) => "Other".to_string(), + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct ProjectPaneGroup { + pub(super) key: ProjectGroupKey, + pub(super) member_indices: Vec, +} + +pub(super) fn resolve_project_group_for_pane_group( + pane_group: &PaneGroup, + pane_ids: Option<&[PaneId]>, + unknown_id: usize, + app: &AppContext, +) -> ProjectGroupKey { + let mut candidate_paths: Vec = Vec::new(); + let mut push_candidate = |path: PathBuf| { + let path = normalize_project_group_path(path); + if !candidate_paths.contains(&path) { + candidate_paths.push(path); + } + }; + let pane_ids_to_scan = + pane_ids.map_or_else(|| pane_group.visible_pane_ids(), <[PaneId]>::to_vec); + for pane_id in pane_ids_to_scan { + collect_pane_project_paths(pane_group, pane_id, app, &mut push_candidate); + } + + #[cfg(feature = "local_fs")] + { + // Repo-root detection is local-FS only; other builds fall back to normalized paths. + use warpui::SingletonEntity as _; + let detected = DetectedRepositories::as_ref(app); + for path in &candidate_paths { + if let Some(root) = detected.get_root_for_path(path) { + return ProjectGroupKey::Root(root); + } + } + } + + if let Some(p) = candidate_paths.into_iter().next() { + return ProjectGroupKey::Cwd(p); + } + ProjectGroupKey::Unknown(unknown_id) +} + +fn collect_pane_project_paths( + pane_group: &PaneGroup, + pane_id: PaneId, + app: &AppContext, + push_candidate: &mut impl FnMut(PathBuf), +) { + if let Some(view) = pane_group.terminal_view_from_pane_id(pane_id, app) { + let view = view.as_ref(app); + collect_terminal_project_paths(view, app, push_candidate); + } else if let Some(code_pane) = pane_group.code_pane_by_id(pane_id) { + let code_view = code_pane.file_view(app); + let code_view = code_view.as_ref(app); + collect_code_project_paths(code_view, app, push_candidate); + } +} + +fn collect_terminal_project_paths( + view: &crate::terminal::TerminalView, + app: &AppContext, + push_candidate: &mut impl FnMut(PathBuf), +) { + let is_local = view.active_session_is_local(app); + // Unknown locality is not safe for raw path grouping; pwd_if_local still covers confirmed + // local paths while remote sessions are initializing. + if is_local == Some(false) { + return; + } + if is_local == Some(true) { + if let Some(repo_path) = view.current_repo_path() { + push_candidate(repo_path.clone()); + } + } + if let Some(pwd) = view.pwd_if_local(app) { + push_candidate(PathBuf::from(pwd)); + } + if is_local == Some(true) { + if let Some(pwd) = view.pwd() { + push_candidate(PathBuf::from(pwd)); + } + } +} + +fn collect_code_project_paths( + view: &CodeView, + _app: &AppContext, + push_candidate: &mut impl FnMut(PathBuf), +) { + for path in view.local_paths() { + push_candidate( + path.parent() + .map_or_else(|| path.clone(), |parent| parent.to_path_buf()), + ); + } +} + +fn normalize_project_group_path(path: PathBuf) -> PathBuf { + let Some(path_str) = path.to_str() else { + return path; + }; + if let Some(normalized) = + normalize_wsl_drive_mount_path(path_str).or_else(|| normalize_windows_drive_path(path_str)) + { + return PathBuf::from(normalized); + } + path +} + +fn normalize_wsl_drive_mount_path(path: &str) -> Option { + let rest = path.strip_prefix("/mnt/")?; + let mut chars = rest.chars(); + let drive = chars.next()?; + if !drive.is_ascii_alphabetic() || chars.next() != Some('/') { + return None; + } + Some(format!( + "{}:/{}", + drive.to_ascii_lowercase(), + chars.as_str() + )) +} + +fn normalize_windows_drive_path(path: &str) -> Option { + let mut chars = path.chars(); + let drive = chars.next()?; + if !drive.is_ascii_alphabetic() || chars.next() != Some(':') { + return None; + } + let rest = chars.as_str().replace('\\', "/"); + Some(format!("{}:{}", drive.to_ascii_lowercase(), rest)) +} + +pub(super) fn format_project_header_label( + key: &ProjectGroupKey, + tab_count: usize, + pane_count: usize, +) -> String { + let mut out = key.label(); + if tab_count > 1 { + out.push_str(&format!(" · {tab_count} tabs")); + } + if pane_count > tab_count { + out.push_str(&format!(" · {pane_count} panes")); + } + out +} + +pub(super) fn group_by_project_key(n: usize, key_fn: F) -> Vec +where + F: Fn(usize) -> ProjectGroupKey, +{ + let mut order: Vec = Vec::new(); + let mut buckets: HashMap> = HashMap::new(); + for i in 0..n { + let key = key_fn(i); + if !buckets.contains_key(&key) { + order.push(key.clone()); + } + buckets.entry(key).or_default().push(i); + } + order + .into_iter() + .map(|k| { + let v = buckets.remove(&k).unwrap_or_default(); + ProjectPaneGroup { + key: k, + member_indices: v, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root(p: &str) -> ProjectGroupKey { + ProjectGroupKey::Root(PathBuf::from(p)) + } + fn cwd(p: &str) -> ProjectGroupKey { + ProjectGroupKey::Cwd(PathBuf::from(p)) + } + + #[test] + fn pre_resolved_repo_roots_group_together() { + let keys = [ + root("/users/u/REPO/warp"), + root("/users/u/REPO/warp"), + root("/users/u/REPO/warp"), + ]; + let groups = group_by_project_key(keys.len(), |i| keys[i].clone()); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].key, root("/users/u/REPO/warp")); + assert_eq!(groups[0].member_indices, vec![0, 1, 2]); + } + + #[test] + fn unknown_falls_back_cleanly() { + let keys = [ + root("/repo/warp"), + ProjectGroupKey::Unknown(1), + root("/repo/warp"), + ]; + let groups = group_by_project_key(keys.len(), |i| keys[i].clone()); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].key, root("/repo/warp")); + assert_eq!(groups[0].member_indices, vec![0, 2]); + assert_eq!(groups[1].key, ProjectGroupKey::Unknown(1)); + assert_eq!(groups[1].member_indices, vec![1]); + } + + #[test] + fn unknown_tabs_do_not_group_together() { + let keys = [ + ProjectGroupKey::Unknown(0), + ProjectGroupKey::Unknown(1), + ProjectGroupKey::Unknown(2), + ]; + let groups = group_by_project_key(keys.len(), |i| keys[i].clone()); + assert_eq!(groups.len(), 3); + assert_eq!(groups[0].member_indices, vec![0]); + assert_eq!(groups[1].member_indices, vec![1]); + assert_eq!(groups[2].member_indices, vec![2]); + } + + #[test] + fn first_appearance_order_preserved() { + let keys = [cwd("/a"), root("/b"), cwd("/a"), cwd("/c"), root("/b")]; + let groups = group_by_project_key(keys.len(), |i| keys[i].clone()); + assert_eq!(groups.len(), 3); + assert_eq!(groups[0].key, cwd("/a")); + assert_eq!(groups[0].member_indices, vec![0, 2]); + assert_eq!(groups[1].key, root("/b")); + assert_eq!(groups[1].member_indices, vec![1, 4]); + assert_eq!(groups[2].key, cwd("/c")); + assert_eq!(groups[2].member_indices, vec![3]); + } + + #[test] + fn stable_order_within_group() { + let keys = [ + root("/repo/warp"), + cwd("/elsewhere"), + root("/repo/warp"), + root("/repo/warp"), + cwd("/elsewhere"), + ]; + let groups = group_by_project_key(keys.len(), |i| keys[i].clone()); + // Members within each group preserve their original ordering. + assert_eq!(groups[0].key, root("/repo/warp")); + assert_eq!(groups[0].member_indices, vec![0, 2, 3]); + assert_eq!(groups[1].key, cwd("/elsewhere")); + assert_eq!(groups[1].member_indices, vec![1, 4]); + } + + #[test] + fn label_uses_basename() { + assert_eq!(root("/users/u/REPO/warp").label(), "warp"); + assert_eq!(root("/users/u/REPO/JOBBYJOB").label(), "JOBBYJOB"); + assert_eq!(cwd("/tmp/foo").label(), "foo"); + } + + #[test] + fn unknown_label_is_other_and_no_detail() { + assert_eq!(ProjectGroupKey::Unknown(0).label(), "Other"); + } + + #[test] + fn header_label_single_tab_single_pane_is_compact() { + // No counts when there is nothing extra to communicate. + assert_eq!(format_project_header_label(&root("/r/warp"), 1, 1), "warp"); + assert_eq!( + format_project_header_label(&ProjectGroupKey::Unknown(0), 1, 1), + "Other" + ); + } + + #[test] + fn header_label_includes_tab_count_when_multi_tab() { + assert_eq!( + format_project_header_label(&root("/r/warp"), 2, 2), + "warp · 2 tabs" + ); + assert_eq!( + format_project_header_label(&root("/r/warp"), 5, 5), + "warp · 5 tabs" + ); + } + + #[test] + fn header_label_includes_pane_count_when_panes_exceed_tabs() { + // Single multi-pane tab in the project: "warp · 2 panes". + assert_eq!( + format_project_header_label(&root("/r/warp"), 1, 2), + "warp · 2 panes" + ); + // Three tabs, one of which has two panes -> 4 panes total. + assert_eq!( + format_project_header_label(&root("/r/warp"), 3, 4), + "warp · 3 tabs · 4 panes" + ); + } + + #[test] + fn header_label_omits_pane_count_when_equal_to_tab_count() { + // 2 tabs / 2 panes -> redundant, so pane count is omitted. + assert_eq!( + format_project_header_label(&root("/r/warp"), 2, 2), + "warp · 2 tabs" + ); + } + + #[test] + fn normalizes_wsl_drive_mount_paths() { + assert_eq!( + normalize_project_group_path(PathBuf::from("/mnt/c/repo/foo")), + PathBuf::from("c:/repo/foo") + ); + assert_eq!( + normalize_project_group_path(PathBuf::from("/mnt/C/repo/foo")), + PathBuf::from("c:/repo/foo") + ); + } + + #[test] + fn normalizes_windows_drive_paths() { + assert_eq!( + normalize_project_group_path(PathBuf::from(r"C:\repo\foo")), + PathBuf::from("c:/repo/foo") + ); + } + + #[test] + fn leaves_non_drive_wsl_paths_alone() { + assert_eq!( + normalize_project_group_path(PathBuf::from("/home/user/repo")), + PathBuf::from("/home/user/repo") + ); + } +} diff --git a/app/src/workspace/view/vertical_tabs/telemetry.rs b/app/src/workspace/view/vertical_tabs/telemetry.rs index 85ec3432e..4a9b66dbf 100644 --- a/app/src/workspace/view/vertical_tabs/telemetry.rs +++ b/app/src/workspace/view/vertical_tabs/telemetry.rs @@ -19,6 +19,7 @@ pub enum VerticalTabsDisplayOption { CompactSubtitle(VerticalTabsCompactSubtitle), ShowPrLink(bool), ShowDiffStats(bool), + GroupByProject(bool), ShowDetailsOnHover(bool), } @@ -32,6 +33,7 @@ impl VerticalTabsDisplayOption { Self::CompactSubtitle(_) => "compact_subtitle", Self::ShowPrLink(_) => "show_pr_link", Self::ShowDiffStats(_) => "show_diff_stats", + Self::GroupByProject(_) => "group_by_project", Self::ShowDetailsOnHover(_) => "show_details_on_hover", } } @@ -56,6 +58,7 @@ impl VerticalTabsDisplayOption { Self::CompactSubtitle(VerticalTabsCompactSubtitle::Command) => json!("command"), Self::ShowPrLink(value) => json!(value), Self::ShowDiffStats(value) => json!(value), + Self::GroupByProject(value) => json!(value), Self::ShowDetailsOnHover(value) => json!(value), } } diff --git a/app/src/workspace/view/vertical_tabs_tests.rs b/app/src/workspace/view/vertical_tabs_tests.rs index f5e9435e7..334ddf00a 100644 --- a/app/src/workspace/view/vertical_tabs_tests.rs +++ b/app/src/workspace/view/vertical_tabs_tests.rs @@ -12,13 +12,14 @@ use warpui::EntityId; use super::{ branch_label_display, coalesce_summary_branch_entries, code_detail_kind_label, - compact_branch_subtitle_display, detail_sidecar_width_and_bounds, - detail_target_for_hovered_row, format_summary_primary_labels, - non_terminal_search_text_fragments, pane_ids_for_display_granularity, - pane_search_text_fragments, preferred_agent_tab_titles, search_fragments_contain_query, - select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position, - summary_overflow_count, summary_search_text_fragments, terminal_kind_badge_label, - terminal_primary_line_data, terminal_pull_request_badge_label, terminal_search_text_fragments, + compact_branch_subtitle_display, complete_visual_tab_order, detail_sidecar_width_and_bounds, + detail_target_for_hovered_row, format_summary_primary_labels, grouped_insert_after_index, + grouped_insert_before_index, non_terminal_search_text_fragments, + pane_ids_for_display_granularity, pane_search_text_fragments, preferred_agent_tab_titles, + search_fragments_contain_query, select_summary_pane_kind_icons, + should_keep_detail_sidecar_visible_for_mouse_position, summary_overflow_count, + summary_search_text_fragments, terminal_kind_badge_label, terminal_primary_line_data, + terminal_pull_request_badge_label, terminal_search_text_fragments, terminal_title_fallback_font, uses_outer_group_container, visible_pane_ids_for_detail_target, vtab_diff_stats_text, AgentTabTextPreference, SummaryPaneKind, SummaryPaneKindIcons, TerminalAgentText, TerminalPrimaryLineData, TerminalPrimaryLineFont, VerticalTabsDetailTarget, @@ -34,6 +35,35 @@ fn code_summary_kind(title: &str) -> SummaryPaneKind { } } +#[test] +fn grouped_insert_before_uses_visual_position_when_available() { + assert_eq!(grouped_insert_before_index(Some(1), 3), 1); +} + +#[test] +fn grouped_insert_before_falls_back_to_tab_index_without_visual_position() { + assert_eq!(grouped_insert_before_index(None, 3), 3); +} + +#[test] +fn grouped_insert_after_uses_visual_tab_count_for_last_visual_tab() { + assert_eq!(grouped_insert_after_index(4, true), Some(4)); +} + +#[test] +fn grouped_insert_after_is_absent_for_non_last_visual_tab() { + assert_eq!(grouped_insert_after_index(4, false), None); +} + +#[test] +fn complete_visual_tab_order_returns_order_only_when_all_tabs_are_visible() { + assert_eq!( + complete_visual_tab_order(&[0, 2, 1], 3), + Some(vec![0, 2, 1]) + ); + assert_eq!(complete_visual_tab_order(&[0, 2], 3), None); +} + #[test] fn summary_pane_kind_icons_render_single_icon_for_homogeneous_tabs() { assert_eq!( diff --git a/app/src/workspace/view_test.rs b/app/src/workspace/view_test.rs index 0349a73ff..62dcd3b35 100644 --- a/app/src/workspace/view_test.rs +++ b/app/src/workspace/view_test.rs @@ -3013,3 +3013,57 @@ fn test_open_cloud_agent_setup_guide_action_opens_management_view_and_is_idempot }); }); } + +#[test] +fn reordered_group_indices_moves_current_above_target() { + assert_eq!( + reordered_group_indices(&[0, 2, 4], 4, 2, TabDragDirection::Above), + vec![0, 4, 2] + ); +} + +#[test] +fn reordered_group_indices_moves_current_below_target() { + assert_eq!( + reordered_group_indices(&[0, 2, 4], 0, 2, TabDragDirection::Below), + vec![2, 0, 4] + ); +} + +#[test] +fn reordered_group_indices_appends_when_target_is_missing() { + assert_eq!( + reordered_group_indices(&[0, 2, 4], 2, 9, TabDragDirection::Below), + vec![0, 4, 2] + ); +} + +#[test] +fn is_valid_tab_permutation_accepts_complete_unique_order() { + assert!(is_valid_tab_permutation(&[0, 2, 1], 3)); +} + +#[test] +fn is_valid_tab_permutation_rejects_filtered_or_duplicate_order() { + assert!(!is_valid_tab_permutation(&[0, 2], 3)); + assert!(!is_valid_tab_permutation(&[0, 1, 1], 3)); + assert!(!is_valid_tab_permutation(&[0, 1, 3], 3)); +} + +#[test] +fn visual_tab_order_rewrite_preserves_active_tab_identity() { + assert_eq!( + visual_tab_order_rewrite(&[0, 2, 1], 3, 1), + Some((vec![0, 2, 1], 2)) + ); + assert_eq!( + visual_tab_order_rewrite(&[0, 2, 1], 3, 2), + Some((vec![0, 2, 1], 1)) + ); +} + +#[test] +fn visual_tab_order_rewrite_rejects_invalid_order() { + assert_eq!(visual_tab_order_rewrite(&[0, 2], 3, 1), None); + assert_eq!(visual_tab_order_rewrite(&[0, 1, 1], 3, 1), None); +}