Files
galaxy/app/src/workspace/view/vertical_tabs.rs
T

5997 lines
211 KiB
Rust

pub mod telemetry;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent_management::AgentNotificationsModel;
use crate::code::editor::{add_color, remove_color};
use crate::code::icon_from_file_path;
use crate::safe_triangle::SafeTriangle;
use crate::send_telemetry_from_app_ctx;
use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::view::TerminalViewState;
use crate::terminal::CLIAgent;
use crate::ui_components::icon_with_status::{
render_icon_with_status, IconWithStatusSizing, IconWithStatusVariant,
};
use crate::workspace::view::vertical_tabs::telemetry::{
VerticalTabsChipEntrypoint, VerticalTabsTelemetryEvent,
};
use crate::FeatureFlag;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::appearance::Appearance;
use crate::context_chips::display_chip::GitLineChanges;
use crate::context_chips::github_pr_display_text_from_url;
use crate::drive::{cloud_object_styling::warp_drive_icon_color, DriveObjectType};
use crate::editor::EditorView;
use crate::pane_group::pane::IPaneType;
use crate::pane_group::TerminalPane;
use crate::pane_group::{
CodePane, NotebookPane, PaneGroup, PaneId, TabBarHoverIndex, WorkflowPane,
};
use crate::tab::{tab_position_id, SelectedTabColor, TabData};
use crate::terminal::session_settings::SessionSettings;
use crate::terminal::TerminalView;
use crate::themes::theme::Fill as ThemeFill;
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::hoa_onboarding::HoaOnboardingStep;
use crate::workspace::tab_settings::{
TabSettings, VerticalTabsCompactSubtitle, VerticalTabsDisplayGranularity,
VerticalTabsPrimaryInfo, VerticalTabsTabItemMode, VerticalTabsViewMode,
};
use crate::workspace::{
PaneViewLocator, TabBarLocation, TabContextMenuAnchor, VerticalTabsPaneContextMenuTarget,
VerticalTabsPaneDropTargetData, Workspace,
};
use languages::language_by_filename;
use galaxy_core::context_flag::ContextFlag;
use galaxy_core::telemetry::TelemetryEvent as _;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::{AnsiColorIdentifier, Fill as WarpThemeFill, WarpTheme};
use galaxy_core::ui::Icon as WarpIcon;
use galaxyui::elements::DispatchEventResult;
use galaxyui::elements::{
resizable_state_handle, Border, ChildAnchor, Clipped, ClippedScrollStateHandle,
ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragAxis,
DragBarSide, Draggable, DropShadow, DropTarget, Element, Empty, EventHandler, Expanded,
Fill as ElementFill, Flex, Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning,
Padding, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, Resizable, ResizableStateHandle, SavePosition,
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::prelude::{Align, MainAxisAlignment};
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::ui_components::text_input::TextInput;
use galaxyui::{AppContext, EntityId, SingletonEntity, ViewHandle, WindowId};
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use settings::Setting as _;
use std::path::{Path, PathBuf};
const PANEL_WIDTH: f32 = 248.;
const MIN_PANEL_WIDTH: f32 = 200.;
const MAX_PANEL_WIDTH_RATIO: f32 = 0.5;
const DETAIL_SIDECAR_SECTION_PADDING: f32 = 12.;
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 GROUP_ITEM_SPACING: f32 = 4.;
const TABS_MODE_ITEM_SPACING: f32 = 4.;
const GROUP_ACTION_BUTTON_ICON_SIZE: f32 = 12.;
const GROUP_ACTION_BUTTON_PADDING: f32 = 2.;
const GROUP_ACTION_BUTTON_GAP: f32 = 2.;
const ROW_CORNER_RADIUS: f32 = 4.;
const BADGE_ICON_SIZE: f32 = 12.;
const DETAIL_SIDECAR_DEFAULT_WIDTH: f32 = 320.;
const DETAIL_SIDECAR_MIN_WIDTH: f32 = 240.;
const DETAIL_SIDECAR_CORNER_RADIUS: f32 = 4.;
/// Fixed height of the metadata row (line 3 in expanded mode). Matches the passive badge height
/// so the row doesn't resize when badges are toggled.
const METADATA_ROW_HEIGHT: f32 = BADGE_ICON_SIZE + 2.;
const TAB_COLOR_OPACITY: Opacity = 15;
const TAB_COLOR_HOVER_OPACITY: Opacity = 50;
// Circular icon constants
const ICON_WITH_STATUS_GAP: f32 = 8.;
pub(super) const VERTICAL_TABS_DETAIL_SIDECAR_POSITION_ID: &str = "vertical_tabs:detail_sidecar";
const VERTICAL_TABS_STATUS_BADGE_ICON_SIZE: f32 = 9.;
const VERTICAL_TABS_STATUS_BADGE_PADDING: f32 = 1.5;
const VERTICAL_TABS_STATUS_BADGE_OFFSET: (f32, f32) = (2., 2.);
const VERTICAL_TABS_SIZING: IconWithStatusSizing = IconWithStatusSizing {
icon_size: 16.,
padding: 4.,
badge_icon_size: VERTICAL_TABS_STATUS_BADGE_ICON_SIZE,
badge_padding: VERTICAL_TABS_STATUS_BADGE_PADDING,
overall_size_override: None,
badge_offset: VERTICAL_TABS_STATUS_BADGE_OFFSET,
};
const VERTICAL_TABS_AGENT_SIZING: IconWithStatusSizing = IconWithStatusSizing {
icon_size: 10.,
padding: 5.,
badge_icon_size: VERTICAL_TABS_STATUS_BADGE_ICON_SIZE,
badge_padding: VERTICAL_TABS_STATUS_BADGE_PADDING,
overall_size_override: Some(24.),
badge_offset: VERTICAL_TABS_STATUS_BADGE_OFFSET,
};
fn vtab_pane_row_position_id(pane_group_id: EntityId, pane_id: PaneId) -> String {
format!("vertical_tabs:pane_row:{pane_group_id:?}:{pane_id}")
}
fn terminal_title_fallback_font(agent_text: &TerminalAgentText) -> TerminalPrimaryLineFont {
if agent_text.cli_agent.is_some() {
TerminalPrimaryLineFont::Ui
} else {
TerminalPrimaryLineFont::Monospace
}
}
fn supports_vertical_tabs_detail_sidecar(typed: &TypedPane<'_>) -> bool {
typed.supports_vertical_tabs_detail_sidecar()
}
fn detail_target_for_hovered_row(
pane_group_id: EntityId,
pane_id: PaneId,
granularity: VerticalTabsDisplayGranularity,
) -> VerticalTabsDetailTarget {
match granularity {
VerticalTabsDisplayGranularity::Panes => VerticalTabsDetailTarget::Pane {
pane_group_id,
pane_id,
},
VerticalTabsDisplayGranularity::Tabs => VerticalTabsDetailTarget::Tab {
pane_group_id,
source_pane_id: pane_id,
},
}
}
fn detail_target_kind(target: VerticalTabsDetailTarget) -> VerticalTabsDetailTargetKind {
match target {
VerticalTabsDetailTarget::Pane { .. } => VerticalTabsDetailTargetKind::Pane,
VerticalTabsDetailTarget::Tab { .. } => VerticalTabsDetailTargetKind::Tab,
}
}
/// Returns whether the current pointer geometry still justifies keeping the vertical-tabs detail
/// sidecar visible, independent of potentially stale element-local hover state.
fn should_keep_detail_sidecar_visible_for_mouse_position(
position: Vector2F,
row_rect: Option<RectF>,
sidecar_rect: Option<RectF>,
safe_triangle: &mut SafeTriangle,
) -> bool {
safe_triangle.set_target_rect(sidecar_rect);
if row_rect.is_some_and(|rect| rect.contains_point(position)) {
safe_triangle.update_position(position);
return true;
}
let Some(sidecar_rect) = sidecar_rect else {
safe_triangle.update_position(position);
return true;
};
if sidecar_rect.contains_point(position) {
safe_triangle.update_position(position);
return true;
}
let suppress_hover = safe_triangle.should_suppress_hover(position);
safe_triangle.update_position(position);
suppress_hover
}
fn visible_pane_ids_for_detail_target<F>(
visible_pane_ids: &[PaneId],
source_pane_id: PaneId,
target_kind: VerticalTabsDetailTargetKind,
mut is_supported: F,
) -> Option<Vec<PaneId>>
where
F: FnMut(PaneId) -> bool,
{
if !visible_pane_ids.contains(&source_pane_id) {
return None;
}
match target_kind {
VerticalTabsDetailTargetKind::Pane => {
is_supported(source_pane_id).then_some(vec![source_pane_id])
}
VerticalTabsDetailTargetKind::Tab => visible_pane_ids
.iter()
.copied()
.all(&mut is_supported)
.then(|| visible_pane_ids.to_vec()),
}
}
fn pane_ids_for_detail_target(
pane_group: &PaneGroup,
target: VerticalTabsDetailTarget,
app: &AppContext,
) -> Option<Vec<PaneId>> {
let visible_pane_ids = pane_group.visible_pane_ids();
visible_pane_ids_for_detail_target(
&visible_pane_ids,
target.source_pane_id(),
detail_target_kind(target),
|pane_id| {
pane_group
.pane_by_id(pane_id)
.map(|_| {
supports_vertical_tabs_detail_sidecar(
&pane_group.resolve_pane_type(pane_id, app),
)
})
.unwrap_or(false)
},
)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TerminalPrimaryLineFont {
Ui,
Monospace,
}
fn oz_icon_fill(theme: &WarpTheme) -> WarpThemeFill {
theme.main_text_color(theme.background())
}
fn render_pane_icon_with_status(
variant: IconWithStatusVariant,
theme: &WarpTheme,
) -> Box<dyn Element> {
let sizing = match &variant {
IconWithStatusVariant::OzAgent { .. } => &VERTICAL_TABS_AGENT_SIZING,
IconWithStatusVariant::CLIAgent { status, .. } if status.is_some() => {
&VERTICAL_TABS_AGENT_SIZING
}
_ => &VERTICAL_TABS_SIZING,
};
render_icon_with_status(variant, sizing, theme, theme.background())
}
#[derive(Clone, Default)]
struct PaneGroupStateHandles {
group: MouseStateHandle,
header: MouseStateHandle,
kebab: MouseStateHandle,
close: MouseStateHandle,
action_buttons: MouseStateHandle,
}
fn pane_row_background(
pane_color: Option<ThemeFill>,
is_selected: bool,
is_hovered: bool,
is_being_dragged: bool,
theme: &WarpTheme,
) -> Option<ThemeFill> {
if let Some(color) = pane_color {
let opacity = if is_selected || is_hovered {
TAB_COLOR_HOVER_OPACITY
} else {
TAB_COLOR_OPACITY
};
Some(color.with_opacity(opacity))
} else if is_selected {
Some(internal_colors::fg_overlay_2(theme))
} else if is_being_dragged || is_hovered {
Some(internal_colors::fg_overlay_1(theme))
} else {
None
}
}
fn render_pane_row_element(
props: PaneProps<'_>,
padding: Padding,
defer_events_to_children: bool,
content: Box<dyn Element>,
theme: &WarpTheme,
) -> Box<dyn Element> {
let detail_target = supports_vertical_tabs_detail_sidecar(&props.typed).then(|| {
detail_target_for_hovered_row(
props.pane_group_id,
props.pane_id,
props.display_granularity,
)
});
let row_position_id = vtab_pane_row_position_id(props.pane_group_id, props.pane_id);
let PaneProps {
pane_id,
pane_group_id,
is_active_tab,
mouse_state,
title_mouse_state: _,
title: _,
subtitle: _,
custom_vertical_tabs_title: _,
display_title_override: _,
is_focused,
typed: _,
is_being_dragged,
pane_color,
badge_mouse_states: _,
detail_hover_state,
display_granularity: _,
renamable_tab_index,
pane_context_menu_tab_index,
is_tab_being_renamed,
rename_editor: _,
is_pane_being_renamed,
pane_rename_editor: _,
} = props;
let is_selected = is_active_tab && is_focused;
let mut row = Hoverable::new(mouse_state, move |state| {
let mut container = Container::new(Clipped::new(content).finish())
.with_padding(padding)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ROW_CORNER_RADIUS)));
if let Some(background) = pane_row_background(
pane_color,
is_selected,
state.is_hovered(),
is_being_dragged,
theme,
) {
container = container.with_background(background);
}
container
.with_border(Border::all(1.).with_border_fill(if is_selected {
internal_colors::fg_overlay_3(theme).into()
} else {
ElementFill::None
}))
.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::FocusPane(PaneViewLocator {
pane_group_id,
pane_id,
}));
})
.on_hover(move |is_hovered, ctx, app, position| {
let show_details_on_hover = *TabSettings::as_ref(app)
.vertical_tabs_show_details_on_hover
.value();
let mut overlay_state = detail_hover_state
.overlay_state
.lock()
.expect("vertical tabs detail overlay lock poisoned");
if !show_details_on_hover {
if overlay_state.active_target.is_some() {
overlay_state.active_target = None;
overlay_state.safe_triangle.set_target_rect(None);
ctx.notify();
}
return;
}
let sidecar_rect = app.element_position_by_id_at_last_frame(
detail_hover_state.window_id,
VERTICAL_TABS_DETAIL_SIDECAR_POSITION_ID,
);
let sidecar_hovered = detail_hover_state
.sidecar_mouse_state
.lock()
.expect("detail sidecar hover state lock poisoned")
.is_mouse_over_element();
overlay_state.safe_triangle.set_target_rect(sidecar_rect);
let suppress_hover = overlay_state.safe_triangle.should_suppress_hover(position);
overlay_state.safe_triangle.update_position(position);
let mut changed = false;
if is_hovered {
if !suppress_hover && overlay_state.active_target != detail_target {
overlay_state.active_target = detail_target;
if detail_target.is_none() {
overlay_state.safe_triangle.set_target_rect(None);
}
changed = true;
}
} else if !suppress_hover
&& !sidecar_hovered
&& overlay_state.active_target == detail_target
{
overlay_state.active_target = None;
overlay_state.safe_triangle.set_target_rect(None);
changed = true;
}
if changed {
ctx.notify();
}
})
.with_skip_synthetic_hover_out()
.with_cursor(Cursor::PointingHand);
if let Some(tab_index) = renamable_tab_index.filter(|_| !is_tab_being_renamed) {
row = row.on_double_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::RenameTab(tab_index));
});
}
let pane_locator = PaneViewLocator {
pane_group_id,
pane_id,
};
if pane_context_menu_tab_index.is_some() && !is_pane_being_renamed {
row = row.on_double_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::RenamePane(pane_locator));
});
}
if let Some(tab_index) = pane_context_menu_tab_index {
row = row.on_right_click(move |ctx, _, position| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleVerticalTabsPaneContextMenu {
tab_index,
target: VerticalTabsPaneContextMenuTarget::ClickedPane(pane_locator),
position,
});
});
}
if defer_events_to_children {
row = row.with_defer_events_to_children();
}
SavePosition::new(row.finish(), &row_position_id).finish()
}
#[derive(Clone, Default)]
struct PaneRowBadgeMouseStates {
diff_stats: MouseStateHandle,
pull_request: MouseStateHandle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VerticalTabsDetailTarget {
Pane {
pane_group_id: EntityId,
pane_id: PaneId,
},
Tab {
pane_group_id: EntityId,
source_pane_id: PaneId,
},
}
impl VerticalTabsDetailTarget {
fn pane_group_id(&self) -> EntityId {
match self {
Self::Pane { pane_group_id, .. } | Self::Tab { pane_group_id, .. } => *pane_group_id,
}
}
fn source_pane_id(&self) -> PaneId {
match self {
Self::Pane { pane_id, .. } => *pane_id,
Self::Tab { source_pane_id, .. } => *source_pane_id,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VerticalTabsDetailTargetKind {
Pane,
Tab,
}
struct VerticalTabsDetailOverlayState {
active_target: Option<VerticalTabsDetailTarget>,
safe_triangle: SafeTriangle,
}
impl Default for VerticalTabsDetailOverlayState {
fn default() -> Self {
Self {
active_target: None,
safe_triangle: SafeTriangle::new(),
}
}
}
#[derive(Clone)]
pub(super) struct VerticalTabsDetailHoverState {
overlay_state: Arc<Mutex<VerticalTabsDetailOverlayState>>,
sidecar_mouse_state: MouseStateHandle,
window_id: WindowId,
}
impl VerticalTabsDetailHoverState {
pub(super) fn reconcile_visibility_for_mouse_position(
&self,
position: Vector2F,
app: &AppContext,
) -> bool {
let mut overlay_state = self
.overlay_state
.lock()
.expect("vertical tabs detail overlay lock poisoned");
let Some(active_target) = overlay_state.active_target else {
return false;
};
let row_rect = app.element_position_by_id_at_last_frame(
self.window_id,
vtab_pane_row_position_id(
active_target.pane_group_id(),
active_target.source_pane_id(),
),
);
let sidecar_rect = app.element_position_by_id_at_last_frame(
self.window_id,
VERTICAL_TABS_DETAIL_SIDECAR_POSITION_ID,
);
if should_keep_detail_sidecar_visible_for_mouse_position(
position,
row_rect,
sidecar_rect,
&mut overlay_state.safe_triangle,
) {
return false;
}
overlay_state.active_target = None;
overlay_state.safe_triangle.set_target_rect(None);
drop(overlay_state);
if let Ok(mut mouse_state) = self.sidecar_mouse_state.lock() {
mouse_state.reset_interaction_state();
}
true
}
}
pub(super) struct VerticalTabsPanelState {
scroll_state: ClippedScrollStateHandle,
resizable_state: ResizableStateHandle,
group_mouse_states: RefCell<HashMap<EntityId, PaneGroupStateHandles>>,
pane_row_mouse_states: RefCell<HashMap<PaneId, MouseStateHandle>>,
pane_title_mouse_states: RefCell<HashMap<PaneId, MouseStateHandle>>,
pane_badge_mouse_states: RefCell<HashMap<PaneId, PaneRowBadgeMouseStates>>,
detail_pane_badge_mouse_states: RefCell<HashMap<PaneId, PaneRowBadgeMouseStates>>,
detail_scroll_state: ClippedScrollStateHandle,
detail_sidecar_mouse_state: MouseStateHandle,
detail_overlay_state: Arc<Mutex<VerticalTabsDetailOverlayState>>,
new_tab_hover_state: MouseStateHandle,
new_tab_button_state: MouseStateHandle,
pub(super) search_query: String,
settings_button_mouse_state: MouseStateHandle,
panes_segment_mouse_state: MouseStateHandle,
tabs_segment_mouse_state: MouseStateHandle,
focused_session_option_mouse_state: MouseStateHandle,
summary_option_mouse_state: MouseStateHandle,
compact_segment_mouse_state: MouseStateHandle,
expanded_segment_mouse_state: MouseStateHandle,
command_option_mouse_state: MouseStateHandle,
directory_option_mouse_state: MouseStateHandle,
branch_option_mouse_state: MouseStateHandle,
subtitle_option_1_mouse_state: MouseStateHandle,
subtitle_option_2_mouse_state: MouseStateHandle,
show_pr_link_mouse_state: MouseStateHandle,
show_pr_link_info_tooltip_mouse_state: MouseStateHandle,
show_diff_stats_mouse_state: MouseStateHandle,
show_details_on_hover_mouse_state: MouseStateHandle,
pub(super) show_settings_popup: bool,
}
impl Default for VerticalTabsPanelState {
fn default() -> Self {
Self {
scroll_state: ClippedScrollStateHandle::default(),
resizable_state: resizable_state_handle(PANEL_WIDTH),
group_mouse_states: RefCell::default(),
pane_row_mouse_states: RefCell::default(),
pane_title_mouse_states: RefCell::default(),
pane_badge_mouse_states: RefCell::default(),
detail_pane_badge_mouse_states: RefCell::default(),
detail_scroll_state: ClippedScrollStateHandle::default(),
detail_sidecar_mouse_state: Default::default(),
detail_overlay_state: Arc::new(Mutex::new(VerticalTabsDetailOverlayState::default())),
new_tab_hover_state: Default::default(),
new_tab_button_state: Default::default(),
search_query: String::new(),
settings_button_mouse_state: Default::default(),
panes_segment_mouse_state: Default::default(),
tabs_segment_mouse_state: Default::default(),
focused_session_option_mouse_state: Default::default(),
summary_option_mouse_state: Default::default(),
compact_segment_mouse_state: Default::default(),
expanded_segment_mouse_state: Default::default(),
command_option_mouse_state: Default::default(),
directory_option_mouse_state: Default::default(),
branch_option_mouse_state: Default::default(),
subtitle_option_1_mouse_state: Default::default(),
subtitle_option_2_mouse_state: Default::default(),
show_pr_link_mouse_state: Default::default(),
show_pr_link_info_tooltip_mouse_state: Default::default(),
show_diff_stats_mouse_state: Default::default(),
show_details_on_hover_mouse_state: Default::default(),
show_settings_popup: false,
}
}
}
impl VerticalTabsPanelState {
/// Returns a lightweight handle bundle for workspace-level visibility reconciliation while the
/// detail sidecar is active.
pub(super) fn detail_hover_state(&self, window_id: WindowId) -> VerticalTabsDetailHoverState {
VerticalTabsDetailHoverState {
overlay_state: self.detail_overlay_state.clone(),
sidecar_mouse_state: self.detail_sidecar_mouse_state.clone(),
window_id,
}
}
pub(super) fn has_active_detail_target(&self) -> bool {
self.detail_overlay_state
.lock()
.map(|overlay_state| overlay_state.active_target.is_some())
.unwrap_or(false)
}
pub(super) fn clear_detail_sidecar(&self) {
if let Ok(mut overlay_state) = self.detail_overlay_state.lock() {
overlay_state.active_target = None;
overlay_state.safe_triangle.set_target_rect(None);
}
if let Ok(mut mouse_state) = self.detail_sidecar_mouse_state.lock() {
mouse_state.reset_interaction_state();
}
}
/// Clears the detail sidecar only if it is currently anchored to a pane row in the given
/// pane group. Used when a tab (and therefore its pane rows) is about to go away so the
/// sidecar doesn't try to position itself against a missing anchor on the next render.
pub(super) fn clear_detail_sidecar_if_for_pane_group(&self, pane_group_id: EntityId) {
let matches = self
.detail_overlay_state
.lock()
.map(|overlay_state| {
overlay_state
.active_target
.is_some_and(|target| target.pane_group_id() == pane_group_id)
})
.unwrap_or(false);
if matches {
self.clear_detail_sidecar();
}
}
}
struct PaneProps<'a> {
pane_id: PaneId,
pane_group_id: EntityId,
is_active_tab: bool,
mouse_state: MouseStateHandle,
title_mouse_state: Option<MouseStateHandle>,
title: String,
subtitle: String,
custom_vertical_tabs_title: Option<String>,
display_title_override: Option<String>,
is_focused: bool,
typed: TypedPane<'a>,
is_being_dragged: bool,
pane_color: Option<ThemeFill>,
badge_mouse_states: PaneRowBadgeMouseStates,
detail_hover_state: VerticalTabsDetailHoverState,
display_granularity: VerticalTabsDisplayGranularity,
renamable_tab_index: Option<usize>,
pane_context_menu_tab_index: Option<usize>,
is_tab_being_renamed: bool,
rename_editor: Option<ViewHandle<EditorView>>,
is_pane_being_renamed: bool,
pane_rename_editor: Option<ViewHandle<EditorView>>,
}
struct PaneRowState {
mouse_state: MouseStateHandle,
title_mouse_state: Option<MouseStateHandle>,
pane_color: Option<ThemeFill>,
badge_mouse_states: PaneRowBadgeMouseStates,
}
enum TerminalPrimaryLineData {
StatusText {
text: String,
},
Text {
text: String,
font: TerminalPrimaryLineFont,
},
}
impl TerminalPrimaryLineData {
fn text(&self) -> &str {
match self {
TerminalPrimaryLineData::StatusText { text, .. }
| TerminalPrimaryLineData::Text { text, .. } => text,
}
}
}
enum TabGroupColorMode {
Uniform(ThemeFill),
PerPane(HashMap<PaneId, Option<ThemeFill>>),
None,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum VerticalTabsResolvedMode {
Panes,
FocusedSession,
Summary,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum SummaryPaneKind {
Terminal,
OzAgent { is_ambient: bool },
CLIAgent { agent: CLIAgent },
Code { title: String },
CodeDiff,
File,
Notebook { is_plan: bool },
Workflow { is_ai_prompt: bool },
Settings,
EnvVarCollection,
EnvironmentManagement,
AIFact,
AIDocument,
ExecutionProfileEditor,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum SummaryPaneKindIcons {
Single(SummaryPaneKind),
Pair {
primary: SummaryPaneKind,
secondary: SummaryPaneKind,
},
}
#[derive(Clone, Debug, PartialEq)]
struct VerticalTabsSummaryBranchEntry {
repo_path: PathBuf,
branch_name: String,
diff_stats: Option<GitLineChanges>,
pull_request_label: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
struct VerticalTabsSummaryData {
primary_labels: Vec<String>,
working_directories: Vec<String>,
branch_entries: Vec<VerticalTabsSummaryBranchEntry>,
}
impl TabGroupColorMode {
fn into_per_pane_colors(
self,
visible_pane_ids: &[PaneId],
) -> Option<HashMap<PaneId, Option<ThemeFill>>> {
match self {
TabGroupColorMode::PerPane(map) => Some(map),
TabGroupColorMode::Uniform(fill) => Some(
visible_pane_ids
.iter()
.map(|&id| (id, Some(fill)))
.collect(),
),
TabGroupColorMode::None => None,
}
}
}
struct GroupHeaderProps<'a> {
tab_index: usize,
pane_group: &'a PaneGroup,
is_being_renamed: bool,
rename_editor: ViewHandle<EditorView>,
header_mouse_state: MouseStateHandle,
}
#[derive(Clone, Copy)]
struct TabGroupDragState {
is_any_pane_dragging: bool,
insert_before_index: usize,
insert_after_index: Option<usize>,
}
fn resolve_vertical_tabs_mode(app: &AppContext) -> VerticalTabsResolvedMode {
let settings = TabSettings::as_ref(app);
match *settings.vertical_tabs_display_granularity.value() {
VerticalTabsDisplayGranularity::Panes => VerticalTabsResolvedMode::Panes,
VerticalTabsDisplayGranularity::Tabs => match *settings.vertical_tabs_tab_item_mode.value()
{
VerticalTabsTabItemMode::FocusedSession => VerticalTabsResolvedMode::FocusedSession,
VerticalTabsTabItemMode::Summary => {
if FeatureFlag::VerticalTabsSummaryMode.is_enabled() {
VerticalTabsResolvedMode::Summary
} else {
VerticalTabsResolvedMode::FocusedSession
}
}
},
}
}
fn push_normalized_unique_summary_text(
values: &mut Vec<String>,
seen: &mut HashMap<String, ()>,
text: &str,
) {
let Some(normalized) = normalize_summary_text(text) else {
return;
};
if seen.contains_key(&normalized) {
return;
}
seen.insert(normalized.clone(), ());
values.push(normalized);
}
fn normalize_summary_text(text: &str) -> Option<String> {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.is_empty()).then_some(normalized)
}
fn coalesce_summary_branch_entries(
entries: Vec<VerticalTabsSummaryBranchEntry>,
) -> Vec<VerticalTabsSummaryBranchEntry> {
let mut coalesced: Vec<VerticalTabsSummaryBranchEntry> = Vec::new();
let mut indices: HashMap<(PathBuf, String), usize> = HashMap::new();
for entry in entries {
let key = (entry.repo_path.clone(), entry.branch_name.clone());
if let Some(index) = indices.get(&key).copied() {
let existing = &mut coalesced[index];
if existing.diff_stats.is_none() {
existing.diff_stats = entry.diff_stats;
}
if existing.pull_request_label.is_none() {
existing.pull_request_label = entry.pull_request_label;
}
} else {
indices.insert(key, coalesced.len());
coalesced.push(entry);
}
}
coalesced
}
fn summary_overflow_count(total_count: usize, visible_limit: usize) -> usize {
total_count.saturating_sub(visible_limit)
}
fn format_summary_primary_labels(labels: &[String], visible_limit: usize) -> Option<String> {
const SEPARATOR: &str = " • ";
if labels.is_empty() {
return None;
}
let visible_count = labels.len().min(visible_limit);
let mut rendered = labels[..visible_count].join(SEPARATOR);
let overflow_count = summary_overflow_count(labels.len(), visible_limit);
if overflow_count > 0 {
rendered.push_str(&format!(" + {overflow_count} more"));
}
Some(rendered)
}
fn summary_search_text_fragments(
summary: &VerticalTabsSummaryData,
title_override: Option<&str>,
) -> Vec<String> {
let mut fragments = Vec::new();
if let Some(title_override) = title_override.and_then(normalize_summary_text) {
fragments.push(title_override);
}
fragments.extend(summary.primary_labels.iter().cloned());
fragments.extend(summary.working_directories.iter().cloned());
for entry in &summary.branch_entries {
fragments.push(entry.branch_name.clone());
if let Some(pull_request_label) = &entry.pull_request_label {
fragments.push(pull_request_label.clone());
}
if let Some(diff_stats) = &entry.diff_stats {
fragments.push(vtab_diff_stats_text(diff_stats));
}
}
fragments
}
fn select_summary_pane_kind_icons(
pane_kinds: impl IntoIterator<Item = (EntityId, SummaryPaneKind)>,
) -> Option<SummaryPaneKindIcons> {
let mut pane_kinds: Vec<(EntityId, SummaryPaneKind)> = pane_kinds.into_iter().collect();
pane_kinds.sort_by_key(|(creation_order_id, _)| *creation_order_id);
let mut unique_kinds = Vec::new();
for (_, pane_kind) in pane_kinds {
if !unique_kinds.contains(&pane_kind) {
unique_kinds.push(pane_kind);
}
if unique_kinds.len() == 2 {
return Some(SummaryPaneKindIcons::Pair {
primary: unique_kinds[0].clone(),
secondary: unique_kinds[1].clone(),
});
}
}
unique_kinds
.first()
.cloned()
.map(SummaryPaneKindIcons::Single)
}
fn resolve_summary_pane_kind_icons(
pane_group: &PaneGroup,
visible_pane_ids: &[PaneId],
app: &AppContext,
) -> Option<SummaryPaneKindIcons> {
select_summary_pane_kind_icons(visible_pane_ids.iter().filter_map(|pane_id| {
pane_group.pane_by_id(*pane_id).map(|pane| {
let pane_configuration = pane.pane_configuration();
let pane_configuration = pane_configuration.as_ref(app);
let typed = pane_group.resolve_pane_type(*pane_id, app);
(
pane_id.creation_order_id(),
typed.summary_pane_kind(pane_configuration.title().trim(), app),
)
})
}))
}
impl VerticalTabsPanelState {
pub(super) fn scroll_to_tab(&self, tab_index: usize) {
self.scroll_state.scroll_to_position(ScrollTarget {
position_id: tab_position_id(tab_index),
mode: ScrollToPositionMode::FullyIntoView,
});
}
/// Returns the indices (in original order) of tab groups that have at least
/// one pane matching the current search query. Returns all indices when the
/// query is empty.
pub(super) fn matching_tab_indices(
&self,
tabs: &[TabData],
active_tab_index: usize,
app: &AppContext,
) -> Vec<usize> {
if self.search_query.is_empty() {
return (0..tabs.len()).collect();
}
let query_lower = self.search_query.to_lowercase();
let resolved_mode = resolve_vertical_tabs_mode(app);
let display_granularity = match resolved_mode {
VerticalTabsResolvedMode::Panes => VerticalTabsDisplayGranularity::Panes,
VerticalTabsResolvedMode::FocusedSession | VerticalTabsResolvedMode::Summary => {
VerticalTabsDisplayGranularity::Tabs
}
};
tabs.iter()
.enumerate()
.filter(|(tab_index, tab)| {
let pane_group = tab.pane_group.as_ref(app);
let visible_pane_ids = pane_group.visible_pane_ids();
match resolved_mode {
VerticalTabsResolvedMode::Summary => {
let summary =
build_vertical_tabs_summary_data(pane_group, &visible_pane_ids, app);
search_fragments_contain_query(
&summary_search_text_fragments(
&summary,
pane_group.custom_title(app).as_deref(),
),
&query_lower,
)
}
VerticalTabsResolvedMode::Panes | VerticalTabsResolvedMode::FocusedSession => {
pane_ids_for_display_granularity(
&visible_pane_ids,
pane_group.focused_pane_id(app),
display_granularity,
)
.into_iter()
.any(|pane_id| {
let title_override = (!uses_outer_group_container(display_granularity))
.then(|| pane_group.custom_title(app))
.flatten();
let ms = MouseStateHandle::default();
PaneProps::new(
pane_group,
pane_id,
tab.pane_group.id(),
*tab_index == active_tab_index,
PaneRowState {
mouse_state: ms,
title_mouse_state: None,
pane_color: None,
badge_mouse_states: PaneRowBadgeMouseStates::default(),
},
self.detail_hover_state(tab.pane_group.window_id(app)),
display_granularity,
true,
title_override.clone(),
None,
None,
false,
None,
false,
None,
app,
)
.is_some_and(|props| pane_matches_query(&props, &query_lower, app))
})
}
}
})
.map(|(i, _)| i)
.collect()
}
}
const CONTROL_BAR_VERTICAL_PADDING: f32 = 4.;
const CONTROL_BAR_SPACING: f32 = 4.;
const SEARCH_ICON_SIZE: f32 = 12.;
const SEARCH_BAR_HEIGHT: f32 = 24.;
const CONTROL_BAR_BUTTON_RADIUS: Radius = Radius::Pixels(4.);
const SPLIT_BUTTON_HEIGHT: f32 = SEARCH_BAR_HEIGHT;
pub(super) const VERTICAL_TABS_ADD_TAB_POSITION_ID: &str = "vertical_tabs_add_tab_button";
pub(super) const VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID: &str = "vertical_tabs_settings_button";
pub(super) fn vtab_action_buttons_position_id(tab_index: usize) -> String {
format!("vtab_action_buttons_{tab_index}")
}
const COMPACT_ICON_SIZE: f32 = 16.;
const GROUP_INSERTION_TARGET_HEIGHT: f32 = 6.;
const GROUP_INSERTION_INDICATOR_HEIGHT: f32 = 3.;
fn any_workspace_pane_being_dragged(workspace: &Workspace, app: &AppContext) -> bool {
workspace
.tabs
.iter()
.any(|tab| tab.pane_group.as_ref(app).any_pane_being_dragged(app))
}
fn vertical_tabs_tab_bar_location(insert_index: usize, tab_count: usize) -> TabBarLocation {
if insert_index == tab_count {
TabBarLocation::AfterTabIndex(tab_count)
} else {
TabBarLocation::TabIndex(insert_index)
}
}
fn render_vertical_tab_hover_indicator(theme: &WarpTheme) -> Box<dyn Element> {
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background(ThemeFill::Solid(theme.accent().into()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
GROUP_INSERTION_INDICATOR_HEIGHT / 2.,
)))
.finish(),
)
.with_height(GROUP_INSERTION_INDICATOR_HEIGHT)
.finish()
}
fn render_vertical_tab_insertion_target_content(content: Box<dyn Element>) -> Box<dyn Element> {
ConstrainedBox::new(
Container::new(content)
.with_padding(
Padding::uniform(0.)
.with_left(GROUP_HORIZONTAL_PADDING)
.with_right(GROUP_HORIZONTAL_PADDING),
)
.finish(),
)
.with_height(GROUP_INSERTION_TARGET_HEIGHT)
.finish()
}
fn render_vertical_tab_insertion_target(
insert_index: usize,
tab_count: usize,
is_drag_target: bool,
theme: &WarpTheme,
) -> Box<dyn Element> {
let content = if is_drag_target {
render_vertical_tab_hover_indicator(theme)
} else {
Empty::new().finish()
};
DropTarget::new(
render_vertical_tab_insertion_target_content(content),
VerticalTabsPaneDropTargetData {
tab_bar_location: vertical_tabs_tab_bar_location(insert_index, tab_count),
tab_hover_index: TabBarHoverIndex::BeforeTab(insert_index),
},
)
.finish()
}
fn add_vertical_tab_insertion_target_overlay(
stack: &mut Stack,
insert_index: usize,
tab_count: usize,
is_drag_target: bool,
parent_anchor: ParentAnchor,
child_anchor: ChildAnchor,
theme: &WarpTheme,
) {
stack.add_positioned_overlay_child(
render_vertical_tab_insertion_target(insert_index, tab_count, is_drag_target, theme),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::ParentBySize,
parent_anchor,
child_anchor,
),
);
}
fn render_control_bar(
state: &VerticalTabsPanelState,
workspace: &Workspace,
search_editor: &ViewHandle<EditorView>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let sub_text = theme.sub_text_color(theme.background());
let search_icon = ConstrainedBox::new(WarpIcon::Search.to_galaxyui_icon(sub_text).finish())
.with_width(SEARCH_ICON_SIZE)
.with_height(SEARCH_ICON_SIZE)
.finish();
let text_input = TextInput::new(
search_editor.clone(),
UiComponentStyles::default()
.set_background(ElementFill::None)
.set_border_radius(CornerRadius::with_all(Radius::Pixels(0.)))
.set_border_width(0.),
)
.build()
.finish();
let search_bar = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(6.)
.with_child(search_icon)
.with_child(Shrinkable::new(1., text_input).finish())
.finish();
let settings_button = render_settings_button(state, appearance);
let new_tab_button = render_new_tab_button(state, workspace, appearance, app);
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(CONTROL_BAR_SPACING)
.with_child(Shrinkable::new(1., search_bar).finish())
.with_child(settings_button)
.with_child(new_tab_button)
.finish(),
)
.with_padding(
Padding::uniform(CONTROL_BAR_VERTICAL_PADDING)
.with_left(GROUP_HORIZONTAL_PADDING)
.with_right(GROUP_HORIZONTAL_PADDING),
)
.finish()
}
fn render_detail_kind_badge_icon(
props: &PaneProps<'_>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let sub_text = theme.sub_text_color(theme.background());
let disabled_text = detail_sidecar_text_colors(theme).disabled;
match &props.typed {
TypedPane::Terminal(terminal_pane) => {
let terminal_view = terminal_pane.terminal_view(app);
let terminal_view = terminal_view.as_ref(app);
let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id());
if let Some(icon) = cli_agent_session.and_then(|session| session.agent.icon()) {
let color = cli_agent_session
.and_then(|session| session.agent.brand_color())
.map(WarpThemeFill::Solid)
.unwrap_or_else(|| theme.accent());
return icon.to_galaxyui_icon(color).finish();
}
let icon = if terminal_view.is_ambient_agent_session(app) {
WarpIcon::OzCloud
} else if terminal_view
.selected_conversation_display_title(app)
.is_some()
{
WarpIcon::Oz
} else {
WarpIcon::Terminal
};
let color = match icon {
WarpIcon::Oz | WarpIcon::OzCloud => oz_icon_fill(theme),
WarpIcon::Terminal => disabled_text,
_ => sub_text,
};
icon.to_galaxyui_icon(color).finish()
}
TypedPane::Code(_) => icon_from_file_path(&props.title, appearance)
.unwrap_or_else(|| WarpIcon::Code2.to_galaxyui_icon(sub_text).finish()),
typed => {
let fill = typed
.warp_drive_object_type()
.map(|object_type| {
WarpThemeFill::Solid(warp_drive_icon_color(appearance, object_type))
})
.unwrap_or(sub_text);
typed.icon().to_galaxyui_icon(fill).finish()
}
}
}
fn render_settings_button(
state: &VerticalTabsPanelState,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let sub_text = theme.sub_text_color(theme.background());
let main_text = theme.main_text_color(theme.background());
let is_popup_open = state.show_settings_popup;
let ui_builder = appearance.ui_builder().clone();
let button = Hoverable::new(
state.settings_button_mouse_state.clone(),
move |hover_state| {
let icon = ConstrainedBox::new(
WarpIcon::Settings
.to_galaxyui_icon(if is_popup_open { main_text } else { sub_text })
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let background = if is_popup_open {
internal_colors::fg_overlay_3(theme)
} else if hover_state.is_hovered() {
internal_colors::fg_overlay_2(theme)
} else {
ThemeFill::Solid(ColorU::transparent_black())
};
let button_container = Container::new(icon)
.with_padding(Padding::uniform(2.))
.with_background(background)
.with_corner_radius(CornerRadius::with_all(CONTROL_BAR_BUTTON_RADIUS))
.finish();
if hover_state.is_hovered() && !is_popup_open {
let tooltip = ui_builder
.tool_tip("View options".to_string())
.build()
.finish();
let mut stack = Stack::new().with_child(button_container);
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
stack.finish()
} else {
button_container
}
},
)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleVerticalTabsSettingsPopup);
})
.with_cursor(Cursor::PointingHand)
.finish();
SavePosition::new(button, VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID).finish()
}
fn render_new_tab_button(
state: &VerticalTabsPanelState,
workspace: &Workspace,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let sub_text = theme.sub_text_color(theme.background());
let main_text = theme.main_text_color(theme.background());
let ui_builder = appearance.ui_builder().clone();
let tab_configs_keybinding =
keybinding_name_to_display_string(super::TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, app);
let is_active = workspace.show_new_session_dropdown_menu.is_some()
|| workspace
.hoa_onboarding_flow
.as_ref()
.is_some_and(|flow| flow.as_ref(app).step() == HoaOnboardingStep::TabConfig);
Hoverable::new(state.new_tab_hover_state.clone(), move |hover_state| {
let plus_button = combo_inner_button(
appearance,
UiIcon::Plus,
is_active,
state.new_tab_button_state.clone(),
)
.with_style(
UiComponentStyles::default()
.set_border_radius(CornerRadius::with_all(CONTROL_BAR_BUTTON_RADIUS))
.set_font_color(if is_active { main_text } else { sub_text }.into()),
)
.with_active_styles(
UiComponentStyles::default()
.set_background(internal_colors::fg_overlay_3(theme).into()),
)
.build()
.on_click(|ctx, _, position| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleNewSessionMenu {
position,
is_vertical_tabs: true,
});
})
.finish();
let button = SavePosition::new(plus_button, VERTICAL_TABS_ADD_TAB_POSITION_ID).finish();
let contents = if hover_state.is_hovered() {
let tooltip = if let Some(sublabel) = tab_configs_keybinding.clone() {
ui_builder
.tool_tip_with_sublabel("Tab configs".to_string(), sublabel)
.build()
.finish()
} else {
ui_builder
.tool_tip("Tab configs".to_string())
.build()
.finish()
};
let mut stack = Stack::new().with_child(button);
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
stack.finish()
} else {
button
};
let mut container = Container::new(
ConstrainedBox::new(contents)
.with_height(SPLIT_BUTTON_HEIGHT)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(CONTROL_BAR_BUTTON_RADIUS));
if is_active {
container = container.with_background(internal_colors::fg_overlay_3(theme));
} else if hover_state.is_hovered() {
container = container.with_background(internal_colors::neutral_1(theme));
}
container.finish()
})
.finish()
}
fn render_vertical_tabs_panel(
state: &VerticalTabsPanelState,
workspace: &Workspace,
side: super::PanelPosition,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let scrollable_groups = ClippedScrollable::vertical(
state.scroll_state.clone(),
render_groups(state, workspace, app),
ScrollbarWidth::Custom(4.),
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
ElementFill::None,
)
.with_overlayed_scrollbar()
.finish();
let panel_content = Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(render_control_bar(
state,
workspace,
&workspace.vertical_tabs_search_input,
app,
))
.with_child(Shrinkable::new(1., scrollable_groups).finish())
.finish();
let panel_with_popup: Box<dyn Element> = if state.show_settings_popup {
let popup = render_settings_popup(state, app);
let mut stack = Stack::new().with_child(panel_content);
stack.add_positioned_overlay_child(
popup,
OffsetPositioning::offset_from_save_position_element(
VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID,
vec2f(0., 4.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
stack.finish()
} else {
panel_content
};
let drag_side = match side {
super::PanelPosition::Left => DragBarSide::Right,
super::PanelPosition::Right => DragBarSide::Left,
};
let inner = Container::new(panel_with_popup)
.with_background(internal_colors::fg_overlay_1(theme))
.finish();
Resizable::new(state.resizable_state.clone(), inner)
.with_dragbar_side(drag_side)
.on_resize(|ctx, _| {
ctx.notify();
})
.with_bounds_callback(Box::new(|window_size| {
let max_width = window_size.x() * MAX_PANEL_WIDTH_RATIO;
(MIN_PANEL_WIDTH, max_width.max(MIN_PANEL_WIDTH))
}))
.finish()
}
fn render_groups(
state: &VerticalTabsPanelState,
workspace: &Workspace,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
if workspace.tabs.is_empty() {
return Container::new(
Text::new_inline("No tabs open", appearance.ui_font_family(), 12.)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
.with_padding(Padding::uniform(12.))
.finish();
}
let resolved_mode = resolve_vertical_tabs_mode(app);
let display_granularity = match resolved_mode {
VerticalTabsResolvedMode::Panes => VerticalTabsDisplayGranularity::Panes,
VerticalTabsResolvedMode::FocusedSession | VerticalTabsResolvedMode::Summary => {
VerticalTabsDisplayGranularity::Tabs
}
};
let uses_outer_group_container = uses_outer_group_container(display_granularity);
let query = state.search_query.as_str();
let visible_tabs: Vec<(usize, Option<Vec<PaneId>>)> = if query.is_empty() {
workspace
.tabs
.iter()
.enumerate()
.map(|(tab_index, _)| (tab_index, None))
.collect()
} else {
let query_lower = query.to_lowercase();
workspace
.tabs
.iter()
.enumerate()
.filter_map(|(tab_index, tab)| {
let pane_group = tab.pane_group.as_ref(app);
let visible_pane_ids = pane_group.visible_pane_ids();
match resolved_mode {
VerticalTabsResolvedMode::Summary => {
let summary =
build_vertical_tabs_summary_data(pane_group, &visible_pane_ids, app);
search_fragments_contain_query(
&summary_search_text_fragments(
&summary,
pane_group.custom_title(app).as_deref(),
),
&query_lower,
)
.then_some((tab_index, None))
}
VerticalTabsResolvedMode::Panes | VerticalTabsResolvedMode::FocusedSession => {
let title_override = (!uses_outer_group_container)
.then(|| pane_group.custom_title(app))
.flatten();
let matching_ids: Vec<PaneId> = pane_ids_for_display_granularity(
&visible_pane_ids,
pane_group.focused_pane_id(app),
display_granularity,
)
.into_iter()
.filter(|&pane_id| {
let Some(mouse_state) =
state.pane_row_mouse_states.borrow().get(&pane_id).cloned()
else {
let ms = MouseStateHandle::default();
return PaneProps::new(
pane_group,
pane_id,
tab.pane_group.id(),
tab_index == workspace.active_tab_index,
PaneRowState {
mouse_state: ms,
title_mouse_state: None,
pane_color: None,
badge_mouse_states: PaneRowBadgeMouseStates::default(),
},
state.detail_hover_state(workspace.window_id),
display_granularity,
true,
title_override.clone(),
None,
None,
false,
None,
false,
None,
app,
)
.is_some_and(|props| {
pane_matches_query(&props, &query_lower, app)
});
};
PaneProps::new(
pane_group,
pane_id,
tab.pane_group.id(),
tab_index == workspace.active_tab_index,
PaneRowState {
mouse_state,
title_mouse_state: None,
pane_color: None,
badge_mouse_states: PaneRowBadgeMouseStates::default(),
},
state.detail_hover_state(workspace.window_id),
display_granularity,
true,
title_override.clone(),
None,
None,
false,
None,
false,
None,
app,
)
.is_some_and(|props| pane_matches_query(&props, &query_lower, app))
})
.collect();
(!matching_ids.is_empty()).then_some((tab_index, Some(matching_ids)))
}
}
})
.collect()
};
if visible_tabs.is_empty() {
if query.is_empty() {
return Empty::new().finish();
} else {
return Container::new(
Text::new_inline(
"No tabs match your search.",
appearance.ui_font_family(),
12.,
)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
.with_padding(Padding::uniform(12.))
.finish();
}
}
let is_any_pane_dragging = any_workspace_pane_being_dragged(workspace, app);
let mut groups = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
if !uses_outer_group_container {
groups = groups.with_spacing(TABS_MODE_ITEM_SPACING);
}
for (visible_tab_index, (tab_index, filtered_pane_ids)) in visible_tabs.iter().enumerate() {
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,
));
}
// Prune stale badge mouse states for panes that no longer exist.
let all_pane_ids: std::collections::HashSet<PaneId> = workspace
.tabs
.iter()
.flat_map(|tab| tab.pane_group.as_ref(app).visible_pane_ids())
.collect();
state
.pane_badge_mouse_states
.borrow_mut()
.retain(|id, _| all_pane_ids.contains(id));
state
.pane_title_mouse_states
.borrow_mut()
.retain(|id, _| all_pane_ids.contains(id));
state
.detail_pane_badge_mouse_states
.borrow_mut()
.retain(|id, _| all_pane_ids.contains(id));
let groups = groups.finish();
if uses_outer_group_container {
groups
} else {
Container::new(groups)
.with_padding(Padding::uniform(8.).with_top(0.))
.finish()
}
}
fn render_tab_group(
state: &VerticalTabsPanelState,
workspace: &Workspace,
tab_index: usize,
tab: &TabData,
filtered_pane_ids: Option<&[PaneId]>,
drag_state: TabGroupDragState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let pane_group = tab.pane_group.as_ref(app);
let pane_group_id = tab.pane_group.id();
let visible_pane_ids = pane_group.visible_pane_ids();
let resolved_mode = resolve_vertical_tabs_mode(app);
let display_granularity = match resolved_mode {
VerticalTabsResolvedMode::Panes => VerticalTabsDisplayGranularity::Panes,
VerticalTabsResolvedMode::FocusedSession | VerticalTabsResolvedMode::Summary => {
VerticalTabsDisplayGranularity::Tabs
}
};
let uses_outer_group_container = uses_outer_group_container(display_granularity);
let representative_pane_ids = pane_ids_for_display_granularity(
&visible_pane_ids,
pane_group.focused_pane_id(app),
display_granularity,
);
let pane_ids_to_render: &[PaneId] = filtered_pane_ids.unwrap_or(&representative_pane_ids);
let PaneGroupStateHandles {
group: group_mouse_state,
header: group_header_mouse_state,
kebab: kebab_mouse_state,
close: close_mouse_state,
action_buttons: action_buttons_mouse_state,
} = state
.group_mouse_states
.borrow_mut()
.entry(pane_group_id)
.or_default()
.clone();
let action_buttons_mouse_over = action_buttons_mouse_state
.lock()
.expect("action buttons hover state lock poisoned")
.is_mouse_over_element();
let row_mouse_states: Vec<(PaneId, MouseStateHandle)> = pane_ids_to_render
.iter()
.map(|pane_id| {
let ms = state
.pane_row_mouse_states
.borrow_mut()
.entry(*pane_id)
.or_default()
.clone();
(*pane_id, ms)
})
.collect();
let title_mouse_states: HashMap<PaneId, MouseStateHandle> = pane_ids_to_render
.iter()
.map(|pane_id| {
let ms = state
.pane_title_mouse_states
.borrow_mut()
.entry(*pane_id)
.or_default()
.clone();
(*pane_id, ms)
})
.collect();
let is_active = tab_index == workspace.active_tab_index
&& !workspace
.current_workspace_state
.is_agent_management_view_open;
let has_top_border = tab_index > 0;
let is_first_tab = tab_index == 0;
let is_last_tab = tab_index + 1 == workspace.tabs.len();
let is_this_tab_dragging = tab.draggable_state.is_dragging();
let color_mode = compute_tab_group_color_mode(tab, pane_group, &visible_pane_ids, theme, app);
let per_pane_colors = color_mode.into_per_pane_colors(&visible_pane_ids);
let is_being_renamed = is_active && workspace.current_workspace_state.is_tab_being_renamed();
let rename_editor = workspace.tab_rename_editor.clone();
let has_custom_title = pane_group.custom_title(app).is_some();
let displayed_tab_title_override = (!uses_outer_group_container)
.then(|| pane_group.custom_title(app))
.flatten();
let is_menu_open_for_tab = workspace
.show_tab_right_click_menu
.is_some_and(|(idx, _)| idx == tab_index);
let is_drag_target = workspace.hovered_tab_index == Some(TabBarHoverIndex::OverTab(tab_index));
let summary = matches!(resolved_mode, VerticalTabsResolvedMode::Summary)
.then(|| build_vertical_tabs_summary_data(pane_group, &visible_pane_ids, app));
let summary_pane_kind_icons = matches!(resolved_mode, VerticalTabsResolvedMode::Summary)
.then(|| resolve_summary_pane_kind_icons(pane_group, &visible_pane_ids, app))
.flatten();
let active_pane_context_menu_target = PaneViewLocator {
pane_group_id,
pane_id: pane_group.focused_pane_id(app),
};
let mut group_element = Hoverable::new(group_mouse_state, move |group_state| {
let build_rows = || {
let mut rows = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(GROUP_ITEM_SPACING);
if matches!(resolved_mode, VerticalTabsResolvedMode::Summary) {
let Some((pane_id, row_mouse_state)) = row_mouse_states.first() else {
return Empty::new().finish();
};
let pane_color = per_pane_colors
.as_ref()
.and_then(|map| map.get(pane_id).copied())
.flatten();
let badge_mouse_states = state
.pane_badge_mouse_states
.borrow_mut()
.entry(*pane_id)
.or_default()
.clone();
let Some(pane_props) = PaneProps::new(
pane_group,
*pane_id,
pane_group_id,
is_active,
PaneRowState {
mouse_state: row_mouse_state.clone(),
title_mouse_state: None,
pane_color,
badge_mouse_states,
},
state.detail_hover_state(workspace.window_id),
display_granularity,
false,
displayed_tab_title_override.clone(),
(!uses_outer_group_container).then_some(tab_index),
None,
!uses_outer_group_container && is_being_renamed,
(!uses_outer_group_container).then_some(rename_editor.clone()),
false,
None,
app,
) else {
return Empty::new().finish();
};
rows.add_child(render_summary_tab_item(
pane_props,
summary
.as_ref()
.expect("summary data must exist in summary mode"),
summary_pane_kind_icons,
app,
));
return rows.finish();
}
for (pane_id, row_mouse_state) in &row_mouse_states {
let pane_color = per_pane_colors
.as_ref()
.and_then(|map| map.get(pane_id).copied())
.flatten();
let badge_mouse_states = state
.pane_badge_mouse_states
.borrow_mut()
.entry(*pane_id)
.or_default()
.clone();
let locator = PaneViewLocator {
pane_group_id,
pane_id: *pane_id,
};
let is_pane_being_renamed = workspace
.current_workspace_state
.is_pane_being_renamed(locator);
let Some(pane_props) = PaneProps::new(
pane_group,
*pane_id,
pane_group_id,
is_active,
PaneRowState {
mouse_state: row_mouse_state.clone(),
title_mouse_state: title_mouse_states.get(pane_id).cloned(),
pane_color,
badge_mouse_states,
},
state.detail_hover_state(workspace.window_id),
display_granularity,
true,
displayed_tab_title_override.clone(),
(!uses_outer_group_container).then_some(tab_index),
uses_outer_group_container.then_some(tab_index),
!uses_outer_group_container && is_being_renamed,
(!uses_outer_group_container).then_some(rename_editor.clone()),
is_pane_being_renamed,
is_pane_being_renamed.then_some(workspace.pane_rename_editor.clone()),
app,
) else {
continue;
};
let view_mode = *TabSettings::as_ref(app).vertical_tabs_view_mode.value();
let row = match view_mode {
VerticalTabsViewMode::Compact => render_compact_pane_row(pane_props, app),
VerticalTabsViewMode::Expanded => render_pane_row(pane_props, app),
};
rows.add_child(row);
}
rows.finish()
};
let group_content = if uses_outer_group_container {
let mut group = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
if has_custom_title || is_being_renamed {
group.add_child(render_group_header(
GroupHeaderProps {
tab_index,
pane_group,
is_being_renamed,
rename_editor: rename_editor.clone(),
header_mouse_state: group_header_mouse_state.clone(),
},
app,
));
}
let show_header = has_custom_title || is_being_renamed;
let mut body_padding = Padding::uniform(0.)
.with_left(GROUP_HORIZONTAL_PADDING)
.with_right(GROUP_HORIZONTAL_PADDING)
.with_bottom(GROUP_BODY_BOTTOM_PADDING);
if !show_header {
body_padding = body_padding.with_top(GROUP_BODY_BOTTOM_PADDING);
}
group.add_child(
Container::new(build_rows())
.with_padding(body_padding)
.finish(),
);
let background = if is_drag_target {
internal_colors::fg_overlay_2(theme)
} else if is_active || group_state.is_hovered() {
internal_colors::fg_overlay_1(theme)
} else {
ThemeFill::Solid(ColorU::transparent_black())
};
let mut container = Container::new(group.finish()).with_background(background);
if is_drag_target {
container = container.with_border(
Border::all(1.).with_border_fill(ThemeFill::Solid(theme.accent().into())),
);
} else if has_top_border || is_first_tab || is_last_tab {
container = container.with_border(
Border::new(1.)
.with_sides(has_top_border || is_first_tab, false, is_last_tab, false)
.with_border_fill(internal_colors::fg_overlay_1(theme)),
);
}
container.finish()
} else {
let background = if is_drag_target {
internal_colors::fg_overlay_2(theme)
} else if is_active || group_state.is_hovered() {
internal_colors::fg_overlay_1(theme)
} else {
ThemeFill::Solid(ColorU::transparent_black())
};
let mut container = Container::new(build_rows()).with_background(background);
if is_drag_target {
container = container.with_border(
Border::all(1.).with_border_fill(ThemeFill::Solid(theme.accent().into())),
);
}
container.finish()
};
// Show the action buttons when the group OR the buttons themselves
// are hovered, following the pattern from AgentManagementView.
// This prevents flickering when the mouse moves from the group
// to the overlay buttons (which may sit outside the group bounds).
let should_show_action_buttons = !drag_state.is_any_pane_dragging
&& (group_state.is_hovered() || action_buttons_mouse_over || is_menu_open_for_tab);
let action_buttons = if should_show_action_buttons {
render_group_action_buttons(
tab_index,
is_menu_open_for_tab,
action_buttons_mouse_state.clone(),
kebab_mouse_state.clone(),
close_mouse_state.clone(),
theme,
)
} else {
Empty::new().finish()
};
let mut stack = Stack::new().with_child(group_content);
if drag_state.is_any_pane_dragging {
add_vertical_tab_insertion_target_overlay(
&mut stack,
drag_state.insert_before_index,
workspace.tabs.len(),
workspace.hovered_tab_index
== Some(TabBarHoverIndex::BeforeTab(drag_state.insert_before_index)),
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
theme,
);
if let Some(insert_after_index) = drag_state.insert_after_index {
add_vertical_tab_insertion_target_overlay(
&mut stack,
insert_after_index,
workspace.tabs.len(),
workspace.hovered_tab_index
== Some(TabBarHoverIndex::BeforeTab(insert_after_index)),
ParentAnchor::BottomLeft,
ChildAnchor::BottomLeft,
theme,
);
}
}
stack.add_positioned_overlay_child(
action_buttons,
OffsetPositioning::offset_from_parent(
vec2f(-4., GROUP_HEADER_VERTICAL_PADDING),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
stack.finish()
})
.on_right_click(move |ctx, _, position| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleVerticalTabsPaneContextMenu {
tab_index,
target: VerticalTabsPaneContextMenuTarget::ActivePane(active_pane_context_menu_target),
position,
});
});
// Mirror the horizontal-tab behavior: middle-click closes the tab, except when it would
// close the last tab in a context that doesn't allow closing the window.
if ContextFlag::CloseWindow.is_enabled() || !is_last_tab {
group_element = group_element.on_middle_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::CloseTab(tab_index));
});
}
let group_element = group_element.with_defer_events_to_children().finish();
let draggable = Draggable::new(tab.draggable_state.clone(), group_element)
.on_drag_start(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::StartTabDrag);
})
.on_drag(move |ctx, _, rect, _| {
ctx.dispatch_typed_action(WorkspaceAction::DragTab {
tab_index,
tab_position: rect,
});
})
.on_drop(|ctx, _, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::DropTab);
})
.with_drag_axis(DragAxis::VerticalOnly)
.finish();
let draggable: Box<dyn Element> = if is_this_tab_dragging {
Container::new(draggable)
.with_background(internal_colors::fg_overlay_1(theme))
.finish()
} else {
draggable
};
let draggable = SavePosition::new(draggable, &tab_position_id(tab_index)).finish();
if is_this_tab_dragging {
draggable
} else {
DropTarget::new(
draggable,
VerticalTabsPaneDropTargetData {
tab_bar_location: TabBarLocation::TabIndex(tab_index),
tab_hover_index: TabBarHoverIndex::OverTab(tab_index),
},
)
.finish()
}
}
fn render_group_action_buttons(
tab_index: usize,
is_menu_open: bool,
action_buttons_mouse_state: MouseStateHandle,
kebab_mouse_state: MouseStateHandle,
close_mouse_state: MouseStateHandle,
theme: &WarpTheme,
) -> Box<dyn Element> {
let meta_color = theme.sub_text_color(theme.background());
let kebab_button = Hoverable::new(kebab_mouse_state, move |button_state| {
let mut container = Container::new(
ConstrainedBox::new(WarpIcon::DotsVertical.to_galaxyui_icon(meta_color).finish())
.with_width(GROUP_ACTION_BUTTON_ICON_SIZE)
.with_height(GROUP_ACTION_BUTTON_ICON_SIZE)
.finish(),
)
.with_padding(Padding::uniform(GROUP_ACTION_BUTTON_PADDING))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if is_menu_open || button_state.is_hovered() {
container = container.with_background(internal_colors::fg_overlay_2(theme));
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleTabRightClickMenu {
tab_index,
anchor: TabContextMenuAnchor::VerticalTabsKebab,
});
})
.finish();
let close_button = Hoverable::new(close_mouse_state, move |button_state| {
let mut container = Container::new(
ConstrainedBox::new(WarpIcon::X.to_galaxyui_icon(meta_color).finish())
.with_width(GROUP_ACTION_BUTTON_ICON_SIZE)
.with_height(GROUP_ACTION_BUTTON_ICON_SIZE)
.finish(),
)
.with_padding(Padding::uniform(GROUP_ACTION_BUTTON_PADDING))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if button_state.is_hovered() {
container = container.with_background(internal_colors::fg_overlay_3(theme));
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::CloseTab(tab_index));
})
.finish();
let button_row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(GROUP_ACTION_BUTTON_GAP)
.with_child(kebab_button)
.with_child(close_button)
.finish();
let belt_border_color = internal_colors::neutral_4(theme);
let belt = Hoverable::new(action_buttons_mouse_state, move |_| {
Container::new(button_row)
.with_background(ThemeFill::Solid(internal_colors::neutral_3(theme)))
.with_border(Border::all(1.).with_border_fill(ThemeFill::Solid(belt_border_color)))
.with_padding(Padding::uniform(GROUP_ACTION_BUTTON_PADDING))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
})
.with_defer_events_to_children()
.finish();
SavePosition::new(belt, &vtab_action_buttons_position_id(tab_index)).finish()
}
fn render_group_header(props: GroupHeaderProps<'_>, app: &AppContext) -> Box<dyn Element> {
let GroupHeaderProps {
tab_index,
pane_group,
is_being_renamed,
rename_editor,
header_mouse_state,
} = props;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let title = pane_group.display_title(app);
let title = if title.is_empty() {
"Untitled tab".to_string()
} else {
title
};
let font_family = appearance.ui_font_family();
let title_color = theme.sub_text_color(theme.background());
Hoverable::new(header_mouse_state, move |_header_state| {
Container::new(if is_being_renamed {
TextInput::new(
rename_editor.clone(),
UiComponentStyles::default()
.set_background(ElementFill::None)
.set_border_radius(CornerRadius::with_all(Radius::Pixels(0.)))
.set_border_width(0.),
)
.build()
.finish()
} else {
Text::new_inline(title.clone(), font_family, 10.)
.with_clip(ClipConfig::ellipsis())
.with_color(title_color.into())
.finish()
})
.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()
})
.on_click(move |ctx, _, _| {
if !is_being_renamed {
ctx.dispatch_typed_action(WorkspaceAction::ActivateTab(tab_index));
}
})
.on_double_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::RenameTab(tab_index));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_passive_terminal_diff_stats_badge(
git_line_changes: &GitLineChanges,
appearance: &Appearance,
) -> Box<dyn Element> {
render_badge_container(
render_vtab_diff_stats_content(git_line_changes, appearance),
internal_colors::fg_overlay_1(appearance.theme()),
)
}
fn resolve_icon_with_status_variant(
typed: &TypedPane<'_>,
title: &str,
appearance: &Appearance,
app: &AppContext,
) -> IconWithStatusVariant {
let theme = appearance.theme();
let main_text = theme.main_text_color(theme.background());
let sub_text = theme.sub_text_color(theme.background());
let drive_color = |object_type: DriveObjectType| -> WarpThemeFill {
WarpThemeFill::Solid(warp_drive_icon_color(appearance, object_type))
};
match typed {
TypedPane::Terminal(terminal_pane) => {
let terminal_view = terminal_pane.terminal_view(app);
let terminal_view = terminal_view.as_ref(app);
let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id());
let is_plugin_backed = cli_agent_session.is_some_and(|s| s.listener.is_some());
let is_ambient = terminal_view.is_ambient_agent_session(app);
let has_conversation = terminal_view
.selected_conversation_display_title(app)
.is_some();
let is_oz_agent = has_conversation || is_ambient;
if let Some(session) = cli_agent_session
.filter(|s| s.listener.is_some())
.filter(|s| !matches!(s.agent, CLIAgent::Unknown))
{
IconWithStatusVariant::CLIAgent {
agent: session.agent,
status: if agent_supports_rich_status(&session.agent) {
Some(session.status.to_conversation_status())
} else {
None
},
}
} else if let Some(session) = cli_agent_session
.filter(|_| !is_plugin_backed)
.filter(|s| !matches!(s.agent, CLIAgent::Unknown))
{
IconWithStatusVariant::CLIAgent {
agent: session.agent,
status: None,
}
} else if is_oz_agent {
IconWithStatusVariant::OzAgent {
status: terminal_view.selected_conversation_status_for_display(app),
is_ambient,
}
} else {
// Plain terminal: use foreground color per design spec
IconWithStatusVariant::Neutral {
icon: WarpIcon::Terminal,
icon_color: main_text,
}
}
}
TypedPane::Code(_) => {
if let Some(icon_element) = icon_from_file_path(title, appearance) {
IconWithStatusVariant::NeutralElement { icon_element }
} else {
IconWithStatusVariant::Neutral {
icon: WarpIcon::Code2,
icon_color: sub_text,
}
}
}
// Settings and environment management use the foreground color per design spec
TypedPane::Settings | TypedPane::EnvironmentManagement => IconWithStatusVariant::Neutral {
icon: typed.icon(),
icon_color: main_text,
},
// Warp Drive object types use their established index colors
TypedPane::Notebook { is_plan } => IconWithStatusVariant::Neutral {
icon: typed.icon(),
icon_color: drive_color(DriveObjectType::Notebook {
is_ai_document: *is_plan,
}),
},
TypedPane::Workflow { is_ai_prompt: true } => IconWithStatusVariant::Neutral {
icon: typed.icon(),
icon_color: drive_color(DriveObjectType::AgentModeWorkflow),
},
TypedPane::Workflow {
is_ai_prompt: false,
} => IconWithStatusVariant::Neutral {
icon: typed.icon(),
icon_color: drive_color(DriveObjectType::Workflow),
},
TypedPane::EnvVarCollection => IconWithStatusVariant::Neutral {
icon: typed.icon(),
icon_color: drive_color(DriveObjectType::EnvVarCollection),
},
TypedPane::AIFact => IconWithStatusVariant::Neutral {
icon: typed.icon(),
icon_color: drive_color(DriveObjectType::AIFact),
},
// Other pane types use sub-text color
other => IconWithStatusVariant::Neutral {
icon: other.icon(),
icon_color: sub_text,
},
}
}
fn has_unread_activity(typed: &TypedPane<'_>, app: &AppContext) -> bool {
let TypedPane::Terminal(terminal_pane) = typed else {
return false;
};
let terminal_view = terminal_pane.terminal_view(app);
let terminal_view_id = terminal_view.as_ref(app).id();
AgentNotificationsModel::as_ref(app)
.notifications()
.has_unread_for_terminal_view(terminal_view_id)
}
const INDICATOR_DOT_SIZE: f32 = 8.;
fn render_title_indicator(theme: &WarpTheme) -> Box<dyn Element> {
ConstrainedBox::new(
WarpIcon::CircleFilled
.to_galaxyui_icon(theme.accent())
.finish(),
)
.with_width(INDICATOR_DOT_SIZE)
.with_height(INDICATOR_DOT_SIZE)
.finish()
}
fn render_pane_row(props: PaneProps<'_>, app: &AppContext) -> Box<dyn Element> {
let effective_subtitle = props.subtitle.clone();
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let icon = render_pane_icon_with_status(
resolve_icon_with_status_variant(&props.typed, &props.title, appearance, app),
theme,
);
// Top-align the icon when there are multiple lines of content so it sits next to
// the first line; center it for single-line rows (Settings, Notebook with no subtitle, etc.).
let icon_alignment =
if matches!(props.typed, TypedPane::Terminal(_)) || !effective_subtitle.is_empty() {
CrossAxisAlignment::Start
} else {
CrossAxisAlignment::Center
};
let text_content = if let TypedPane::Terminal(terminal_pane) = &props.typed {
render_terminal_row_content(
&props,
terminal_pane.terminal_view(app).as_ref(app),
appearance,
app,
)
} else {
let has_indicator =
props.typed.badge(app).is_some() || has_unread_activity(&props.typed, app);
let mut title_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
title_row.add_child(
Shrinkable::new(
1.,
render_pane_title_slot(
&props,
|| {
Text::new_inline(props.displayed_title().to_string(), font_family, 12.)
.with_clip(ClipConfig::ellipsis())
.with_color(theme.main_text_color(theme.background()).into())
.finish()
},
12.,
theme.main_text_color(theme.background()),
ClipConfig::ellipsis(),
appearance,
app,
),
)
.finish(),
);
if has_indicator {
title_row.add_child(
Container::new(render_title_indicator(theme))
.with_margin_left(4.)
.finish(),
);
}
let mut content_col = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(2.)
.with_child(title_row.finish());
if !effective_subtitle.is_empty() {
let subtitle_clip = if matches!(props.typed, TypedPane::Code(_)) {
ClipConfig::start()
} else {
ClipConfig::ellipsis()
};
content_col.add_child(
Text::new_inline(effective_subtitle, font_family, 12.)
.with_clip(subtitle_clip)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
);
}
content_col.finish()
};
let content = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(icon_alignment)
.with_spacing(ICON_WITH_STATUS_GAP)
.with_child(icon)
.with_child(Shrinkable::new(1., text_content).finish())
.finish();
render_pane_row_element(props, Padding::uniform(8.), true, content, theme)
}
enum TypedPane<'a> {
Terminal(&'a TerminalPane),
Code(&'a CodePane),
CodeDiff,
File,
Notebook { is_plan: bool },
Workflow { is_ai_prompt: bool },
Settings,
EnvVarCollection,
EnvironmentManagement,
AIFact,
AIDocument,
ExecutionProfileEditor,
Other,
}
impl TypedPane<'_> {
fn summary_pane_kind(&self, title: &str, app: &AppContext) -> SummaryPaneKind {
match self {
TypedPane::Terminal(terminal_pane) => {
let terminal_view = terminal_pane.terminal_view(app);
let terminal_view = terminal_view.as_ref(app);
if let Some(session) =
CLIAgentSessionsModel::as_ref(app).session(terminal_view.id())
{
return SummaryPaneKind::CLIAgent {
agent: session.agent,
};
}
let is_ambient = terminal_view.is_ambient_agent_session(app);
if terminal_view
.selected_conversation_display_title(app)
.is_some()
|| is_ambient
{
SummaryPaneKind::OzAgent { is_ambient }
} else {
SummaryPaneKind::Terminal
}
}
TypedPane::Code(_) => SummaryPaneKind::Code {
title: title.to_string(),
},
TypedPane::CodeDiff => SummaryPaneKind::CodeDiff,
TypedPane::File => SummaryPaneKind::File,
TypedPane::Notebook { is_plan } => SummaryPaneKind::Notebook { is_plan: *is_plan },
TypedPane::Workflow { is_ai_prompt } => SummaryPaneKind::Workflow {
is_ai_prompt: *is_ai_prompt,
},
TypedPane::Settings => SummaryPaneKind::Settings,
TypedPane::EnvVarCollection => SummaryPaneKind::EnvVarCollection,
TypedPane::EnvironmentManagement => SummaryPaneKind::EnvironmentManagement,
TypedPane::AIFact => SummaryPaneKind::AIFact,
TypedPane::AIDocument => SummaryPaneKind::AIDocument,
TypedPane::ExecutionProfileEditor => SummaryPaneKind::ExecutionProfileEditor,
TypedPane::Other => SummaryPaneKind::Other,
}
}
fn warp_drive_object_type(&self) -> Option<DriveObjectType> {
typed_pane_warp_drive_object_type(self)
}
fn supports_vertical_tabs_detail_sidecar(&self) -> bool {
matches!(self, TypedPane::Terminal(_) | TypedPane::Code(_))
|| self.warp_drive_object_type().is_some()
}
fn kind_label(&self) -> &'static str {
match self {
TypedPane::Terminal(_) => "Terminal",
TypedPane::Code(_) => "Code",
TypedPane::CodeDiff => "Code Diff",
TypedPane::File => "File",
TypedPane::Notebook { .. } => "Notebook",
TypedPane::Workflow { .. } => "Workflow",
TypedPane::Settings => "Settings",
TypedPane::EnvVarCollection => "Environment Variables",
TypedPane::EnvironmentManagement => "Environments",
TypedPane::AIFact => "Rules",
TypedPane::AIDocument => "Plan",
TypedPane::ExecutionProfileEditor => "Execution Profile",
TypedPane::Other => "Other",
}
}
fn badge(&self, app: &AppContext) -> Option<String> {
match self {
TypedPane::Code(code_pane) => code_pane
.file_view(app)
.as_ref(app)
.contains_unsaved_changes(app)
.then(|| "Unsaved".to_string()),
TypedPane::Terminal(_)
| TypedPane::CodeDiff
| TypedPane::File
| TypedPane::Notebook { .. }
| TypedPane::Workflow { .. }
| TypedPane::Settings
| TypedPane::EnvVarCollection
| TypedPane::EnvironmentManagement
| TypedPane::AIFact
| TypedPane::AIDocument
| TypedPane::ExecutionProfileEditor
| TypedPane::Other => None,
}
}
fn icon(&self) -> WarpIcon {
match self {
TypedPane::Terminal(_) => WarpIcon::Terminal,
TypedPane::Code(_) => WarpIcon::Code2,
TypedPane::CodeDiff => WarpIcon::Diff,
TypedPane::File => WarpIcon::File,
TypedPane::Notebook { is_plan: true } => WarpIcon::Compass,
TypedPane::Notebook { is_plan: false } => WarpIcon::Notebook,
TypedPane::Workflow { is_ai_prompt: true } => WarpIcon::Prompt,
TypedPane::Workflow {
is_ai_prompt: false,
} => WarpIcon::Workflow,
TypedPane::Settings | TypedPane::EnvironmentManagement => WarpIcon::Gear,
TypedPane::EnvVarCollection => WarpIcon::EnvVarCollection,
TypedPane::AIFact => WarpIcon::BookOpen,
TypedPane::AIDocument => WarpIcon::Compass,
TypedPane::ExecutionProfileEditor => WarpIcon::Lightning,
TypedPane::Other => WarpIcon::File,
}
}
}
fn pane_display_title_and_subtitle(
typed: &TypedPane<'_>,
title: &str,
secondary_title: &str,
) -> (String, String) {
if matches!(typed, TypedPane::Code(_)) && !title.is_empty() {
let path = Path::new(title);
let filename = path
.file_name()
.map(|file_name| file_name.to_string_lossy().to_string())
.unwrap_or_else(|| title.to_string());
let parent_raw = path
.parent()
.map(|parent| parent.to_string_lossy().to_string())
.unwrap_or_default();
let home_dir = dirs::home_dir();
let home_str = home_dir.as_ref().and_then(|path| path.to_str());
let parent = galaxy_util::path::user_friendly_path(&parent_raw, home_str).to_string();
(filename, parent)
} else {
(
if title.is_empty() {
typed.kind_label().to_string()
} else {
title.to_string()
},
secondary_title.to_string(),
)
}
}
fn build_vertical_tabs_summary_data(
pane_group: &PaneGroup,
visible_pane_ids: &[PaneId],
app: &AppContext,
) -> VerticalTabsSummaryData {
let mut primary_labels = Vec::new();
let mut primary_seen = HashMap::new();
let mut working_directories = Vec::new();
let mut working_directory_seen = HashMap::new();
let mut branch_entries = Vec::new();
for pane_id in visible_pane_ids {
let Some(pane) = pane_group.pane_by_id(*pane_id) else {
continue;
};
let pane_configuration = pane.pane_configuration();
let pane_configuration = pane_configuration.as_ref(app);
let typed = pane_group.resolve_pane_type(*pane_id, app);
let (pane_title, pane_subtitle) = pane_display_title_and_subtitle(
&typed,
pane_configuration.title().trim(),
pane_configuration.title_secondary().trim(),
);
match typed {
TypedPane::Terminal(terminal_pane) => {
let terminal_view = terminal_pane.terminal_view(app);
let terminal_view = terminal_view.as_ref(app);
let title_text = terminal_view.terminal_title_from_shell();
let working_directory = terminal_view.display_working_directory(app);
let working_directory_text = working_directory
.clone()
.filter(|wd| !wd.trim().is_empty())
.unwrap_or_else(|| title_text.clone());
let agent_text = terminal_agent_text(terminal_view, app);
let (conversation_display_title, cli_agent_title) =
preferred_agent_tab_titles(&agent_text, agent_tab_text_preference(app));
let primary_label = terminal_primary_line_data(
terminal_view.is_long_running_and_user_controlled(),
conversation_display_title,
cli_agent_title,
title_text.as_str(),
working_directory_text.as_str(),
terminal_title_fallback_font(&agent_text),
terminal_view.last_completed_command_text(),
);
push_normalized_unique_summary_text(
&mut primary_labels,
&mut primary_seen,
primary_label.text(),
);
if let Some(working_directory) = working_directory {
push_normalized_unique_summary_text(
&mut working_directories,
&mut working_directory_seen,
&working_directory,
);
}
if let (Some(repo_path), Some(branch_name)) = (
terminal_view.current_repo_path().cloned(),
terminal_view
.current_git_branch(app)
.and_then(|branch| normalize_summary_text(&branch)),
) {
branch_entries.push(VerticalTabsSummaryBranchEntry {
repo_path,
branch_name,
diff_stats: terminal_view.current_diff_line_changes(app),
pull_request_label: terminal_view
.current_pull_request_url(app)
.as_deref()
.map(terminal_pull_request_badge_label)
.and_then(|label| normalize_summary_text(&label)),
});
}
}
TypedPane::Code(_) => {
push_normalized_unique_summary_text(
&mut primary_labels,
&mut primary_seen,
&pane_title,
);
push_normalized_unique_summary_text(
&mut working_directories,
&mut working_directory_seen,
&pane_subtitle,
);
}
TypedPane::CodeDiff
| TypedPane::File
| TypedPane::Notebook { .. }
| TypedPane::Workflow { .. }
| TypedPane::Settings
| TypedPane::EnvVarCollection
| TypedPane::EnvironmentManagement
| TypedPane::AIFact
| TypedPane::AIDocument
| TypedPane::ExecutionProfileEditor
| TypedPane::Other => {
push_normalized_unique_summary_text(
&mut primary_labels,
&mut primary_seen,
&pane_title,
);
}
}
}
VerticalTabsSummaryData {
primary_labels,
working_directories,
branch_entries: coalesce_summary_branch_entries(branch_entries),
}
}
impl<'a> PaneProps<'a> {
#[allow(clippy::too_many_arguments)]
fn new(
pane_group: &'a PaneGroup,
pane_id: PaneId,
pane_group_id: EntityId,
is_active_tab: bool,
pane_row_state: PaneRowState,
detail_hover_state: VerticalTabsDetailHoverState,
display_granularity: VerticalTabsDisplayGranularity,
include_custom_vertical_tabs_title: bool,
display_title_override: Option<String>,
renamable_tab_index: Option<usize>,
pane_context_menu_tab_index: Option<usize>,
is_tab_being_renamed: bool,
rename_editor: Option<ViewHandle<EditorView>>,
is_pane_being_renamed: bool,
pane_rename_editor: Option<ViewHandle<EditorView>>,
app: &AppContext,
) -> Option<Self> {
let pane = pane_group.pane_by_id(pane_id)?;
// When a pane is a temporary replacement (e.g. an expanded code diff),
// resolve display properties from the original hidden pane so the
// sidebar row keeps showing the original icon, title, and metadata.
let display_pane_id = pane_group
.original_pane_for_replacement(pane_id)
.unwrap_or(pane_id);
let display_pane = pane_group.pane_by_id(display_pane_id)?;
let pane_configuration = display_pane.pane_configuration();
let pane_configuration = pane_configuration.as_ref(app);
let typed = pane_group.resolve_pane_type(display_pane_id, app);
let (display_title, display_subtitle) = pane_display_title_and_subtitle(
&typed,
pane_configuration.title().trim(),
pane_configuration.title_secondary().trim(),
);
Some(Self {
pane_id,
pane_group_id,
is_active_tab,
mouse_state: pane_row_state.mouse_state,
title_mouse_state: pane_row_state.title_mouse_state,
title: display_title,
subtitle: display_subtitle,
custom_vertical_tabs_title: include_custom_vertical_tabs_title
.then(|| {
pane_configuration
.custom_vertical_tabs_title()
.map(str::to_owned)
})
.flatten(),
display_title_override,
is_focused: pane_group.focused_pane_id(app) == pane_id,
typed,
is_being_dragged: pane.is_pane_being_dragged(app),
pane_color: pane_row_state.pane_color,
badge_mouse_states: pane_row_state.badge_mouse_states,
detail_hover_state,
display_granularity,
renamable_tab_index,
pane_context_menu_tab_index,
is_tab_being_renamed,
rename_editor,
is_pane_being_renamed,
pane_rename_editor,
})
}
fn displayed_title(&self) -> &str {
self.custom_vertical_tabs_title
.as_deref()
.or(self.display_title_override.as_deref())
.unwrap_or(self.title.as_str())
}
fn generated_or_tab_title(&self) -> &str {
self.display_title_override
.as_deref()
.unwrap_or(self.title.as_str())
}
fn shows_inline_tab_rename_editor(&self) -> bool {
(self.is_tab_being_renamed && self.rename_editor.is_some())
|| (self.is_pane_being_renamed && self.pane_rename_editor.is_some())
}
fn rendered_search_text_fragments(&self, app: &AppContext) -> Vec<String> {
let generated_fragments = match &self.typed {
TypedPane::Terminal(terminal_pane) => terminal_pane_search_text_fragments(
terminal_pane,
self.display_title_override.as_deref(),
app,
),
TypedPane::Code(_)
| TypedPane::CodeDiff
| TypedPane::File
| TypedPane::Notebook { .. }
| TypedPane::Workflow { .. }
| TypedPane::Settings
| TypedPane::EnvVarCollection
| TypedPane::EnvironmentManagement
| TypedPane::AIFact
| TypedPane::AIDocument
| TypedPane::ExecutionProfileEditor
| TypedPane::Other => {
non_terminal_search_text_fragments(self.generated_or_tab_title(), &self.subtitle)
}
};
pane_search_text_fragments(
self.custom_vertical_tabs_title.as_deref(),
generated_fragments,
)
}
}
fn pane_matches_query(props: &PaneProps<'_>, query_lower: &str, app: &AppContext) -> bool {
search_fragments_contain_query(&props.rendered_search_text_fragments(app), query_lower)
}
fn uses_outer_group_container(display_granularity: VerticalTabsDisplayGranularity) -> bool {
matches!(display_granularity, VerticalTabsDisplayGranularity::Panes)
}
fn search_fragments_contain_query(fragments: &[String], query_lower: &str) -> bool {
fragments
.iter()
.filter(|fragment| !fragment.trim().is_empty())
.any(|fragment| fragment.to_lowercase().contains(query_lower))
}
fn pane_search_text_fragments(
custom_title: Option<&str>,
generated_fragments: Vec<String>,
) -> Vec<String> {
let mut fragments = Vec::new();
let mut seen = HashMap::new();
if let Some(custom_title) = custom_title {
push_normalized_unique_summary_text(&mut fragments, &mut seen, custom_title);
}
for fragment in generated_fragments {
push_normalized_unique_summary_text(&mut fragments, &mut seen, &fragment);
}
fragments
}
fn non_terminal_search_text_fragments(title: &str, subtitle: &str) -> Vec<String> {
let mut fragments = vec![title.to_string()];
if !subtitle.trim().is_empty() {
fragments.push(subtitle.to_string());
}
fragments
}
fn terminal_pane_search_text_fragments(
terminal_pane: &TerminalPane,
display_title_override: Option<&str>,
app: &AppContext,
) -> Vec<String> {
let terminal_view = terminal_pane.terminal_view(app);
let terminal_view = terminal_view.as_ref(app);
let title_text = terminal_view.terminal_title_from_shell();
let working_directory = terminal_view
.display_working_directory(app)
.filter(|wd| !wd.trim().is_empty())
.unwrap_or_else(|| title_text.clone());
let agent_text = terminal_agent_text(terminal_view, app);
let (conversation_display_title, cli_agent_title) =
preferred_agent_tab_titles(&agent_text, agent_tab_text_preference(app));
let primary_text = display_title_override
.map(str::to_owned)
.unwrap_or_else(|| {
terminal_primary_line_data(
terminal_view.is_long_running_and_user_controlled(),
conversation_display_title,
cli_agent_title,
title_text.as_str(),
working_directory.as_str(),
terminal_title_fallback_font(&agent_text),
terminal_view.last_completed_command_text(),
)
.text()
.to_string()
});
let pull_request_label = terminal_view
.current_pull_request_url(app)
.as_deref()
.map(terminal_pull_request_badge_label);
terminal_search_text_fragments(
primary_text,
working_directory,
terminal_view.current_git_branch(app),
terminal_kind_badge_label(agent_text.is_oz_agent, agent_text.cli_agent),
pull_request_label,
terminal_view.current_diff_line_changes(app),
)
}
fn terminal_search_text_fragments(
primary_text: String,
working_directory: String,
git_branch: Option<String>,
kind_badge_label: String,
pull_request_label: Option<String>,
diff_stats: Option<GitLineChanges>,
) -> Vec<String> {
let mut fragments = vec![primary_text, working_directory, kind_badge_label];
if let Some(git_branch) = git_branch.filter(|branch| !branch.trim().is_empty()) {
fragments.push(git_branch);
}
if let Some(pull_request_label) = pull_request_label.filter(|label| !label.trim().is_empty()) {
fragments.push(pull_request_label);
}
if let Some(diff_stats) = diff_stats {
fragments.push(vtab_diff_stats_text(&diff_stats));
}
fragments
}
fn terminal_primary_line_data(
is_long_running: bool,
conversation_display_title: Option<String>,
cli_agent_title: Option<String>,
terminal_title: &str,
working_directory: &str,
terminal_title_font: TerminalPrimaryLineFont,
last_completed_command: Option<String>,
) -> TerminalPrimaryLineData {
let trimmed_title = terminal_title.trim();
let trimmed_working_directory = working_directory.trim();
if let Some(cli_agent_title) = cli_agent_title {
return TerminalPrimaryLineData::StatusText {
text: cli_agent_title,
};
}
if is_long_running && !trimmed_title.is_empty() && trimmed_title != trimmed_working_directory {
return TerminalPrimaryLineData::Text {
text: trimmed_title.to_string(),
font: TerminalPrimaryLineFont::Monospace,
};
}
if let Some(conversation_title) = conversation_display_title {
return TerminalPrimaryLineData::StatusText {
text: conversation_title,
};
}
if !trimmed_title.is_empty() && trimmed_title != trimmed_working_directory {
return TerminalPrimaryLineData::Text {
text: trimmed_title.to_string(),
font: terminal_title_font,
};
}
if let Some(last_completed_command) = last_completed_command {
return TerminalPrimaryLineData::Text {
text: last_completed_command,
font: TerminalPrimaryLineFont::Monospace,
};
}
TerminalPrimaryLineData::Text {
text: "New session".to_string(),
font: TerminalPrimaryLineFont::Ui,
}
}
fn terminal_kind_badge_label(is_oz_agent: bool, cli_agent: Option<CLIAgent>) -> String {
if let Some(cli_agent) = cli_agent {
cli_agent.display_name().to_string()
} else if is_oz_agent {
"Oz".to_string()
} else {
"Terminal".to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentTabTextPreference {
ConversationTitle,
LatestUserPrompt,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct TerminalAgentText {
conversation_display_title: Option<String>,
conversation_latest_user_prompt: Option<String>,
cli_agent_title: Option<String>,
cli_agent_latest_user_prompt: Option<String>,
is_oz_agent: bool,
cli_agent: Option<CLIAgent>,
}
fn agent_tab_text_preference(app: &AppContext) -> AgentTabTextPreference {
if *TabSettings::as_ref(app).use_latest_user_prompt_as_conversation_title_in_tab_names {
AgentTabTextPreference::LatestUserPrompt
} else {
AgentTabTextPreference::ConversationTitle
}
}
fn preferred_agent_tab_titles(
agent_text: &TerminalAgentText,
preference: AgentTabTextPreference,
) -> (Option<String>, Option<String>) {
let conversation_title = match preference {
AgentTabTextPreference::ConversationTitle => agent_text
.conversation_display_title
.clone()
.or_else(|| agent_text.conversation_latest_user_prompt.clone()),
AgentTabTextPreference::LatestUserPrompt => agent_text
.conversation_latest_user_prompt
.clone()
.or_else(|| agent_text.conversation_display_title.clone()),
};
let cli_agent_title = match preference {
AgentTabTextPreference::ConversationTitle => agent_text.cli_agent_title.clone(),
AgentTabTextPreference::LatestUserPrompt => agent_text
.cli_agent_latest_user_prompt
.clone()
.or_else(|| agent_text.cli_agent_title.clone()),
};
(conversation_title, cli_agent_title)
}
fn terminal_agent_text(terminal_view: &TerminalView, app: &AppContext) -> TerminalAgentText {
let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id());
let is_plugin_backed = cli_agent_session.is_some_and(|session| session.listener.is_some());
let is_ambient_agent = terminal_view.is_ambient_agent_session(app);
let mut agent_text = TerminalAgentText {
is_oz_agent: is_ambient_agent,
cli_agent: cli_agent_session.map(|session| session.agent),
..Default::default()
};
if cli_agent_session.is_some() && !is_plugin_backed {
return agent_text;
}
agent_text.conversation_display_title = terminal_view.selected_conversation_display_title(app);
agent_text.conversation_latest_user_prompt =
terminal_view.selected_conversation_latest_user_prompt_for_tab_name(app);
agent_text.is_oz_agent =
agent_text.conversation_display_title.is_some() || agent_text.is_oz_agent;
if let Some(session) = cli_agent_session {
agent_text.cli_agent_title = session.session_context.title_like_text();
agent_text.cli_agent_latest_user_prompt = session.session_context.latest_user_prompt();
}
agent_text
}
fn terminal_pull_request_badge_label(pull_request_url: &str) -> String {
github_pr_display_text_from_url(pull_request_url)
.map(|label| label.strip_prefix("PR ").unwrap_or(&label).to_string())
.unwrap_or_else(|| pull_request_url.to_string())
}
fn vtab_diff_stats_tokens(line_changes: &GitLineChanges) -> Vec<String> {
let mut tokens = Vec::new();
if line_changes.lines_added > 0 {
tokens.push(format!("+{}", line_changes.lines_added));
}
if line_changes.lines_removed > 0 {
tokens.push(format!("-{}", line_changes.lines_removed));
}
if tokens.is_empty() {
tokens.push("0".to_string());
}
tokens
}
fn vtab_diff_stats_text(line_changes: &GitLineChanges) -> String {
vtab_diff_stats_tokens(line_changes).join(" ")
}
impl PaneGroup {
fn resolve_pane_type(&self, pane_id: PaneId, app: &AppContext) -> TypedPane<'_> {
match pane_id.pane_type() {
IPaneType::Terminal => TypedPane::Terminal(
self.downcast_pane_by_id::<TerminalPane>(pane_id)
.expect("IPaneType::Terminal must correspond to a TerminalPane"),
),
IPaneType::Code => TypedPane::Code(
self.downcast_pane_by_id::<CodePane>(pane_id)
.expect("IPaneType::Code must correspond to a CodePane"),
),
IPaneType::CodeDiff => TypedPane::CodeDiff,
IPaneType::File => TypedPane::File,
IPaneType::Notebook => {
let is_plan = self
.downcast_pane_by_id::<NotebookPane>(pane_id)
.map(|np| np.notebook_view(app).as_ref(app).is_plan(app))
.unwrap_or(false);
TypedPane::Notebook { is_plan }
}
IPaneType::Workflow => {
let is_ai_prompt = self
.downcast_pane_by_id::<WorkflowPane>(pane_id)
.map(|wp| {
let wv = wp.get_view(app);
wv.as_ref(app).is_agent_mode_workflow()
})
.unwrap_or(false);
TypedPane::Workflow { is_ai_prompt }
}
IPaneType::Settings => TypedPane::Settings,
IPaneType::EnvVarCollection => TypedPane::EnvVarCollection,
IPaneType::EnvironmentManagement => TypedPane::EnvironmentManagement,
IPaneType::AIFact => TypedPane::AIFact,
IPaneType::AIDocument => TypedPane::AIDocument,
IPaneType::ExecutionProfileEditor => TypedPane::ExecutionProfileEditor,
IPaneType::GetStarted
| IPaneType::NetworkLog
| IPaneType::Welcome
| IPaneType::DeferredPlaceholder => TypedPane::Other,
#[cfg(test)]
IPaneType::Dummy => TypedPane::Other,
}
}
}
fn render_terminal_row_content(
props: &PaneProps<'_>,
terminal_view: &TerminalView,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let main_text_color = theme.main_text_color(theme.background());
let sub_text_color = theme.sub_text_color(theme.background());
let primary_info = *TabSettings::as_ref(app).vertical_tabs_primary_info.value();
let title_text = terminal_view.terminal_title_from_shell();
let working_directory = terminal_view
.display_working_directory(app)
.filter(|wd| !wd.trim().is_empty())
.unwrap_or_else(|| title_text.clone());
let git_branch = terminal_view.current_git_branch(app);
// Line 1 and line 2 depend on the "Pane title as" setting.
// Line 3 (metadata) shows context data on the left + badges on the right.
//
// | Setting | Line 1 (title) | Line 2 (description) | Line 3 left |
// |------------------|----------------------|------------------------|----------------------|
// | Command | command/conversation | working directory | git branch |
// | WorkingDirectory | working directory | command/conversation | git branch |
// | Branch | git branch | command/conversation | working directory |
let (first_line, second_line, metadata_left) = match primary_info {
VerticalTabsPrimaryInfo::Command => (
render_pane_title_slot(
props,
|| {
render_terminal_primary_line_for_view(
terminal_view,
appearance,
main_text_color,
app,
)
},
12.,
main_text_color,
ClipConfig::ellipsis(),
appearance,
app,
),
render_text_line(
&working_directory,
sub_text_color,
ClipConfig::start(),
appearance,
),
MetadataLeftContent::GitBranch(git_branch),
),
VerticalTabsPrimaryInfo::WorkingDirectory => (
render_pane_title_slot(
props,
|| {
render_text_line(
&working_directory,
main_text_color,
ClipConfig::start(),
appearance,
)
},
12.,
main_text_color,
ClipConfig::ellipsis(),
appearance,
app,
),
render_terminal_primary_line_for_view(terminal_view, appearance, sub_text_color, app),
MetadataLeftContent::GitBranch(git_branch),
),
VerticalTabsPrimaryInfo::Branch => {
let (branch_text, show_branch_icon) =
branch_label_display(git_branch.as_deref(), working_directory.as_str());
(
render_pane_title_slot(
props,
|| {
if show_branch_icon {
render_git_branch_text(&branch_text, main_text_color, 12., appearance)
} else {
render_text_line(
&branch_text,
main_text_color,
ClipConfig::start(),
appearance,
)
}
},
12.,
main_text_color,
ClipConfig::ellipsis(),
appearance,
app,
),
render_terminal_primary_line_for_view(
terminal_view,
appearance,
sub_text_color,
app,
),
MetadataLeftContent::WorkingDirectory(working_directory),
)
}
};
let first_line_element = if has_unread_activity(&props.typed, app) {
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(Shrinkable::new(1., first_line).finish())
.with_child(
Container::new(render_title_indicator(theme))
.with_margin_left(4.)
.finish(),
)
.finish()
} else {
first_line
};
let mut content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start);
content.add_child(first_line_element);
content.add_child(Container::new(second_line).with_margin_top(2.).finish());
content.add_child(
Container::new(render_terminal_metadata_line(
terminal_view,
props.pane_group_id,
props.pane_id,
metadata_left,
chip_entrypoint_for_granularity(props.display_granularity),
&props.badge_mouse_states,
appearance,
app,
))
.with_margin_top(2.)
.finish(),
);
content.finish()
}
fn chip_entrypoint_for_granularity(
granularity: VerticalTabsDisplayGranularity,
) -> VerticalTabsChipEntrypoint {
match granularity {
VerticalTabsDisplayGranularity::Panes => VerticalTabsChipEntrypoint::Pane,
VerticalTabsDisplayGranularity::Tabs => VerticalTabsChipEntrypoint::Tab,
}
}
fn branch_label_display(git_branch: Option<&str>, fallback: &str) -> (String, bool) {
match git_branch.filter(|branch| !branch.trim().is_empty()) {
Some(branch) => (branch.to_string(), true),
None => (fallback.to_string(), false),
}
}
fn compact_branch_subtitle_display(
git_branch: Option<&str>,
working_directory: Option<&str>,
) -> Option<(String, bool)> {
git_branch
.filter(|branch| !branch.trim().is_empty())
.map(|branch| (branch.to_string(), true))
.or_else(|| {
working_directory
.filter(|wd| !wd.trim().is_empty())
.map(|wd| (wd.to_string(), false))
})
}
fn render_git_branch_text(
branch: &str,
text_color: WarpThemeFill,
font_size: f32,
appearance: &Appearance,
) -> Box<dyn Element> {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(2.)
.with_child(
ConstrainedBox::new(UiIcon::GitBranch.to_galaxyui_icon(text_color).finish())
.with_width(font_size - 2.)
.with_height(font_size - 2.)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
Text::new_inline(branch.to_string(), appearance.ui_font_family(), font_size)
.with_clip(ClipConfig::ellipsis())
.with_color(text_color.into())
.finish(),
)
.finish(),
)
.finish()
}
enum MetadataLeftContent {
GitBranch(Option<String>),
WorkingDirectory(String),
}
fn render_text_line(
text: &str,
text_color: WarpThemeFill,
clip: ClipConfig,
appearance: &Appearance,
) -> Box<dyn Element> {
Text::new_inline(text.to_string(), appearance.ui_font_family(), 12.)
.with_clip(clip)
.with_color(text_color.into())
.finish()
}
fn render_inline_tab_rename_editor(
rename_editor: &ViewHandle<EditorView>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let editor_line_height = rename_editor
.as_ref(app)
.line_height(app.font_cache(), appearance);
TextInput::new(
rename_editor.clone(),
UiComponentStyles::default()
.set_height(editor_line_height)
.set_background(ElementFill::None)
.set_border_radius(CornerRadius::with_all(Radius::Pixels(0.)))
.set_border_width(0.),
)
.build()
.finish()
}
fn render_title_override(
props: &PaneProps<'_>,
font_size: f32,
text_color: WarpThemeFill,
clip: ClipConfig,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if props.is_tab_being_renamed {
return props
.rename_editor
.as_ref()
.map(|rename_editor| render_inline_tab_rename_editor(rename_editor, appearance, app));
}
if props.is_pane_being_renamed {
return props
.pane_rename_editor
.as_ref()
.map(|rename_editor| render_inline_tab_rename_editor(rename_editor, appearance, app));
}
props
.custom_vertical_tabs_title
.as_ref()
.or(props.display_title_override.as_ref())
.map(|title| {
Text::new_inline(title.clone(), appearance.ui_font_family(), font_size)
.with_clip(clip)
.with_color(text_color.into())
.finish()
})
}
fn render_pane_title_slot(
props: &PaneProps<'_>,
generated_title: impl FnOnce() -> Box<dyn Element>,
font_size: f32,
text_color: WarpThemeFill,
clip: ClipConfig,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let title = render_title_override(props, font_size, text_color, clip, appearance, app)
.unwrap_or_else(generated_title);
if !matches!(
props.display_granularity,
VerticalTabsDisplayGranularity::Panes
) || props.shows_inline_tab_rename_editor()
{
return title;
}
let Some(title_mouse_state) = props.title_mouse_state.clone() else {
return title;
};
let locator = PaneViewLocator {
pane_group_id: props.pane_group_id,
pane_id: props.pane_id,
};
Hoverable::new(title_mouse_state, move |_| title)
.on_double_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::RenamePane(locator));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_summary_tab_item(
props: PaneProps<'_>,
summary: &VerticalTabsSummaryData,
summary_pane_kind_icons: Option<SummaryPaneKindIcons>,
app: &AppContext,
) -> Box<dyn Element> {
const MAX_VISIBLE_PRIMARY_LABELS: usize = 4;
const MAX_VISIBLE_BRANCH_LINES: usize = 3;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let main_text_color = theme.main_text_color(theme.background());
let sub_text_color = theme.sub_text_color(theme.background());
let icon = summary_pane_kind_icons
.map(|icons| render_summary_pane_kind_icons(icons, appearance))
.unwrap_or_else(|| {
render_pane_icon_with_status(
resolve_icon_with_status_variant(&props.typed, &props.title, appearance, app),
theme,
)
});
let primary_line_text =
format_summary_primary_labels(&summary.primary_labels, MAX_VISIBLE_PRIMARY_LABELS)
.unwrap_or_else(|| props.title.clone());
let mut text_col = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start);
text_col.add_child(
render_title_override(
&props,
12.,
main_text_color,
ClipConfig::end(),
appearance,
app,
)
.unwrap_or_else(|| {
render_text_line(
&primary_line_text,
main_text_color,
ClipConfig::end(),
appearance,
)
}),
);
if !summary.working_directories.is_empty() {
text_col.add_child(
Container::new(render_text_line(
&summary.working_directories.join(" • "),
sub_text_color,
ClipConfig::start(),
appearance,
))
.with_margin_top(1.)
.finish(),
);
}
for branch_entry in summary.branch_entries.iter().take(MAX_VISIBLE_BRANCH_LINES) {
text_col.add_child(
Container::new(render_summary_branch_line(branch_entry, appearance))
.with_margin_top(4.)
.finish(),
);
}
let hidden_branch_count =
summary_overflow_count(summary.branch_entries.len(), MAX_VISIBLE_BRANCH_LINES);
if hidden_branch_count > 0 {
text_col.add_child(
Container::new(
Text::new_inline(
format!("+ {hidden_branch_count} more"),
appearance.ui_font_family(),
10.,
)
.with_clip(ClipConfig::end())
.with_color(sub_text_color.into())
.finish(),
)
.with_margin_top(4.)
.finish(),
);
}
let content = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(ICON_WITH_STATUS_GAP)
.with_child(icon)
.with_child(Shrinkable::new(1., text_col.finish()).finish())
.finish();
render_pane_row_element(props, Padding::uniform(8.), true, content, theme)
}
fn render_summary_pane_kind_icons(
icons: SummaryPaneKindIcons,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
match icons {
SummaryPaneKindIcons::Single(kind) => render_summary_pane_kind_icon_circle(
kind,
VERTICAL_TABS_SIZING.icon_size,
VERTICAL_TABS_SIZING.padding,
appearance,
),
SummaryPaneKindIcons::Pair { primary, secondary } => {
let sizing = &VERTICAL_TABS_AGENT_SIZING;
let circle_size = sizing.icon_size + sizing.padding * 2.;
let overall_size = sizing.overall_size_override.unwrap_or(circle_size);
let primary_icon = render_summary_pane_kind_icon_circle(
primary,
sizing.icon_size,
sizing.padding,
appearance,
);
let secondary_icon = render_summary_pane_kind_icon_circle(
secondary,
sizing.badge_icon_size,
sizing.badge_padding,
appearance,
);
let secondary_with_ring = Container::new(secondary_icon)
.with_uniform_padding(sizing.badge_padding)
.with_background(theme.background())
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish();
let mut stack = Stack::new().with_child(
ConstrainedBox::new(primary_icon)
.with_width(overall_size)
.with_height(overall_size)
.finish(),
);
stack.add_positioned_child(
secondary_with_ring,
OffsetPositioning::offset_from_parent(
vec2f(sizing.badge_offset.0, sizing.badge_offset.1),
ParentOffsetBounds::ParentBySize,
ParentAnchor::BottomRight,
ChildAnchor::BottomRight,
),
);
ConstrainedBox::new(stack.finish())
.with_width(overall_size)
.with_height(overall_size)
.finish()
}
}
}
fn render_summary_pane_kind_icon_circle(
kind: SummaryPaneKind,
icon_size: f32,
padding: f32,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let (icon_element, background): (Box<dyn Element>, ElementFill) = match kind {
SummaryPaneKind::OzAgent { is_ambient } => {
let icon = if is_ambient {
WarpIcon::OzCloud
} else {
WarpIcon::Oz
};
(
icon.to_galaxyui_icon(oz_icon_fill(theme)).finish(),
theme.background().into(),
)
}
SummaryPaneKind::CLIAgent { agent } => {
let icon_color = agent.brand_icon_color();
let icon_element = agent
.icon()
.map(|icon| {
icon.to_galaxyui_icon(WarpThemeFill::Solid(icon_color))
.finish()
})
.unwrap_or_else(|| {
WarpIcon::Terminal
.to_galaxyui_icon(theme.sub_text_color(theme.background()))
.finish()
});
(
icon_element,
ThemeFill::Solid(
agent
.brand_color()
.unwrap_or(ColorU::new(100, 100, 100, 255)),
)
.into(),
)
}
SummaryPaneKind::Code { title } => (
icon_from_file_path(&title, appearance).unwrap_or_else(|| {
WarpIcon::Code2
.to_galaxyui_icon(theme.sub_text_color(theme.background()))
.finish()
}),
internal_colors::fg_overlay_2(theme).into(),
),
SummaryPaneKind::Terminal
| SummaryPaneKind::CodeDiff
| SummaryPaneKind::File
| SummaryPaneKind::Notebook { .. }
| SummaryPaneKind::Workflow { .. }
| SummaryPaneKind::Settings
| SummaryPaneKind::EnvVarCollection
| SummaryPaneKind::EnvironmentManagement
| SummaryPaneKind::AIFact
| SummaryPaneKind::AIDocument
| SummaryPaneKind::ExecutionProfileEditor
| SummaryPaneKind::Other => {
let (icon, icon_color) = summary_pane_kind_icon(kind, appearance);
(
icon.to_galaxyui_icon(icon_color).finish(),
internal_colors::fg_overlay_2(theme).into(),
)
}
};
Container::new(
ConstrainedBox::new(icon_element)
.with_width(icon_size)
.with_height(icon_size)
.finish(),
)
.with_uniform_padding(padding)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
(icon_size + padding * 2.) / 2.,
)))
.finish()
}
fn summary_pane_kind_icon(
kind: SummaryPaneKind,
appearance: &Appearance,
) -> (WarpIcon, WarpThemeFill) {
let theme = appearance.theme();
let main_text = theme.main_text_color(theme.background());
let sub_text = theme.sub_text_color(theme.background());
let drive_color = |object_type: DriveObjectType| -> WarpThemeFill {
WarpThemeFill::Solid(warp_drive_icon_color(appearance, object_type))
};
match kind {
SummaryPaneKind::Terminal => (WarpIcon::Terminal, main_text),
SummaryPaneKind::OzAgent { is_ambient } => (
if is_ambient {
WarpIcon::OzCloud
} else {
WarpIcon::Oz
},
main_text,
),
SummaryPaneKind::CLIAgent { agent } => (
agent.icon().unwrap_or(WarpIcon::Terminal),
WarpThemeFill::Solid(agent.brand_icon_color()),
),
SummaryPaneKind::Code { .. } => (WarpIcon::Code2, sub_text),
SummaryPaneKind::CodeDiff => (WarpIcon::Diff, sub_text),
SummaryPaneKind::File => (WarpIcon::File, sub_text),
SummaryPaneKind::Notebook { is_plan } => (
if is_plan {
WarpIcon::Compass
} else {
WarpIcon::Notebook
},
drive_color(DriveObjectType::Notebook {
is_ai_document: is_plan,
}),
),
SummaryPaneKind::Workflow { is_ai_prompt } => (
if is_ai_prompt {
WarpIcon::Prompt
} else {
WarpIcon::Workflow
},
if is_ai_prompt {
drive_color(DriveObjectType::AgentModeWorkflow)
} else {
drive_color(DriveObjectType::Workflow)
},
),
SummaryPaneKind::Settings | SummaryPaneKind::EnvironmentManagement => {
(WarpIcon::Gear, main_text)
}
SummaryPaneKind::EnvVarCollection => (
WarpIcon::EnvVarCollection,
drive_color(DriveObjectType::EnvVarCollection),
),
SummaryPaneKind::AIFact => (WarpIcon::BookOpen, drive_color(DriveObjectType::AIFact)),
SummaryPaneKind::AIDocument => (WarpIcon::Compass, sub_text),
SummaryPaneKind::ExecutionProfileEditor => (WarpIcon::Lightning, sub_text),
SummaryPaneKind::Other => (WarpIcon::File, sub_text),
}
}
fn render_summary_branch_line(
entry: &VerticalTabsSummaryBranchEntry,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let sub_text_color = theme.sub_text_color(theme.background());
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Shrinkable::new(
1.,
render_git_branch_text(&entry.branch_name, sub_text_color, 10., appearance),
)
.finish(),
);
let mut right_badges = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.);
let mut has_right_badges = false;
if let Some(diff_stats) = &entry.diff_stats {
right_badges.add_child(render_passive_terminal_diff_stats_badge(
diff_stats, appearance,
));
has_right_badges = true;
}
if let Some(pull_request_label) = &entry.pull_request_label {
right_badges.add_child(render_passive_terminal_pull_request_badge(
pull_request_label,
appearance,
));
has_right_badges = true;
}
if has_right_badges {
row.add_child(
Container::new(right_badges.finish())
.with_padding_left(4.)
.finish(),
);
}
ConstrainedBox::new(row.finish())
.with_height(METADATA_ROW_HEIGHT)
.finish()
}
fn render_terminal_primary_line_for_view(
terminal_view: &TerminalView,
appearance: &Appearance,
text_color: WarpThemeFill,
app: &AppContext,
) -> Box<dyn Element> {
let title_text = terminal_view.terminal_title_from_shell();
let working_directory = terminal_view
.display_working_directory(app)
.filter(|wd| !wd.trim().is_empty())
.unwrap_or_else(|| title_text.clone());
let agent_text = terminal_agent_text(terminal_view, app);
let (conversation_display_title, cli_agent_title) =
preferred_agent_tab_titles(&agent_text, agent_tab_text_preference(app));
render_terminal_primary_line(
terminal_primary_line_data(
terminal_view.is_long_running_and_user_controlled(),
conversation_display_title,
cli_agent_title,
title_text.as_str(),
working_directory.as_str(),
terminal_title_fallback_font(&agent_text),
terminal_view.last_completed_command_text(),
),
terminal_view,
appearance,
text_color,
)
}
/// Primary line for terminal pane rows. Precedence:
/// 1. CLI agent session with plugin data (query/summary) + status
/// 2. Oz agent conversation title + status
/// 3. Terminal title
fn render_terminal_primary_line(
primary_line: TerminalPrimaryLineData,
terminal_view: &TerminalView,
appearance: &Appearance,
text_color: WarpThemeFill,
) -> Box<dyn Element> {
let theme = appearance.theme();
let is_errored = terminal_view.current_state().state == TerminalViewState::Errored;
let error_color = theme.ui_error_color();
let wrap_with_error_indicator = |title_element: Box<dyn Element>| -> Box<dyn Element> {
if !is_errored {
return title_element;
}
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.)
.with_child(
ConstrainedBox::new(
UiIcon::AlertTriangle
.to_galaxyui_icon(error_color.into())
.finish(),
)
.with_width(BADGE_ICON_SIZE)
.with_height(BADGE_ICON_SIZE)
.finish(),
)
.with_child(Shrinkable::new(1., title_element).finish())
.finish()
};
match primary_line {
TerminalPrimaryLineData::StatusText { text, .. } => {
Text::new_inline(text, appearance.ui_font_family(), 12.)
.with_clip(ClipConfig::ellipsis())
.with_color(text_color.into())
.finish()
}
TerminalPrimaryLineData::Text { text, font } => {
let font_family = match font {
TerminalPrimaryLineFont::Ui => appearance.ui_font_family(),
TerminalPrimaryLineFont::Monospace => appearance.monospace_font_family(),
};
let title_el = Text::new_inline(text, font_family, 12.)
.with_clip(ClipConfig::ellipsis())
.with_color(text_color.into())
.finish();
wrap_with_error_indicator(title_el)
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_terminal_metadata_line(
terminal_view: &TerminalView,
pane_group_id: EntityId,
pane_id: PaneId,
left_content: MetadataLeftContent,
row_entrypoint: VerticalTabsChipEntrypoint,
badge_mouse_states: &PaneRowBadgeMouseStates,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let sub_text_color = theme.sub_text_color(theme.background());
let mut meta = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Left: Shrinkable so it clips before reaching the right-side badges.
let left_element: Box<dyn Element> = match left_content {
MetadataLeftContent::GitBranch(Some(branch)) if !branch.trim().is_empty() => {
Shrinkable::new(
1.,
render_git_branch_text(&branch, sub_text_color, 10., appearance),
)
.finish()
}
MetadataLeftContent::WorkingDirectory(wd) if !wd.trim().is_empty() => Shrinkable::new(
1.,
Text::new_inline(wd, appearance.ui_font_family(), 10.)
.with_clip(ClipConfig::start())
.with_color(sub_text_color.into())
.finish(),
)
.finish(),
_ => Empty::new().finish(),
};
meta.add_child(left_element);
// Right: wrap badges in a container with left padding equal to the inter-chip gap (4px).
// SpaceBetween treats this padding as part of the right element's natural width, so when the
// panel is narrow and the Shrinkable text has fully collapsed, there is still a guaranteed
// 4px gap between the text and the first chip — matching the spacing between chips.
if let Some(right_badges) = render_terminal_right_badges(
terminal_view,
pane_group_id,
pane_id,
row_entrypoint,
badge_mouse_states,
appearance,
app,
) {
meta.add_child(Container::new(right_badges).with_padding_left(4.).finish());
}
// Constrain to a fixed height so toggling badges on/off doesn't change the row height.
ConstrainedBox::new(meta.finish())
.with_height(METADATA_ROW_HEIGHT)
.finish()
}
fn render_terminal_right_badges(
terminal_view: &TerminalView,
pane_group_id: EntityId,
pane_id: PaneId,
entrypoint: VerticalTabsChipEntrypoint,
badge_mouse_states: &PaneRowBadgeMouseStates,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let show_diff_stats = *TabSettings::as_ref(app)
.vertical_tabs_show_diff_stats
.value();
let show_pr_link = *TabSettings::as_ref(app).vertical_tabs_show_pr_link.value();
let mut right_badges = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.);
let mut has_badges = false;
if show_diff_stats {
if let Some(git_line_changes) = terminal_view.current_diff_line_changes(app) {
right_badges.add_child(render_terminal_diff_stats_badge(
&git_line_changes,
pane_group_id,
pane_id,
entrypoint,
badge_mouse_states.diff_stats.clone(),
appearance,
));
has_badges = true;
}
}
if show_pr_link {
if let Some(pull_request_url) = terminal_view.current_pull_request_url(app) {
let label = terminal_pull_request_badge_label(&pull_request_url);
right_badges.add_child(render_terminal_pull_request_badge(
label,
pull_request_url,
entrypoint,
badge_mouse_states.pull_request.clone(),
appearance,
));
has_badges = true;
}
}
has_badges.then(|| right_badges.finish())
}
fn render_terminal_diff_stats_badge(
git_line_changes: &GitLineChanges,
pane_group_id: EntityId,
pane_id: PaneId,
entrypoint: VerticalTabsChipEntrypoint,
mouse_state: MouseStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
Hoverable::new(mouse_state, move |state| {
let bg = if state.is_hovered() {
internal_colors::fg_overlay_2(theme)
} else {
internal_colors::fg_overlay_1(theme)
};
render_badge_container(
render_vtab_diff_stats_content(git_line_changes, appearance),
bg,
)
})
.on_click(move |ctx, app, _| {
send_telemetry_from_app_ctx!(
VerticalTabsTelemetryEvent::DiffStatsChipClicked { entrypoint },
app
);
let locator = PaneViewLocator {
pane_group_id,
pane_id,
};
ctx.dispatch_typed_action(WorkspaceAction::FocusPane(locator));
ctx.dispatch_typed_action(WorkspaceAction::OpenCodeReviewPanel(locator));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_terminal_pull_request_badge(
label: String,
url: String,
entrypoint: VerticalTabsChipEntrypoint,
mouse_state: MouseStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
Hoverable::new(mouse_state, move |state| {
let bg = if state.is_hovered() {
internal_colors::fg_overlay_2(theme)
} else {
internal_colors::fg_overlay_1(theme)
};
render_badge_container(render_pull_request_badge_content(&label, appearance), bg)
})
.on_click(move |ctx, app, _| {
send_telemetry_from_app_ctx!(
VerticalTabsTelemetryEvent::PrChipClicked { entrypoint },
app
);
ctx.dispatch_typed_action(WorkspaceAction::OpenLink(url.clone()));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_passive_terminal_pull_request_badge(
label: &str,
appearance: &Appearance,
) -> Box<dyn Element> {
render_badge_container(
render_pull_request_badge_content(label, appearance),
internal_colors::fg_overlay_1(appearance.theme()),
)
}
fn render_compact_non_terminal_title(
title: &str,
typed: &TypedPane<'_>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let clip_config = if matches!(typed, TypedPane::Code(_)) {
ClipConfig::start()
} else {
ClipConfig::ellipsis()
};
Text::new_inline(title.to_string(), appearance.ui_font_family(), 12.)
.with_clip(clip_config)
.with_color(theme.main_text_color(theme.background()).into())
.finish()
}
fn render_vtab_diff_stats_content(
line_changes: &GitLineChanges,
appearance: &Appearance,
) -> Box<dyn Element> {
let font_family = appearance.ui_font_family();
let font_size = 10.;
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
for (index, token) in vtab_diff_stats_tokens(line_changes).iter().enumerate() {
if index > 0 {
row.add_child(Text::new_inline(" ", font_family, font_size).finish());
}
let color = if token.starts_with('+') {
add_color(appearance)
} else if token.starts_with('-') {
remove_color(appearance)
} else {
internal_colors::neutral_6(appearance.theme())
};
row.add_child(
Text::new_inline(token.clone(), font_family, font_size)
.with_color(color)
.with_style(Properties::default().weight(Weight::Semibold))
.finish(),
);
}
row.finish()
}
fn render_badge_container(content: Box<dyn Element>, background: ThemeFill) -> Box<dyn Element> {
Container::new(content)
.with_padding(Padding::uniform(1.).with_left(4.).with_right(4.))
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
.finish()
}
fn render_pull_request_badge_content(label: &str, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let main_text_color = theme.main_text_color(theme.background());
let sub_text_color = theme.sub_text_color(theme.background());
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.)
.with_child(
ConstrainedBox::new(UiIcon::Github.to_galaxyui_icon(main_text_color).finish())
.with_width(BADGE_ICON_SIZE)
.with_height(BADGE_ICON_SIZE)
.finish(),
)
.with_child(
Text::new_inline(label.to_string(), appearance.ui_font_family(), 10.)
.with_color(sub_text_color.into())
.finish(),
)
.finish()
}
fn compute_tab_group_color_mode(
tab: &TabData,
pane_group: &PaneGroup,
visible_pane_ids: &[PaneId],
theme: &WarpTheme,
app: &AppContext,
) -> TabGroupColorMode {
// Manual override applies to the whole group.
if !matches!(tab.selected_color, SelectedTabColor::Unset) {
return match tab.color() {
Some(color) => TabGroupColorMode::Uniform(
color.to_ansi_color(&theme.terminal_colors().normal).into(),
),
None => TabGroupColorMode::None,
};
}
let dir_colors = TabSettings::as_ref(app)
.directory_tab_colors
.value()
.clone();
let per_pane: HashMap<PaneId, Option<AnsiColorIdentifier>> = visible_pane_ids
.iter()
.map(|&pane_id| {
let color = if let Some(tv) = pane_group.terminal_view_from_pane_id(pane_id, app) {
// Terminal pane: determine color from CWD.
tv.as_ref(app).pwd_if_local(app).and_then(|cwd| {
dir_colors
.color_for_directory(Path::new(&cwd))
.and_then(|c| c.ansi_color())
})
} else if let Some(code_view) = pane_group.code_view_from_pane_id(pane_id, app) {
// Code pane: determine color from the open file path using longest-prefix
// matching against configured directories, so e.g. warp-internal/code.rs
// inherits the color assigned to warp-internal.
code_view
.as_ref(app)
.local_path(app)
.as_deref()
.and_then(|file_path| {
dir_colors
.color_for_directory(file_path)
.and_then(|c| c.ansi_color())
})
} else {
// Other non-terminal panes (notebook, workflow, etc.): fall back to the
// cached directory color from the tab's last active terminal.
tab.default_directory_color
};
(pane_id, color)
})
.collect();
let has_uncolored = per_pane.values().any(|c| c.is_none());
let mut distinct_colors: Vec<AnsiColorIdentifier> = Vec::new();
for color in per_pane.values().flatten() {
if !distinct_colors.contains(color) {
distinct_colors.push(*color);
}
}
// Uniform only when every pane has a color and they all match.
let is_uniform = !has_uncolored && distinct_colors.len() == 1;
if distinct_colors.is_empty() {
TabGroupColorMode::None
} else if is_uniform {
let color = distinct_colors[0];
TabGroupColorMode::Uniform(color.to_ansi_color(&theme.terminal_colors().normal).into())
} else {
let theme_map = per_pane
.into_iter()
.map(|(id, c)| {
let fill = c.map(|c| c.to_ansi_color(&theme.terminal_colors().normal).into());
(id, fill)
})
.collect();
TabGroupColorMode::PerPane(theme_map)
}
}
fn resolve_compact_subtitle(
primary: VerticalTabsPrimaryInfo,
subtitle_pref: VerticalTabsCompactSubtitle,
) -> VerticalTabsCompactSubtitle {
let is_conflict = matches!(
(primary, subtitle_pref),
(
VerticalTabsPrimaryInfo::Command,
VerticalTabsCompactSubtitle::Command
) | (
VerticalTabsPrimaryInfo::WorkingDirectory,
VerticalTabsCompactSubtitle::WorkingDirectory
) | (
VerticalTabsPrimaryInfo::Branch,
VerticalTabsCompactSubtitle::Branch
)
);
if is_conflict {
default_compact_subtitle(primary)
} else {
subtitle_pref
}
}
fn default_compact_subtitle(primary: VerticalTabsPrimaryInfo) -> VerticalTabsCompactSubtitle {
match primary {
VerticalTabsPrimaryInfo::Command => VerticalTabsCompactSubtitle::Branch,
VerticalTabsPrimaryInfo::WorkingDirectory => VerticalTabsCompactSubtitle::Branch,
VerticalTabsPrimaryInfo::Branch => VerticalTabsCompactSubtitle::Command,
}
}
fn subtitle_options_for_primary(
primary: VerticalTabsPrimaryInfo,
) -> [(VerticalTabsCompactSubtitle, &'static str); 2] {
match primary {
VerticalTabsPrimaryInfo::Command => [
(VerticalTabsCompactSubtitle::Branch, "Branch"),
(
VerticalTabsCompactSubtitle::WorkingDirectory,
"Working Directory",
),
],
VerticalTabsPrimaryInfo::WorkingDirectory => [
(VerticalTabsCompactSubtitle::Branch, "Branch"),
(
VerticalTabsCompactSubtitle::Command,
"Command / Conversation",
),
],
VerticalTabsPrimaryInfo::Branch => [
(
VerticalTabsCompactSubtitle::Command,
"Command / Conversation",
),
(
VerticalTabsCompactSubtitle::WorkingDirectory,
"Working Directory",
),
],
}
}
pub(super) fn render_settings_popup(
state: &VerticalTabsPanelState,
app: &AppContext,
) -> Box<dyn Element> {
const SETTINGS_POPUP_CORNER_RADIUS: f32 = 6.;
const SETTINGS_POPUP_MENU_ITEM_FONT_SIZE: f32 = 12.;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let current_granularity = *TabSettings::as_ref(app)
.vertical_tabs_display_granularity
.value();
let current_tab_item_mode = *TabSettings::as_ref(app).vertical_tabs_tab_item_mode.value();
let current_mode = *TabSettings::as_ref(app).vertical_tabs_view_mode.value();
let current_primary_info = *TabSettings::as_ref(app).vertical_tabs_primary_info.value();
let current_subtitle = resolve_compact_subtitle(
current_primary_info,
*TabSettings::as_ref(app)
.vertical_tabs_compact_subtitle
.value(),
);
let show_pr_link = *TabSettings::as_ref(app).vertical_tabs_show_pr_link.value();
let show_diff_stats = *TabSettings::as_ref(app)
.vertical_tabs_show_diff_stats
.value();
let show_details_on_hover = *TabSettings::as_ref(app)
.vertical_tabs_show_details_on_hover
.value();
let show_tab_item_section = matches!(current_granularity, VerticalTabsDisplayGranularity::Tabs)
&& FeatureFlag::VerticalTabsSummaryMode.is_enabled();
let show_focused_session_controls = !matches!(
resolve_vertical_tabs_mode(app),
VerticalTabsResolvedMode::Summary
);
let sub_text = theme.sub_text_color(theme.background());
let view_as_header = Container::new(
Text::new_inline(
"View as".to_string(),
appearance.ui_font_family(),
SETTINGS_POPUP_MENU_ITEM_FONT_SIZE,
)
.with_color(sub_text.into())
.finish(),
)
.with_horizontal_padding(16.)
.with_margin_bottom(4.)
.finish();
let view_as_segmented_control = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Expanded::new(
1.,
render_popup_text_segment(
"Panes",
matches!(current_granularity, VerticalTabsDisplayGranularity::Panes),
state.panes_segment_mouse_state.clone(),
VerticalTabsDisplayGranularity::Panes,
appearance,
theme,
),
)
.finish(),
)
.with_child(
Expanded::new(
1.,
render_popup_text_segment(
"Tabs",
matches!(current_granularity, VerticalTabsDisplayGranularity::Tabs),
state.tabs_segment_mouse_state.clone(),
VerticalTabsDisplayGranularity::Tabs,
appearance,
theme,
),
)
.finish(),
)
.finish(),
)
.with_uniform_padding(4.)
.with_background(internal_colors::fg_overlay_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
SETTINGS_POPUP_CORNER_RADIUS,
)))
.finish();
let view_as_segmented_control_row = Container::new(view_as_segmented_control)
.with_horizontal_padding(16.)
.with_padding_bottom(4.)
.finish();
let tab_item_header = Container::new(
Text::new_inline(
"Tab item".to_string(),
appearance.ui_font_family(),
SETTINGS_POPUP_MENU_ITEM_FONT_SIZE,
)
.with_color(sub_text.into())
.finish(),
)
.with_horizontal_padding(16.)
.with_margin_bottom(4.)
.finish();
let focused_session_option = render_tab_item_mode_option(
"Focused session",
matches!(
current_tab_item_mode,
VerticalTabsTabItemMode::FocusedSession
),
state.focused_session_option_mouse_state.clone(),
VerticalTabsTabItemMode::FocusedSession,
appearance,
theme,
);
let summary_option = if FeatureFlag::VerticalTabsSummaryMode.is_enabled() {
Some(render_tab_item_mode_option(
"Summary",
matches!(current_tab_item_mode, VerticalTabsTabItemMode::Summary),
state.summary_option_mouse_state.clone(),
VerticalTabsTabItemMode::Summary,
appearance,
theme,
))
} else {
None
};
let density_header = Container::new(
Text::new_inline(
"Density".to_string(),
appearance.ui_font_family(),
SETTINGS_POPUP_MENU_ITEM_FONT_SIZE,
)
.with_color(sub_text.into())
.finish(),
)
.with_horizontal_padding(16.)
.with_margin_bottom(4.)
.finish();
// Segmented control row (compact/expanded toggle)
// Segmented control row (compact/expanded toggle) — always at the top
let segmented_control = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Expanded::new(
1.,
render_popup_segment(
WarpIcon::Menu01,
matches!(current_mode, VerticalTabsViewMode::Compact),
state.compact_segment_mouse_state.clone(),
VerticalTabsViewMode::Compact,
theme,
sub_text,
),
)
.finish(),
)
.with_child(
Expanded::new(
1.,
render_popup_segment(
WarpIcon::Grid,
matches!(current_mode, VerticalTabsViewMode::Expanded),
state.expanded_segment_mouse_state.clone(),
VerticalTabsViewMode::Expanded,
theme,
sub_text,
),
)
.finish(),
)
.finish(),
)
.with_uniform_padding(4.)
.with_background(internal_colors::fg_overlay_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
SETTINGS_POPUP_CORNER_RADIUS,
)))
.finish();
let segmented_control_row = Container::new(segmented_control)
.with_horizontal_padding(16.)
.with_padding_bottom(4.)
.finish();
// Divider between toggle and "Pane title as" section
let make_divider = |theme: &WarpTheme| {
Container::new(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background(internal_colors::fg_overlay_2(theme))
.finish(),
)
.with_height(1.)
.finish(),
)
.with_margin_top(8.)
.with_margin_bottom(8.)
.finish()
};
let pane_title_header = Container::new(
Text::new_inline(
"Pane title as".to_string(),
appearance.ui_font_family(),
SETTINGS_POPUP_MENU_ITEM_FONT_SIZE,
)
.with_color(sub_text.into())
.finish(),
)
.with_horizontal_padding(16.)
.with_margin_bottom(4.)
.finish();
let command_option = render_primary_info_option(
"Command / Conversation",
matches!(current_primary_info, VerticalTabsPrimaryInfo::Command),
state.command_option_mouse_state.clone(),
VerticalTabsPrimaryInfo::Command,
appearance,
theme,
);
let directory_option = render_primary_info_option(
"Working Directory",
matches!(
current_primary_info,
VerticalTabsPrimaryInfo::WorkingDirectory
),
state.directory_option_mouse_state.clone(),
VerticalTabsPrimaryInfo::WorkingDirectory,
appearance,
theme,
);
let branch_option = render_primary_info_option(
"Branch",
matches!(current_primary_info, VerticalTabsPrimaryInfo::Branch),
state.branch_option_mouse_state.clone(),
VerticalTabsPrimaryInfo::Branch,
appearance,
theme,
);
// Assemble popup — top-level display granularity first, then density and pane-row sections.
let mut popup_col = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
popup_col.add_child(view_as_header);
popup_col.add_child(view_as_segmented_control_row);
if show_tab_item_section {
popup_col.add_child(make_divider(theme));
popup_col.add_child(tab_item_header);
popup_col.add_child(focused_session_option);
if let Some(summary_option) = summary_option {
popup_col.add_child(summary_option);
}
}
if show_focused_session_controls {
popup_col.add_child(make_divider(theme));
popup_col.add_child(density_header);
popup_col.add_child(segmented_control_row);
popup_col.add_child(make_divider(theme));
popup_col.add_child(pane_title_header);
popup_col.add_child(command_option);
popup_col.add_child(directory_option);
popup_col.add_child(branch_option);
if matches!(current_mode, VerticalTabsViewMode::Compact) {
popup_col.add_child(make_divider(theme));
let subtitle_header = Container::new(
Text::new_inline(
"Additional metadata".to_string(),
appearance.ui_font_family(),
SETTINGS_POPUP_MENU_ITEM_FONT_SIZE,
)
.with_color(sub_text.into())
.finish(),
)
.with_horizontal_padding(16.)
.with_margin_bottom(4.)
.finish();
popup_col.add_child(subtitle_header);
let options = subtitle_options_for_primary(current_primary_info);
let mouse_states = [
state.subtitle_option_1_mouse_state.clone(),
state.subtitle_option_2_mouse_state.clone(),
];
for (i, (value, label)) in options.iter().enumerate() {
popup_col.add_child(render_compact_subtitle_option(
label,
current_subtitle == *value,
mouse_states[i].clone(),
*value,
appearance,
theme,
));
}
}
if matches!(current_mode, VerticalTabsViewMode::Expanded) {
popup_col.add_child(make_divider(theme));
let show_header = Container::new(
Text::new_inline(
"Show".to_string(),
appearance.ui_font_family(),
SETTINGS_POPUP_MENU_ITEM_FONT_SIZE,
)
.with_color(sub_text.into())
.finish(),
)
.with_horizontal_padding(16.)
.with_margin_bottom(4.)
.finish();
popup_col.add_child(show_header);
let pr_validation_suppressed = SessionSettings::as_ref(app)
.github_pr_chip_default_validation
.is_suppressed();
let pr_link_info_tooltip = if show_pr_link && pr_validation_suppressed {
Some(ShowToggleInfoTooltip {
mouse_state: state.show_pr_link_info_tooltip_mouse_state.clone(),
tooltip_text: "Requires the GitHub CLI to be installed and authenticated",
})
} else {
None
};
popup_col.add_child(render_show_toggle_option(
"PR link",
show_pr_link,
state.show_pr_link_mouse_state.clone(),
WorkspaceAction::ToggleVerticalTabsShowPrLink,
pr_link_info_tooltip,
appearance,
theme,
));
popup_col.add_child(render_show_toggle_option(
"Diff stats",
show_diff_stats,
state.show_diff_stats_mouse_state.clone(),
WorkspaceAction::ToggleVerticalTabsShowDiffStats,
None,
appearance,
theme,
));
}
}
popup_col.add_child(make_divider(theme));
popup_col.add_child(render_show_toggle_option(
"Show details on hover",
show_details_on_hover,
state.show_details_on_hover_mouse_state.clone(),
WorkspaceAction::ToggleVerticalTabsShowDetailsOnHover,
None,
appearance,
theme,
));
EventHandler::new(
ConstrainedBox::new(
Container::new(popup_col.finish())
.with_vertical_padding(8.)
.with_background(internal_colors::neutral_1(theme))
.with_border(Border::all(1.).with_border_fill(internal_colors::fg_overlay_1(theme)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
SETTINGS_POPUP_CORNER_RADIUS,
)))
.with_drop_shadow(DropShadow::default())
.finish(),
)
.with_width(200.)
.finish(),
)
.on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation)
.finish()
}
fn render_compact_subtitle_option(
label: &str,
is_selected: bool,
mouse_state: MouseStateHandle,
value: VerticalTabsCompactSubtitle,
appearance: &Appearance,
theme: &WarpTheme,
) -> Box<dyn Element> {
const ICON_SIZE: f32 = 16.;
const FONT_SIZE: f32 = 12.;
const GAP: f32 = 8.;
let label = label.to_string();
let main_text = theme.main_text_color(theme.background());
Hoverable::new(mouse_state, move |hover_state| {
let check_icon: Box<dyn Element> = if is_selected {
ConstrainedBox::new(WarpIcon::Check.to_galaxyui_icon(main_text).finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
} else {
ConstrainedBox::new(Empty::new().finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
};
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(GAP)
.with_child(check_icon)
.with_child(
Text::new_inline(label.clone(), appearance.ui_font_family(), FONT_SIZE)
.with_color(main_text.into())
.finish(),
)
.finish();
let mut container = Container::new(row)
.with_horizontal_padding(16.)
.with_vertical_padding(2.);
if hover_state.is_hovered() {
container = container.with_background(internal_colors::fg_overlay_1(theme));
}
container.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::SetVerticalTabsCompactSubtitle(value));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_tab_item_mode_option(
label: &str,
is_selected: bool,
mouse_state: MouseStateHandle,
value: VerticalTabsTabItemMode,
appearance: &Appearance,
theme: &WarpTheme,
) -> Box<dyn Element> {
const ICON_SIZE: f32 = 16.;
const FONT_SIZE: f32 = 12.;
const GAP: f32 = 8.;
let label = label.to_string();
let main_text = theme.main_text_color(theme.background());
Hoverable::new(mouse_state, move |hover_state| {
let check_icon: Box<dyn Element> = if is_selected {
ConstrainedBox::new(WarpIcon::Check.to_galaxyui_icon(main_text).finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
} else {
ConstrainedBox::new(Empty::new().finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
};
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(GAP)
.with_child(check_icon)
.with_child(
Text::new_inline(label.clone(), appearance.ui_font_family(), FONT_SIZE)
.with_color(main_text.into())
.finish(),
)
.finish();
let mut container = Container::new(row)
.with_horizontal_padding(16.)
.with_vertical_padding(2.);
if hover_state.is_hovered() {
container = container.with_background(internal_colors::fg_overlay_1(theme));
}
container.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::SetVerticalTabsTabItemMode(value));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_primary_info_option(
label: &str,
is_selected: bool,
mouse_state: MouseStateHandle,
value: VerticalTabsPrimaryInfo,
appearance: &Appearance,
theme: &WarpTheme,
) -> Box<dyn Element> {
const ICON_SIZE: f32 = 16.;
const FONT_SIZE: f32 = 12.;
const GAP: f32 = 8.;
let label = label.to_string();
let main_text = theme.main_text_color(theme.background());
Hoverable::new(mouse_state, move |hover_state| {
let check_icon: Box<dyn Element> = if is_selected {
ConstrainedBox::new(WarpIcon::Check.to_galaxyui_icon(main_text).finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
} else {
ConstrainedBox::new(Empty::new().finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
};
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(GAP)
.with_child(check_icon)
.with_child(
Text::new_inline(label.clone(), appearance.ui_font_family(), FONT_SIZE)
.with_color(main_text.into())
.finish(),
)
.finish();
let mut container = Container::new(row)
.with_horizontal_padding(16.)
.with_vertical_padding(2.);
if hover_state.is_hovered() {
container = container.with_background(internal_colors::fg_overlay_1(theme));
}
container.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::SetVerticalTabsPrimaryInfo(value));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
struct ShowToggleInfoTooltip {
mouse_state: MouseStateHandle,
tooltip_text: &'static str,
}
fn render_show_toggle_option(
label: &str,
is_enabled: bool,
mouse_state: MouseStateHandle,
action: WorkspaceAction,
info_tooltip: Option<ShowToggleInfoTooltip>,
appearance: &Appearance,
theme: &WarpTheme,
) -> Box<dyn Element> {
const ICON_SIZE: f32 = 16.;
const FONT_SIZE: f32 = 12.;
const GAP: f32 = 8.;
const INFO_ICON_SIZE: f32 = 12.;
const INFO_GAP: f32 = 4.;
let label = label.to_string();
let main_text = theme.main_text_color(theme.background());
let info_color = theme.sub_text_color(theme.background());
let ui_builder = appearance.ui_builder().clone();
let info_mouse_state = info_tooltip.as_ref().map(|t| t.mouse_state.clone());
let info_tooltip_text = info_tooltip.as_ref().map(|t| t.tooltip_text.to_string());
Hoverable::new(mouse_state, move |hover_state| {
let check_icon: Box<dyn Element> = if is_enabled {
ConstrainedBox::new(WarpIcon::Check.to_galaxyui_icon(main_text).finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
} else {
ConstrainedBox::new(Empty::new().finish())
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish()
};
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(Container::new(check_icon).with_margin_right(GAP).finish());
row.add_child(
Text::new_inline(label.clone(), appearance.ui_font_family(), FONT_SIZE)
.with_color(main_text.into())
.finish(),
);
if let (Some(info_ms), Some(info_text)) =
(info_mouse_state.clone(), info_tooltip_text.clone())
{
let builder = ui_builder.clone();
let info_icon = Hoverable::new(info_ms, move |info_hover| {
let icon = ConstrainedBox::new(UiIcon::Info.to_galaxyui_icon(info_color).finish())
.with_width(INFO_ICON_SIZE)
.with_height(INFO_ICON_SIZE)
.finish();
if info_hover.is_hovered() {
let tooltip = builder.tool_tip(info_text.clone()).build().finish();
let mut stack = Stack::new().with_child(icon);
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., -4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
stack.finish()
} else {
icon
}
})
.finish();
row.add_child(
Container::new(info_icon)
.with_padding_left(INFO_GAP)
.finish(),
);
}
let row = row.finish();
let mut container = Container::new(row)
.with_horizontal_padding(16.)
.with_vertical_padding(2.);
if hover_state.is_hovered() {
container = container.with_background(internal_colors::fg_overlay_1(theme));
}
container.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(action.clone());
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_popup_segment(
icon: WarpIcon,
is_selected: bool,
mouse_state: MouseStateHandle,
mode: VerticalTabsViewMode,
theme: &WarpTheme,
icon_color: WarpThemeFill,
) -> Box<dyn Element> {
Hoverable::new(mouse_state, move |hover_state| {
let background = if is_selected {
internal_colors::fg_overlay_3(theme)
} else if hover_state.is_hovered() {
internal_colors::fg_overlay_1(theme)
} else {
ThemeFill::Solid(ColorU::transparent_black())
};
Container::new(
Align::new(
ConstrainedBox::new(icon.to_galaxyui_icon(icon_color).finish())
.with_width(COMPACT_ICON_SIZE)
.with_height(COMPACT_ICON_SIZE)
.finish(),
)
.finish(),
)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_vertical_padding(2.)
.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::SetVerticalTabsViewMode(mode));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_popup_text_segment(
label: &str,
is_selected: bool,
mouse_state: MouseStateHandle,
granularity: VerticalTabsDisplayGranularity,
appearance: &Appearance,
theme: &WarpTheme,
) -> Box<dyn Element> {
let label = label.to_string();
let main_text = theme.main_text_color(theme.background());
let sub_text = theme.sub_text_color(theme.background());
Hoverable::new(mouse_state, move |hover_state| {
let background = if is_selected {
internal_colors::fg_overlay_3(theme)
} else if hover_state.is_hovered() {
internal_colors::fg_overlay_1(theme)
} else {
ThemeFill::Solid(ColorU::transparent_black())
};
Container::new(
Align::new(
Text::new_inline(label.clone(), appearance.ui_font_family(), 14.)
.with_color(if is_selected { main_text } else { sub_text }.into())
.finish(),
)
.finish(),
)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_vertical_padding(2.)
.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::SetVerticalTabsDisplayGranularity(
granularity,
));
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn pane_ids_for_display_granularity(
visible_pane_ids: &[PaneId],
focused_pane_id: PaneId,
granularity: VerticalTabsDisplayGranularity,
) -> Vec<PaneId> {
match granularity {
VerticalTabsDisplayGranularity::Panes => visible_pane_ids.to_vec(),
VerticalTabsDisplayGranularity::Tabs => visible_pane_ids
.iter()
.copied()
.find(|pane_id| *pane_id == focused_pane_id)
.or_else(|| visible_pane_ids.first().copied())
.into_iter()
.collect(),
}
}
fn detail_sidecar_offset_and_max_height(
anchor_position_id: &str,
side: super::PanelPosition,
window_id: WindowId,
app: &AppContext,
) -> (
pathfinder_geometry::vector::Vector2F,
f32,
f32,
PositionedElementOffsetBounds,
PositionedElementAnchor,
ChildAnchor,
) {
const DETAIL_SIDECAR_MAX_HEIGHT: f32 = 420.;
const DETAIL_SIDECAR_HORIZONTAL_GAP: f32 = 12.;
const DETAIL_SIDECAR_WINDOW_MARGIN: f32 = 16.;
// When the panel is on the left, the sidecar opens to the right and vice versa.
let (top_anchors, bottom_anchors, gap_x) = match side {
super::PanelPosition::Left => (
(PositionedElementAnchor::TopRight, ChildAnchor::TopLeft),
(
PositionedElementAnchor::BottomRight,
ChildAnchor::BottomLeft,
),
DETAIL_SIDECAR_HORIZONTAL_GAP,
),
super::PanelPosition::Right => (
(PositionedElementAnchor::TopLeft, ChildAnchor::TopRight),
(
PositionedElementAnchor::BottomLeft,
ChildAnchor::BottomRight,
),
-DETAIL_SIDECAR_HORIZONTAL_GAP,
),
};
let Some(window) = app.windows().platform_window(window_id) else {
return (
vec2f(gap_x, 0.),
DETAIL_SIDECAR_MAX_HEIGHT,
DETAIL_SIDECAR_DEFAULT_WIDTH,
PositionedElementOffsetBounds::WindowBySize,
top_anchors.0,
top_anchors.1,
);
};
let max_height = (window.size().y() - DETAIL_SIDECAR_WINDOW_MARGIN * 2.)
.clamp(0., DETAIL_SIDECAR_MAX_HEIGHT);
let previous_sidecar_height = app
.element_position_by_id_at_last_frame(window_id, VERTICAL_TABS_DETAIL_SIDECAR_POSITION_ID)
.map(|sidecar_rect| sidecar_rect.height())
.unwrap_or(max_height)
.min(max_height);
let Some(anchor_rect) = app.element_position_by_id_at_last_frame(window_id, anchor_position_id)
else {
return (
vec2f(gap_x, 0.),
max_height,
DETAIL_SIDECAR_DEFAULT_WIDTH,
PositionedElementOffsetBounds::WindowBySize,
top_anchors.0,
top_anchors.1,
);
};
let window_width = window.size().x();
let available_width =
(window_width - anchor_rect.max_x() - DETAIL_SIDECAR_HORIZONTAL_GAP).max(0.);
let (width, positioned_bounds) = detail_sidecar_width_and_bounds(available_width);
let window_bottom = window.size().y() - DETAIL_SIDECAR_WINDOW_MARGIN;
let should_anchor_to_bottom = anchor_rect.min_y() + previous_sidecar_height > window_bottom;
if should_anchor_to_bottom {
let min_bottom = DETAIL_SIDECAR_WINDOW_MARGIN + previous_sidecar_height;
let offset_y = (min_bottom - anchor_rect.max_y()).max(0.);
(
vec2f(gap_x, offset_y),
max_height,
width,
positioned_bounds,
bottom_anchors.0,
bottom_anchors.1,
)
} else {
let offset_y = (DETAIL_SIDECAR_WINDOW_MARGIN - anchor_rect.min_y()).max(0.);
(
vec2f(gap_x, offset_y),
max_height,
width,
positioned_bounds,
top_anchors.0,
top_anchors.1,
)
}
}
fn detail_sidecar_width_and_bounds(available_width: f32) -> (f32, PositionedElementOffsetBounds) {
if available_width >= DETAIL_SIDECAR_DEFAULT_WIDTH {
(
DETAIL_SIDECAR_DEFAULT_WIDTH,
PositionedElementOffsetBounds::WindowBySize,
)
} else if available_width >= DETAIL_SIDECAR_MIN_WIDTH {
(available_width, PositionedElementOffsetBounds::WindowBySize)
} else {
(
DETAIL_SIDECAR_MIN_WIDTH,
PositionedElementOffsetBounds::Unbounded,
)
}
}
struct DetailSidecarTextColors {
main: WarpThemeFill,
sub: WarpThemeFill,
disabled: WarpThemeFill,
}
fn detail_sidecar_background(theme: &WarpTheme) -> ColorU {
theme
.background()
.blend(&internal_colors::fg_overlay_2(theme))
.into_solid()
}
fn detail_sidecar_border_fill(theme: &WarpTheme) -> ThemeFill {
theme
.background()
.blend(&internal_colors::fg_overlay_4(theme))
}
fn detail_sidecar_text_colors(theme: &WarpTheme) -> DetailSidecarTextColors {
let bg = ThemeFill::Solid(detail_sidecar_background(theme));
DetailSidecarTextColors {
main: theme.main_text_color(bg),
sub: theme.sub_text_color(bg),
disabled: theme.disabled_text_color(bg),
}
}
fn render_detail_badge(
label: impl Into<String>,
icon: Option<Box<dyn Element>>,
background: Option<ThemeFill>,
text_color: WarpThemeFill,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut content = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.);
if let Some(icon) = icon {
content.add_child(
ConstrainedBox::new(icon)
.with_width(12.)
.with_height(12.)
.finish(),
);
}
content.add_child(
Text::new_inline(label.into(), appearance.ui_font_family(), 10.)
.with_color(text_color.into())
.finish(),
);
let mut badge = Container::new(content.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)));
if let Some(background) = background {
badge = badge
.with_padding(Padding::uniform(2.).with_left(6.).with_right(6.))
.with_background(background);
}
badge.finish()
}
fn render_detail_status_pill(
status: &ConversationStatus,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let (icon, color) = status.status_icon_and_color(theme);
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.)
.with_child(
ConstrainedBox::new(icon.to_galaxyui_icon(WarpThemeFill::Solid(color)).finish())
.with_width(12.)
.with_height(12.)
.finish(),
)
.with_child(
Text::new_inline(status.to_string(), appearance.ui_font_family(), 10.)
.with_color(WarpThemeFill::Solid(color).into())
.finish(),
)
.finish(),
)
.with_padding(Padding::uniform(2.).with_left(4.).with_right(4.))
.with_background(ThemeFill::Solid(coloru_with_opacity(color, 10)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
.finish()
}
fn render_detail_wrapping_text(
text: impl Into<String>,
font_size: f32,
color: WarpThemeFill,
style: Option<Properties>,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut text = Text::new(text.into(), appearance.ui_font_family(), font_size)
.soft_wrap(true)
.with_color(color.into());
if let Some(style) = style {
text = text.with_style(style);
}
text.finish()
}
fn render_terminal_detail_primary_line(
primary_line: &TerminalPrimaryLineData,
color: WarpThemeFill,
appearance: &Appearance,
) -> Box<dyn Element> {
let font_family = match primary_line {
TerminalPrimaryLineData::StatusText { .. } => appearance.ui_font_family(),
TerminalPrimaryLineData::Text { font, .. } => match font {
TerminalPrimaryLineFont::Ui => appearance.ui_font_family(),
TerminalPrimaryLineFont::Monospace => appearance.monospace_font_family(),
},
};
Text::new(primary_line.text().to_string(), font_family, 12.)
.soft_wrap(true)
.with_color(color.into())
.finish()
}
fn detail_pane_props<'a>(
state: &VerticalTabsPanelState,
workspace: &Workspace,
pane_group: &'a PaneGroup,
pane_group_id: EntityId,
pane_id: PaneId,
app: &AppContext,
) -> Option<PaneProps<'a>> {
let badge_mouse_states = state
.detail_pane_badge_mouse_states
.borrow_mut()
.entry(pane_id)
.or_default()
.clone();
PaneProps::new(
pane_group,
pane_id,
pane_group_id,
false,
PaneRowState {
mouse_state: MouseStateHandle::default(),
title_mouse_state: None,
pane_color: None,
badge_mouse_states,
},
state.detail_hover_state(workspace.window_id),
*TabSettings::as_ref(app)
.vertical_tabs_display_granularity
.value(),
false,
None,
None,
None,
false,
None,
false,
None,
app,
)
}
fn render_terminal_detail_section(
props: &PaneProps<'_>,
terminal_view: &TerminalView,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let text_colors = detail_sidecar_text_colors(theme);
let working_directory = terminal_view.display_working_directory(app);
let git_branch = terminal_view.current_git_branch(app);
let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id());
let agent_text = terminal_agent_text(terminal_view, app);
let (conversation_display_title, cli_agent_title) =
preferred_agent_tab_titles(&agent_text, agent_tab_text_preference(app));
let kind_label = terminal_kind_badge_label(agent_text.is_oz_agent, agent_text.cli_agent);
let status = if let Some(session) =
cli_agent_session.filter(|s| s.listener.is_some() && agent_supports_rich_status(&s.agent))
{
Some(session.status.to_conversation_status())
} else if agent_text.is_oz_agent {
terminal_view.selected_conversation_status_for_display(app)
} else {
None
};
let title_text = terminal_view.terminal_title_from_shell();
let primary_line = terminal_primary_line_data(
terminal_view.is_long_running_and_user_controlled(),
conversation_display_title,
cli_agent_title,
title_text.as_str(),
working_directory.as_deref().unwrap_or(title_text.as_str()),
terminal_title_fallback_font(&agent_text),
terminal_view.last_completed_command_text(),
);
let mut section = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(DETAIL_SIDECAR_SECTION_GAP);
if let Some(status) = status.as_ref() {
section.add_child(render_detail_status_pill(status, appearance));
}
if let Some(working_directory) = working_directory.filter(|wd| !wd.trim().is_empty()) {
section.add_child(render_detail_wrapping_text(
working_directory,
12.,
text_colors.main,
None,
appearance,
));
}
if let Some(branch) = git_branch.filter(|branch| !branch.trim().is_empty()) {
section.add_child(render_git_branch_text(
&branch,
text_colors.main,
12.,
appearance,
));
}
section.add_child(render_terminal_detail_primary_line(
&primary_line,
text_colors.sub,
appearance,
));
let mut metadata_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
metadata_row.add_child(render_detail_badge(
kind_label,
Some(render_detail_kind_badge_icon(props, appearance, app)),
None,
text_colors.disabled,
appearance,
));
let mut right_badges = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.);
let mut has_right_badges = false;
if let Some(git_line_changes) = terminal_view.current_diff_line_changes(app) {
right_badges.add_child(render_terminal_diff_stats_badge(
&git_line_changes,
props.pane_group_id,
props.pane_id,
VerticalTabsChipEntrypoint::DetailsSidecar,
props.badge_mouse_states.diff_stats.clone(),
appearance,
));
has_right_badges = true;
}
if let Some(pull_request_url) = terminal_view.current_pull_request_url(app) {
right_badges.add_child(render_terminal_pull_request_badge(
terminal_pull_request_badge_label(&pull_request_url),
pull_request_url,
VerticalTabsChipEntrypoint::DetailsSidecar,
props.badge_mouse_states.pull_request.clone(),
appearance,
));
has_right_badges = true;
}
if has_right_badges {
metadata_row.add_child(right_badges.finish());
}
section.add_child(metadata_row.finish());
Container::new(section.finish())
.with_padding(Padding::uniform(DETAIL_SIDECAR_SECTION_PADDING))
.finish()
}
fn render_code_detail_section(
props: &PaneProps<'_>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let text_colors = detail_sidecar_text_colors(theme);
let TypedPane::Code(code_pane) = &props.typed else {
return Empty::new().finish();
};
let code_view = code_pane.file_view(app);
let code_view = code_view.as_ref(app);
let extra_open_tabs = code_view.tab_count().saturating_sub(1);
let mut section = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(DETAIL_SIDECAR_SECTION_GAP);
section.add_child(render_detail_wrapping_text(
props.title.clone(),
12.,
text_colors.main,
None,
appearance,
));
if !props.subtitle.trim().is_empty() {
section.add_child(render_detail_wrapping_text(
props.subtitle.clone(),
12.,
text_colors.sub,
None,
appearance,
));
}
if extra_open_tabs > 0 {
section.add_child(render_detail_wrapping_text(
format!("and {extra_open_tabs} more"),
12.,
text_colors.sub,
None,
appearance,
));
}
if let Some(language_name) = code_detail_kind_label(&props.title) {
let mut metadata_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
metadata_row.add_child(render_detail_badge(
language_name,
Some(render_detail_kind_badge_icon(props, appearance, app)),
None,
text_colors.disabled,
appearance,
));
if let Some(badge) = props.typed.badge(app) {
metadata_row.add_child(render_detail_badge(
badge,
None,
Some(internal_colors::fg_overlay_1(theme)),
text_colors.sub,
appearance,
));
}
section.add_child(metadata_row.finish());
}
Container::new(section.finish())
.with_padding(Padding::uniform(DETAIL_SIDECAR_SECTION_PADDING))
.finish()
}
fn render_warp_drive_object_detail_section(
props: &PaneProps<'_>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let text_colors = detail_sidecar_text_colors(theme);
let mut section = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(DETAIL_SIDECAR_SECTION_GAP);
section.add_child(render_detail_wrapping_text(
props.title.clone(),
12.,
text_colors.main,
None,
appearance,
));
section.add_child(render_detail_badge(
props.typed.kind_label(),
Some(render_detail_kind_badge_icon(props, appearance, app)),
None,
text_colors.disabled,
appearance,
));
Container::new(section.finish())
.with_padding(Padding::uniform(DETAIL_SIDECAR_SECTION_PADDING))
.finish()
}
fn code_detail_kind_label(file_name: &str) -> Option<String> {
language_by_filename(Path::new(file_name)).map(|language| language.display_name().to_string())
}
fn typed_pane_warp_drive_object_type(typed: &TypedPane<'_>) -> Option<DriveObjectType> {
match typed {
TypedPane::Notebook { is_plan } => Some(DriveObjectType::Notebook {
is_ai_document: *is_plan,
}),
TypedPane::Workflow { is_ai_prompt: true } => Some(DriveObjectType::AgentModeWorkflow),
TypedPane::Workflow {
is_ai_prompt: false,
} => Some(DriveObjectType::Workflow),
TypedPane::EnvVarCollection => Some(DriveObjectType::EnvVarCollection),
TypedPane::AIFact => Some(DriveObjectType::AIFact),
TypedPane::AIDocument => Some(DriveObjectType::Notebook {
is_ai_document: true,
}),
TypedPane::Terminal(_)
| TypedPane::Code(_)
| TypedPane::CodeDiff
| TypedPane::File
| TypedPane::Settings
| TypedPane::EnvironmentManagement
| TypedPane::ExecutionProfileEditor
| TypedPane::Other => None,
}
}
fn render_detail_section(
props: &PaneProps<'_>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
match &props.typed {
TypedPane::Terminal(terminal_pane) => render_terminal_detail_section(
props,
terminal_pane.terminal_view(app).as_ref(app),
appearance,
app,
),
TypedPane::Code(_) => render_code_detail_section(props, appearance, app),
TypedPane::Notebook { .. }
| TypedPane::Workflow { .. }
| TypedPane::EnvVarCollection
| TypedPane::AIFact
| TypedPane::AIDocument => render_warp_drive_object_detail_section(props, appearance, app),
TypedPane::CodeDiff
| TypedPane::File
| TypedPane::Settings
| TypedPane::EnvironmentManagement
| TypedPane::ExecutionProfileEditor
| TypedPane::Other => Empty::new().finish(),
}
}
pub(super) struct DetailSidecarOverlay {
pub(super) anchor_position_id: String,
pub(super) offset: pathfinder_geometry::vector::Vector2F,
pub(super) bounds: PositionedElementOffsetBounds,
pub(super) parent_anchor: PositionedElementAnchor,
pub(super) child_anchor: ChildAnchor,
pub(super) sidecar: Box<dyn Element>,
}
pub(super) fn render_detail_sidecar(
state: &VerticalTabsPanelState,
workspace: &Workspace,
side: super::PanelPosition,
app: &AppContext,
) -> Option<DetailSidecarOverlay> {
if !*TabSettings::as_ref(app)
.vertical_tabs_show_details_on_hover
.value()
{
state.clear_detail_sidecar();
return None;
}
let active_target = state
.detail_overlay_state
.lock()
.ok()
.and_then(|overlay_state| overlay_state.active_target)?;
let Some(tab) = workspace
.tabs
.iter()
.find(|tab| tab.pane_group.id() == active_target.pane_group_id())
else {
state.clear_detail_sidecar();
return None;
};
let context_menu_open_for_tab = workspace
.tabs
.iter()
.position(|tab| tab.pane_group.id() == active_target.pane_group_id())
.and_then(|tab_index| {
workspace
.show_tab_right_click_menu
.map(|(open_tab_index, _)| open_tab_index == tab_index)
})
.unwrap_or(false);
if context_menu_open_for_tab {
state.clear_detail_sidecar();
return None;
}
let pane_group = tab.pane_group.as_ref(app);
let Some(pane_ids) = pane_ids_for_detail_target(pane_group, active_target, app) else {
state.clear_detail_sidecar();
return None;
};
if pane_ids.is_empty() {
state.clear_detail_sidecar();
return None;
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let sidecar_background = detail_sidecar_background(theme);
let anchor_position_id = vtab_pane_row_position_id(
active_target.pane_group_id(),
active_target.source_pane_id(),
);
let (offset, max_height, width, bounds, parent_anchor, child_anchor) =
detail_sidecar_offset_and_max_height(&anchor_position_id, side, workspace.window_id, app);
let source_row_mouse_state = state
.pane_row_mouse_states
.borrow()
.get(&active_target.source_pane_id())
.cloned();
let detail_overlay_state = state.detail_overlay_state.clone();
let window_id = workspace.window_id;
let mut sections = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, pane_id) in pane_ids.iter().enumerate() {
let Some(props) = detail_pane_props(
state,
workspace,
pane_group,
active_target.pane_group_id(),
*pane_id,
app,
) else {
state.clear_detail_sidecar();
return None;
};
if index > 0 {
sections.add_child(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background(detail_sidecar_border_fill(theme))
.finish(),
)
.with_height(1.)
.finish(),
);
}
sections.add_child(render_detail_section(&props, appearance, app));
}
let scrollable = ConstrainedBox::new(
ClippedScrollable::vertical(
state.detail_scroll_state.clone(),
sections.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
ElementFill::None,
)
.with_overlayed_scrollbar()
.finish(),
)
.with_max_height(max_height)
.finish();
let sidecar = Hoverable::new(state.detail_sidecar_mouse_state.clone(), move |_| {
SavePosition::new(
Container::new(scrollable)
.with_background(sidecar_background)
.with_border(Border::all(1.).with_border_fill(detail_sidecar_border_fill(theme)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
DETAIL_SIDECAR_CORNER_RADIUS,
)))
.with_drop_shadow(DropShadow::default())
.finish(),
VERTICAL_TABS_DETAIL_SIDECAR_POSITION_ID,
)
.finish()
})
.on_hover(move |is_hovered, ctx, app, position| {
let mut overlay_state = detail_overlay_state
.lock()
.expect("vertical tabs detail overlay lock poisoned");
overlay_state
.safe_triangle
.set_target_rect(app.element_position_by_id_at_last_frame(
window_id,
VERTICAL_TABS_DETAIL_SIDECAR_POSITION_ID,
));
overlay_state.safe_triangle.update_position(position);
if !is_hovered {
let row_hovered = source_row_mouse_state.as_ref().is_some_and(|mouse_state| {
mouse_state
.lock()
.expect("vertical tabs source row hover lock poisoned")
.is_mouse_over_element()
});
if !row_hovered && overlay_state.active_target == Some(active_target) {
overlay_state.active_target = None;
overlay_state.safe_triangle.set_target_rect(None);
ctx.notify();
}
}
})
.finish();
Some(DetailSidecarOverlay {
anchor_position_id,
offset,
bounds,
parent_anchor,
child_anchor,
sidecar: ConstrainedBox::new(sidecar).with_width(width).finish(),
})
}
fn render_compact_pane_row(props: PaneProps<'_>, app: &AppContext) -> Box<dyn Element> {
let effective_subtitle = props.subtitle.clone();
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let main_text_color = theme.main_text_color(theme.background());
let sub_text_color = theme.sub_text_color(theme.background());
let font_family = appearance.ui_font_family();
let has_indicator = props.typed.badge(app).is_some() || has_unread_activity(&props.typed, app);
let icon = render_pane_icon_with_status(
resolve_icon_with_status_variant(&props.typed, &props.title, appearance, app),
theme,
);
let primary_info = *TabSettings::as_ref(app).vertical_tabs_primary_info.value();
let compact_subtitle = resolve_compact_subtitle(
primary_info,
*TabSettings::as_ref(app)
.vertical_tabs_compact_subtitle
.value(),
);
// Build title (line 1) based on "Pane title as" and subtitle (line 2) based on
// "Additional metadata" setting.
let (title_element, subtitle_element): (Box<dyn Element>, Option<Box<dyn Element>>) =
if let TypedPane::Terminal(terminal_pane) = &props.typed {
let terminal_view = terminal_pane.terminal_view(app).as_ref(app);
let terminal_title = terminal_view.terminal_title_from_shell();
let git_branch = terminal_view.current_git_branch(app);
let working_directory = terminal_view
.display_working_directory(app)
.filter(|wd| !wd.trim().is_empty());
let working_directory_text = working_directory
.clone()
.unwrap_or_else(|| terminal_title.clone());
let branch_display =
branch_label_display(git_branch.as_deref(), working_directory_text.as_str());
// Title based on "Pane title as"
let title: Box<dyn Element> = render_pane_title_slot(
&props,
|| match primary_info {
VerticalTabsPrimaryInfo::Command => render_terminal_primary_line_for_view(
terminal_view,
appearance,
main_text_color,
app,
),
VerticalTabsPrimaryInfo::WorkingDirectory => {
Text::new_inline(working_directory_text.clone(), font_family, 12.)
.with_clip(ClipConfig::start())
.with_color(main_text_color.into())
.finish()
}
VerticalTabsPrimaryInfo::Branch => match branch_display {
(branch_text, true) => {
render_git_branch_text(&branch_text, main_text_color, 12., appearance)
}
(fallback_text, false) => Text::new_inline(fallback_text, font_family, 12.)
.with_clip(ClipConfig::start())
.with_color(main_text_color.into())
.finish(),
},
},
12.,
main_text_color,
ClipConfig::ellipsis(),
appearance,
app,
);
// Subtitle based on "Additional metadata"
let subtitle: Option<Box<dyn Element>> = match compact_subtitle {
VerticalTabsCompactSubtitle::Branch => compact_branch_subtitle_display(
git_branch.as_deref(),
working_directory.as_deref(),
)
.map(|(text, show_branch_icon)| {
if show_branch_icon {
render_git_branch_text(&text, sub_text_color, 10., appearance)
} else {
Text::new_inline(text, font_family, 10.)
.with_clip(ClipConfig::start())
.with_color(sub_text_color.into())
.finish()
}
}),
VerticalTabsCompactSubtitle::WorkingDirectory => working_directory.map(|wd| {
Text::new_inline(wd, font_family, 10.)
.with_clip(ClipConfig::start())
.with_color(sub_text_color.into())
.finish()
}),
VerticalTabsCompactSubtitle::Command => {
let agent_text = terminal_agent_text(terminal_view, app);
let (conv_title, cli_title) =
preferred_agent_tab_titles(&agent_text, agent_tab_text_preference(app));
let line_data = terminal_primary_line_data(
terminal_view.is_long_running_and_user_controlled(),
conv_title,
cli_title,
terminal_title.as_str(),
working_directory_text.as_str(),
terminal_title_fallback_font(&agent_text),
terminal_view.last_completed_command_text(),
);
Some(
Text::new_inline(line_data.text().to_string(), font_family, 10.)
.with_clip(ClipConfig::ellipsis())
.with_color(sub_text_color.into())
.finish(),
)
}
};
(title, subtitle)
} else {
let title = render_pane_title_slot(
&props,
|| {
render_compact_non_terminal_title(
props.displayed_title(),
&props.typed,
appearance,
)
},
12.,
main_text_color,
if matches!(props.typed, TypedPane::Code(_)) {
ClipConfig::start()
} else {
ClipConfig::ellipsis()
},
appearance,
app,
);
let subtitle = if effective_subtitle.is_empty() {
None
} else {
let subtitle_clip = if matches!(props.typed, TypedPane::Code(_)) {
ClipConfig::start()
} else {
ClipConfig::ellipsis()
};
Some(
Text::new_inline(effective_subtitle, font_family, 10.)
.with_clip(subtitle_clip)
.with_color(sub_text_color.into())
.finish(),
)
};
(title, subtitle)
};
// Title row with optional indicator
let title_row = if has_indicator {
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., title_element).finish())
.with_child(
Container::new(render_title_indicator(theme))
.with_margin_left(4.)
.finish(),
)
.finish()
} else {
title_element
};
// Assemble text column: title + optional subtitle
// Top-align the icon when there are two lines of content; center for single-line rows.
let icon_alignment = if subtitle_element.is_some() {
CrossAxisAlignment::Start
} else {
CrossAxisAlignment::Center
};
let mut text_col = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(1.);
text_col.add_child(title_row);
if let Some(subtitle) = subtitle_element {
text_col.add_child(subtitle);
}
let content = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(icon_alignment)
.with_spacing(ICON_WITH_STATUS_GAP)
.with_child(icon)
.with_child(Shrinkable::new(1., text_col.finish()).finish())
.finish();
render_pane_row_element(props, Padding::uniform(8.), true, content, theme)
}
impl Workspace {
pub(super) fn render_vertical_tabs_panel(
&self,
side: super::PanelPosition,
app: &AppContext,
) -> Box<dyn Element> {
render_vertical_tabs_panel(&self.vertical_tabs_panel, self, side, app)
}
}
#[cfg(test)]
#[path = "vertical_tabs_tests.rs"]
mod tests;