first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -0,0 +1,3 @@
mod view;
pub use view::{init, AutoHandoffSleepModal, AutoHandoffSleepModalEvent};
@@ -0,0 +1,282 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{
Align, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Expanded, Flex, Image, MainAxisSize, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::FixedBinding;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, ActionButtonTheme, ButtonSize, PrimaryTheme, SecondaryTheme,
};
const MODAL_WIDTH: f32 = 420.;
const HERO_HEIGHT: f32 = 92.;
const HERO_IMAGE_PATH: &str = "async/png/onboarding/auto_handoff_sleep_banner.png";
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
AutoHandoffSleepModalAction::Dismiss,
id!(AutoHandoffSleepModal::ui_name()),
)]);
}
#[derive(Clone, Debug)]
pub enum AutoHandoffSleepModalAction {
Enable,
Dismiss,
}
#[derive(Clone, Debug)]
pub enum AutoHandoffSleepModalEvent {
/// User clicked "Enable" — turn on auto-handoff-on-sleep and close.
Enable,
/// User dismissed the modal without enabling.
Dismiss,
}
struct CloseButtonTheme;
impl ActionButtonTheme for CloseButtonTheme {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(appearance.theme().surface_overlay_1())
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
_appearance: &Appearance,
) -> ColorU {
ColorU::white()
}
}
pub struct AutoHandoffSleepModal {
close_button: ViewHandle<ActionButton>,
enable_button: ViewHandle<ActionButton>,
dismiss_button: ViewHandle<ActionButton>,
}
impl AutoHandoffSleepModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let close_button = ctx.add_view(|_ctx| {
ActionButton::new("", CloseButtonTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Dismiss))
});
let enable_button = ctx.add_view(|_ctx| {
ActionButton::new("Enable", PrimaryTheme)
.with_full_width(true)
.with_size(ButtonSize::Default)
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Enable))
});
let dismiss_button = ctx.add_view(|_ctx| {
ActionButton::new("Dismiss", SecondaryTheme)
.with_full_width(true)
.with_size(ButtonSize::Default)
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Dismiss))
});
Self {
close_button,
enable_button,
dismiss_button,
}
}
fn render_hero(&self) -> Box<dyn Element> {
let hero = ConstrainedBox::new(
Image::new(
AssetSource::Bundled {
path: HERO_IMAGE_PATH,
},
CacheOption::Original,
)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
.cover()
.top_aligned()
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(HERO_HEIGHT)
.finish();
let close_el = Container::new(ChildView::new(&self.close_button).finish())
.with_uniform_padding(4.)
.with_padding_right(2.)
.finish();
let mut hero_stack = Stack::new();
hero_stack.add_child(hero);
hero_stack.add_positioned_child(
close_el,
OffsetPositioning::offset_from_parent(
vec2f(-4., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
hero_stack.finish()
}
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
let red = appearance.theme().terminal_colors().normal.red;
let text_color: ColorU = red.into();
let background_color = appearance.theme().ansi_overlay_2(red);
let text = Text::new_inline(
"Run Connection Lost".to_string(),
appearance.ui_font_family(),
14.,
)
.with_color(text_color)
.finish();
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(text)
.finish(),
)
.with_horizontal_padding(8.)
.with_background(Fill::Solid(background_color))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_height(24.)
.finish()
}
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
Text::new("Enable auto-handoff?", appearance.ui_font_family(), 20.)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_3())
.into_solid(),
)
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
}
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"Give Warp the option to automatically move active local agents to the cloud when \
your computer sleeps.",
appearance.ui_font_family(),
14.,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().surface_3())
.into_solid(),
)
.finish()
}
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
let footer = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(12.)
.with_child(Expanded::new(1., ChildView::new(&self.enable_button).finish()).finish())
.with_child(Expanded::new(1., ChildView::new(&self.dismiss_button).finish()).finish())
.finish();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(12.)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(8.)
.with_child(Self::render_badge(appearance))
.with_child(Self::render_title(appearance))
.finish(),
)
.with_child(Self::render_description(appearance))
.with_child(footer)
.finish(),
)
.with_horizontal_padding(32.)
.with_vertical_padding(32.)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
}
}
impl Entity for AutoHandoffSleepModal {
type Event = AutoHandoffSleepModalEvent;
}
impl View for AutoHandoffSleepModal {
fn ui_name() -> &'static str {
"AutoHandoffSleepModal"
}
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
ctx.focus_self();
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let card = ConstrainedBox::new(
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_hero())
.with_child(self.render_body(appearance))
.finish(),
)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
.with_width(MODAL_WIDTH)
.finish();
Container::new(Align::new(card).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for AutoHandoffSleepModal {
type Action = AutoHandoffSleepModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AutoHandoffSleepModalAction::Enable => {
ctx.emit(AutoHandoffSleepModalEvent::Enable);
}
AutoHandoffSleepModalAction::Dismiss => {
ctx.emit(AutoHandoffSleepModalEvent::Dismiss);
}
}
}
}
@@ -1,9 +1,3 @@
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::terminal::general_settings::GeneralSettings;
use crate::ui_components::blended_colors;
use crate::view_components::{Dropdown, DropdownEvent, DropdownItem, ToastFlavor};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::CustomerType;
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
@@ -28,6 +22,13 @@ use pathfinder_geometry::vector::vec2f;
use settings::Setting as _;
use thousands::Separable;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::terminal::general_settings::GeneralSettings;
use crate::ui_components::blended_colors;
use crate::view_components::{Dropdown, DropdownEvent, DropdownItem, ToastFlavor};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::CustomerType;
const BUTTON_DIAMETER: f32 = 20.;
const DROPDOWN_WIDTH: f32 = 160.;
const MODAL_HEIGHT: f32 = 540.;
@@ -1,9 +1,3 @@
use crate::auth::AuthStateProvider;
use crate::pricing::PricingInfoModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
@@ -25,8 +19,13 @@ use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use crate::send_telemetry_from_ctx;
use crate::TelemetryEvent;
use crate::auth::AuthStateProvider;
use crate::pricing::PricingInfoModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
const MODAL_WIDTH: f32 = 360.;
const MODAL_HEIGHT: f32 = 532.;
+4 -3
View File
@@ -1,6 +1,3 @@
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme};
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
@@ -20,6 +17,10 @@ use galaxyui::{
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme};
/// White button theme for the Codex modal CTA.
struct WhiteButtonTheme;
+140 -65
View File
@@ -1,31 +1,38 @@
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::active_agent_views_model::ConversationOrTaskId;
use crate::ai::agent_conversations_model::ConversationOrTask;
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::menu::Menu;
use crate::ui_components::icons::Icon;
use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection};
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::util::truncation::truncate_from_end;
use crate::workspace::view::conversation_list::view::ConversationListViewAction;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_util::path::user_friendly_path;
use galaxyui::elements::{
AnchorPair, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, Highlight, Hoverable,
MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle, OffsetPositioning,
OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds,
PositioningAxis, Radius, SavePosition, Shrinkable, Stack, Text, XAxisAnchor, YAxisAnchor,
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Fill as ElementFill, Flex,
Highlight, Hoverable, MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle,
OffsetPositioning, OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds,
PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Shrinkable, Stack, Text,
XAxisAnchor, YAxisAnchor,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextStyle;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::ui_components::text_input::TextInput;
use galaxyui::{AppContext, SingletonEntity, ViewHandle};
use pathfinder_geometry::vector::vec2f;
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationProvenance,
};
use crate::ai::conversation_status_ui::STATUS_ELEMENT_PADDING;
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::editor::EditorView;
use crate::menu::Menu;
use crate::ui_components::agent_icon::agent_conversation_entry_icon_variant;
use crate::ui_components::icon_with_status::render_icon_with_status;
use crate::ui_components::icons::Icon;
use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection};
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::util::truncation::truncate_from_end;
use crate::workspace::view::conversation_list::view::ConversationListViewAction;
/// Maximum length for tooltip text before truncation
const MAX_TOOLTIP_LENGTH: usize = 80;
@@ -36,13 +43,24 @@ const ICON_SPACING: f32 = 4.;
/// Offset for the sharing dialog from the item row
const DIALOG_OFFSET_PIXELS: f32 = -16.;
/// Total size of the agent icon-with-status component rendered in each conversation list
/// row.
const LIST_ITEM_AGENT_SIZE: f32 = 22.;
/// Extra overhang past the default overlay position, as a fraction of
/// `LIST_ITEM_AGENT_SIZE`. Pushes the badge all the way to the bounding box's BR corner;
/// the conversation list reads better with the status sitting slightly further out than
/// on the other surfaces.
const LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05;
/// Generate a position ID for a conversation list item
fn conversation_item_position_id(id: &ConversationOrTaskId) -> String {
fn conversation_item_position_id(id: &AgentConversationEntryId) -> String {
match id {
ConversationOrTaskId::ConversationId(conv_id) => {
AgentConversationEntryId::Conversation(conv_id) => {
format!("conversation_list_item_{conv_id}")
}
ConversationOrTaskId::TaskId(task_id) => format!("conversation_list_task_{task_id}"),
AgentConversationEntryId::AmbientRun(task_id) => {
format!("conversation_list_task_{task_id}")
}
}
}
@@ -53,6 +71,7 @@ pub const STATIC_ITEM_MIN_HEIGHT: f32 = 42.;
#[derive(Clone, Default)]
pub struct ItemState {
pub mouse_state: MouseStateHandle,
pub title_mouse_state: MouseStateHandle,
pub overflow_button_state: MouseStateHandle,
}
@@ -66,7 +85,7 @@ pub enum OverflowMenuDisplay {
}
pub struct ItemProps<'a> {
pub conversation: &'a ConversationOrTask<'a>,
pub conversation: &'a AgentConversationEntry,
pub highlight_indices: Option<&'a Vec<usize>>,
pub is_selected: bool,
pub is_focused_conversation: bool,
@@ -74,7 +93,10 @@ pub struct ItemProps<'a> {
pub state: &'a ItemState,
pub overflow_menu: &'a ViewHandle<Menu<ConversationListViewAction>>,
pub overflow_menu_display: OverflowMenuDisplay,
pub conversation_id: ConversationOrTaskId,
pub conversation_id: AgentConversationEntryId,
pub is_renaming: bool,
pub can_rename: bool,
pub rename_editor: Option<&'a ViewHandle<EditorView>>,
pub sharing_dialog: &'a ViewHandle<SharingDialog>,
pub is_share_dialog_open: bool,
pub list_position_id: &'a str,
@@ -147,7 +169,10 @@ pub fn render_static_item(props: StaticItemProps<'_>, app: &AppContext) -> Box<d
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
// `fire_when_covered: false` makes hover ignore the row when it's
// covered by an overlay (e.g. a modal), so the selection/tooltip
// don't leak through — matching how clicks are already blocked.
fire_when_covered: false,
}),
)
.finish()
@@ -164,6 +189,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
overflow_menu,
overflow_menu_display,
conversation_id,
is_renaming,
can_rename,
rename_editor,
sharing_dialog,
is_share_dialog_open,
list_position_id,
@@ -176,8 +204,12 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let font_size = appearance.ui_font_size();
let title_font_size = font_size + 2.;
let mut title_text = Text::new_inline(conversation.title(app), font_family, title_font_size)
.with_color(theme.main_text_color(theme.background()).into());
let mut title_text = Text::new_inline(
conversation.display.title.clone(),
font_family,
title_font_size,
)
.with_color(theme.main_text_color(theme.background()).into());
if let Some(indices) = highlight_indices {
if !indices.is_empty() {
@@ -194,33 +226,48 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
}
}
let status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.;
let icon_element: Box<dyn Element> = if conversation.is_ambient_agent_conversation() {
ConstrainedBox::new(
Icon::Cloud
.to_galaxyui_icon(theme.sub_text_color(theme.background()))
.finish(),
)
.with_width(status_element_size)
.with_height(status_element_size)
.finish()
let title_element: Box<dyn Element> =
if let Some(rename_editor) = rename_editor.filter(|_| is_renaming) {
render_inline_rename_editor(rename_editor, appearance)
} else {
title_text.finish()
};
let title_element = if can_rename && !is_renaming {
let title_mouse_state = state.title_mouse_state.clone();
Hoverable::new(title_mouse_state, move |_| title_element)
.on_double_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::StartRename {
id: conversation_id,
});
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
render_status_element(&conversation.status(app), font_size, appearance)
title_element
};
let status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.;
let icon_element = render_icon_with_status(
agent_conversation_entry_icon_variant(conversation),
LIST_ITEM_AGENT_SIZE,
LIST_ITEM_OVERLAY_EXTRA_OVERHANG,
theme,
theme.background(),
);
let icon_and_title_row = Shrinkable::new(
1.0,
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(ICON_SPACING)
.with_child(icon_element)
.with_child(Shrinkable::new(1.0, title_text.finish()).finish())
.with_child(Shrinkable::new(1.0, title_element).finish())
.finish(),
)
.finish();
let timestamp = Text::new_inline(
format_approx_duration_from_now_utc(conversation.last_updated()),
format_approx_duration_from_now_utc(conversation.display.last_updated),
font_family,
font_size - 2.,
)
@@ -266,10 +313,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(bottom_row)
.finish();
// Use shared logic from ConversationOrTask to determine open action
let open_action = conversation.get_open_action(None, app);
let title = conversation.title(app);
let tooltip_text = truncate_from_end(&title, MAX_TOOLTIP_LENGTH);
let can_open = conversation.capabilities.can_open;
let tooltip_text = truncate_from_end(&conversation.display.title, MAX_TOOLTIP_LENGTH);
let overflow_button_state = state.overflow_button_state.clone();
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
let container = Container::new(row)
@@ -287,7 +332,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let mut stack = Stack::new().with_child(container.finish());
// We show the overflow menu button when the item is selected, or the overflow menu is already open.
if is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
if !is_renaming
&& (is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed))
{
let button_style = UiComponentStyles::default()
.set_background(theme.surface_2().into())
.set_border_color(theme.surface_3().into());
@@ -326,7 +373,10 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
}
// Hide the tooltip when the overflow menu is being shown so that they don't overlap.
if is_selected && matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
if !is_renaming
&& is_selected
&& matches!(overflow_menu_display, OverflowMenuDisplay::Closed)
{
let tooltip = ui_builder.tool_tip(tooltip_text).build().finish();
let (parent_anchor, child_anchor, offset_x) = if tooltip_opens_right {
(ParentAnchor::MiddleRight, ChildAnchor::MiddleLeft, 4.)
@@ -347,7 +397,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let list_position_id = list_position_id.to_string();
move |ctx, _, position| {
let Some(parent_bounds) = ctx.element_position_by_id(&list_position_id) else {
log::warn!("Could not retreive the position of the conversation list for overflow menu display.");
log::warn!("Could not retrieve the position of the conversation list for overflow menu display.");
return;
};
@@ -360,7 +410,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
})
.with_defer_events_to_children();
let hoverable_element = if open_action.is_some() {
let hoverable_element = if can_open && !is_renaming {
hoverable
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
@@ -381,7 +431,10 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
// `fire_when_covered: false` makes hover ignore the row when
// it's covered by an overlay (e.g. a modal), so the
// selection/tooltip don't leak through — matching clicks.
fire_when_covered: false,
}),
)
.finish();
@@ -415,26 +468,48 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
SavePosition::new(item_stack.finish(), &position_id).finish()
}
fn render_inline_rename_editor(
rename_editor: &ViewHandle<EditorView>,
appearance: &Appearance,
) -> Box<dyn Element> {
TextInput::new(
rename_editor.clone(),
UiComponentStyles::default()
.set_background(ElementFill::None)
.set_border_radius(CornerRadius::with_all(Radius::Pixels(0.)))
.set_border_width(0.)
.set_font_size(appearance.ui_font_size() + 2.),
)
.build()
.finish()
}
/// Returns the secondary label for a conversation list item:
/// - For local conversations: the working directory.
/// - For tasks: the source (Linear, Slack, CLI, etc.)
fn format_item_subtext(conversation: &ConversationOrTask, app: &AppContext) -> Option<String> {
match conversation {
ConversationOrTask::Task(task) => {
task.source.as_ref().map(|s| s.display_name().to_string())
}
ConversationOrTask::Conversation(metadata) => {
// If this conversation is active (with an expanded agent view),
// we use the terminal session's live working directory.
let live_pwd = ActiveAgentViewsModel::as_ref(app)
.get_active_session_for_conversation(metadata.nav_data.id, app)
.and_then(|session| session.as_ref(app).current_working_directory().cloned());
let pwd = live_pwd.or_else(|| metadata.nav_data.initial_working_directory.clone());
pwd.map(|pwd| {
let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from));
user_friendly_path(&pwd, home_dir.as_deref()).into_owned()
})
}
fn format_item_subtext(conversation: &AgentConversationEntry, app: &AppContext) -> Option<String> {
if matches!(
conversation.provenance,
AgentConversationProvenance::AmbientRun
) {
return conversation
.display
.source
.as_ref()
.map(|source| source.display_name().to_string());
}
let live_pwd = conversation
.identity
.local_conversation_id
.and_then(|conversation_id| {
ActiveAgentViewsModel::as_ref(app)
.get_active_session_for_conversation(conversation_id, app)
.and_then(|session| session.as_ref(app).current_working_directory().cloned())
});
let pwd = live_pwd.or_else(|| conversation.display.working_directory.clone());
pwd.map(|pwd| {
let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from));
user_friendly_path(&pwd, home_dir.as_deref()).into_owned()
})
}
+299 -151
View File
@@ -1,39 +1,11 @@
use pathfinder_geometry::vector::Vector2F;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::sync::{Arc, Mutex};
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{AgentConversationsModel, ConversationOrTask};
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::drive::sharing::ShareableObject;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::server::telemetry::SharingDialogSource;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::view_components::DismissibleToast;
use crate::workspace::global_actions::ForkedConversationDestination;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabSettings;
use crate::workspace::view::conversation_list::item::{
render_item, render_static_item, ItemProps, ItemState, OverflowMenuDisplay, StaticItemProps,
STATIC_ITEM_MIN_HEIGHT,
};
use crate::workspace::ToastStack;
use crate::workspace::WorkspaceAction;
use pathfinder_geometry::vector::Vector2F;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::Icon;
use super::view_model::{ConversationEntry, ConversationListViewModel};
use galaxy_editor::editor::NavigationKey;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
@@ -52,6 +24,35 @@ use galaxyui::{
ViewContext, ViewHandle, WindowId,
};
use super::view_model::{ConversationEntry, ConversationListViewModel};
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{
AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel,
};
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::conversation_rename::rename_conversation;
use crate::appearance::Appearance;
use crate::drive::sharing::dialog::SharingDialog;
use crate::drive::sharing::ShareableObject;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::server::telemetry::SharingDialogSource;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::view_components::DismissibleToast;
use crate::workspace::global_actions::ForkedConversationDestination;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabSettings;
use crate::workspace::view::conversation_list::item::{
render_item, render_static_item, ItemProps, ItemState, OverflowMenuDisplay, StaticItemProps,
STATIC_ITEM_MIN_HEIGHT,
};
use crate::workspace::{ToastStack, WorkspaceAction};
const VIEW_ALL_LABEL: &str = "View all";
/// Maximum number of past items to show before the user toggles "view all".
const INITIAL_MAX_PAST_ITEMS: usize = 10;
@@ -60,7 +61,7 @@ const INITIAL_MAX_PAST_ITEMS: usize = 10;
struct StateHandles {
list_state: UniformListState,
scroll_state: ScrollStateHandle,
item_states: HashMap<ConversationOrTaskId, ItemState>,
item_states: HashMap<AgentConversationEntryId, ItemState>,
start_new_conversation_item: ItemState,
list_hover: MouseStateHandle,
zero_state_button: MouseStateHandle,
@@ -93,7 +94,11 @@ pub enum ConversationSection {
#[derive(Clone, Debug)]
enum ListItem {
SectionHeader(ConversationSection),
Conversation(ConversationEntry),
Conversation {
entry: ConversationEntry,
/// The section the conversation is rendered under.
section: ConversationSection,
},
/// The "+ New conversation" item at the end of the active section.
StartNewConversation,
ToggleViewAllButton,
@@ -101,7 +106,7 @@ enum ListItem {
#[derive(Clone, Copy)]
struct OverflowMenuState {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
/// When `Some`, the menu was opened via right-click and should be
/// positioned at the cursor location rather than the kebab button.
position: Option<Vector2F>,
@@ -114,19 +119,19 @@ pub enum ConversationListViewAction {
terminal_view_id: Option<EntityId>,
},
ToggleOverflowMenu {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
/// When `Some`, the menu was opened via right-click and should be
/// positioned where the right click took place.
position: Option<Vector2F>,
},
OpenShareDialog {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
},
DeleteFromOverflowMenu {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
},
OpenItem {
id: ConversationOrTaskId,
id: AgentConversationEntryId,
},
ArrowUp,
ArrowDown,
@@ -137,9 +142,14 @@ pub enum ConversationListViewAction {
ToggleSection(ConversationSection),
ToggleViewAll,
ForkConversation {
conversation_id: ConversationOrTaskId,
conversation_id: AgentConversationEntryId,
destination: ForkedConversationDestination,
},
StartRename {
id: AgentConversationEntryId,
},
FinishRename,
CancelRename,
}
pub enum Event {
@@ -162,8 +172,10 @@ pub struct ConversationListView {
overflow_menu_state: Option<OverflowMenuState>,
/// Sharing dialog for conversations.
sharing_dialog: ViewHandle<SharingDialog>,
rename_editor: ViewHandle<EditorView>,
renaming_conversation_id: Option<AIConversationId>,
/// Track which conversation the share dialog is open for.
share_dialog_open_for: Option<ConversationOrTaskId>,
share_dialog_open_for: Option<AgentConversationEntryId>,
selected_index: Option<usize>,
collapsed_sections: HashSet<ConversationSection>,
/// Cached flat list of items (headers + conversations) for rendering and navigation.
@@ -232,6 +244,30 @@ impl ConversationListView {
ctx.subscribe_to_view(&query_editor, |me, _handle, event, ctx| {
me.handle_query_editor_event(event, ctx);
});
let rename_editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(appearance.ui_font_size() + 2.), appearance),
select_all_on_focus: true,
clear_selections_on_blur: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::Always,
..Default::default()
},
ctx,
)
});
ctx.subscribe_to_view(&rename_editor, |me, _, event, ctx| match event {
EditorEvent::Blurred | EditorEvent::Enter => {
me.finish_rename(ctx);
}
EditorEvent::Escape => {
me.cancel_rename(ctx);
}
_ => {}
});
// We use this as both the "view all" and "show less" button
// (switching out the text on-toggle).
@@ -276,6 +312,8 @@ impl ConversationListView {
item_overflow_menu,
overflow_menu_state: None,
sharing_dialog,
rename_editor,
renaming_conversation_id: None,
share_dialog_open_for: None,
selected_index: None,
collapsed_sections: HashSet::new(),
@@ -291,11 +329,15 @@ impl ConversationListView {
/// Rebuilds the flat list of items based on sections and collapse state.
fn rebuild_list_items(&mut self, ctx: &mut ViewContext<Self>) {
let active_views_model = ActiveAgentViewsModel::as_ref(ctx);
let active_ids = if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() {
active_views_model.get_all_active_conversation_ids(ctx)
} else {
active_views_model.get_all_open_conversation_ids(ctx)
};
let active_ids: HashSet<_> =
if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() {
active_views_model.get_all_active_conversation_ids(ctx)
} else {
active_views_model.get_all_open_conversation_ids(ctx)
}
.into_iter()
.map(AgentConversationEntryId::from)
.collect();
let focused_new_conversation =
active_views_model.maybe_get_focused_new_conversation(ctx.window_id(), ctx);
@@ -305,33 +347,58 @@ impl ConversationListView {
let mut active_items = Vec::new();
let mut past_items = Vec::new();
for entry in model.filtered_items() {
let list_item = ListItem::Conversation(entry.clone());
if active_ids.contains(&entry.id) {
active_items.push(list_item);
let local_conversation_entry_id = model
.get_item_by_id(&entry.id, ctx)
.and_then(|entry| entry.identity.local_conversation_id)
.map(AgentConversationEntryId::Conversation);
let is_active = active_ids.contains(&entry.id)
|| local_conversation_entry_id.is_some_and(|id| active_ids.contains(&id));
if is_active {
active_items.push(ListItem::Conversation {
entry: entry.clone(),
section: ConversationSection::Active,
});
} else {
past_items.push(list_item);
past_items.push(ListItem::Conversation {
entry: entry.clone(),
section: ConversationSection::Past,
});
}
}
// If the focused conversation is a new/empty conversation that's not already in the list,
// add it as a regular conversation entry so it participates in the sort.
if let Some(new_conv_id) = focused_new_conversation {
let conv_id = ConversationOrTaskId::ConversationId(new_conv_id);
let already_in_list = active_items
.iter()
.any(|item| matches!(item, ListItem::Conversation(entry) if entry.id == conv_id));
let conv_id = AgentConversationEntryId::Conversation(new_conv_id);
let already_in_list = active_items.iter().any(
|item| matches!(item, ListItem::Conversation { entry, .. } if entry.id == conv_id),
);
if !already_in_list {
active_items.push(ListItem::Conversation(ConversationEntry {
id: conv_id,
highlight_indices: vec![],
}));
active_items.push(ListItem::Conversation {
entry: ConversationEntry {
id: conv_id,
highlight_indices: vec![],
},
section: ConversationSection::Active,
});
}
}
// Sort active items by last opened time (most recently opened first).
active_items.sort_by(|a, b| {
let get_time = |item: &ListItem| match item {
ListItem::Conversation(entry) => active_views_model.get_last_opened_time(&entry.id),
ListItem::Conversation { entry, .. } => {
let entry_time = active_views_model
.get_last_opened_time(&ConversationOrTaskId::from(entry.id));
let local_time = model
.get_item_by_id(&entry.id, ctx)
.and_then(|item| item.identity.local_conversation_id)
.and_then(|id| {
active_views_model
.get_last_opened_time(&ConversationOrTaskId::ConversationId(id))
});
entry_time.max(local_time)
}
_ => None,
};
get_time(b).cmp(&get_time(a))
@@ -389,16 +456,31 @@ impl ConversationListView {
self.list_items.get(index)
}
/// Finds the flat index of a conversation or task by ID, or None if not found.
fn get_index_of_conversation_id(&self, conversation_id: ConversationOrTaskId) -> Option<usize> {
fn get_index_of_conversation_id(
&self,
conversation_id: AgentConversationEntryId,
) -> Option<usize> {
self.list_items.iter().position(|item| match item {
ListItem::Conversation(entry) => entry.id == conversation_id,
ListItem::Conversation { entry, .. } => entry.id == conversation_id,
ListItem::SectionHeader(_)
| ListItem::StartNewConversation
| ListItem::ToggleViewAllButton => false,
})
}
/// Whether the given entry is currently shown in the Active section.
fn is_in_active_section(&self, conversation_id: AgentConversationEntryId) -> bool {
self.list_items.iter().any(|item| {
matches!(
item,
ListItem::Conversation {
entry,
section: ConversationSection::Active,
} if entry.id == conversation_id
)
})
}
pub fn on_left_panel_focused(&mut self, ctx: &mut ViewContext<Self>) {
// Focus the search bar when the panel is opened.
ctx.focus(&self.query_editor);
@@ -406,8 +488,9 @@ impl ConversationListView {
// Select the focused conversation if there is one.
let focused_conversation =
ActiveAgentViewsModel::as_ref(ctx).get_focused_conversation(ctx.window_id());
self.selected_index =
focused_conversation.and_then(|id| self.get_index_of_conversation_id(id));
self.selected_index = focused_conversation
.map(AgentConversationEntryId::from)
.and_then(|id| self.get_index_of_conversation_id(id));
if let Some(index) = self.selected_index {
self.state_handles.list_state.scroll_to(index);
@@ -444,7 +527,7 @@ impl ConversationListView {
fn is_selectable(&self, index: usize) -> bool {
self.get_list_item(index).is_some_and(|item| match item {
ListItem::Conversation(_) | ListItem::StartNewConversation => true,
ListItem::Conversation { .. } | ListItem::StartNewConversation => true,
ListItem::SectionHeader(_) | ListItem::ToggleViewAllButton => false,
})
}
@@ -531,10 +614,9 @@ impl ConversationListView {
self.focus_query_editor(ctx);
}
/// Send telemetry for opening a conversation or task
fn send_open_telemetry(id: &ConversationOrTaskId, ctx: &mut ViewContext<Self>) {
fn send_open_telemetry(id: &AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
match id {
ConversationOrTaskId::ConversationId(conversation_id) => {
AgentConversationEntryId::Conversation(conversation_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ConversationOpened {
conversation_id: conversation_id.to_string(),
@@ -543,7 +625,7 @@ impl ConversationListView {
ctx
);
}
ConversationOrTaskId::TaskId(task_id) => {
AgentConversationEntryId::AmbientRun(task_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::CloudRunOpened {
task_id: task_id.to_string(),
@@ -569,14 +651,12 @@ impl ConversationListView {
ListItem::StartNewConversation => {
ctx.emit(Event::NewConversationInNewTab);
}
ListItem::Conversation(entry) => {
let model = self.view_model.as_ref(ctx);
let Some(item) = model.get_item_by_id(&entry.id, ctx) else {
return;
};
// Use shared logic from ConversationOrTask to determine click action
if let Some(action) = item.get_open_action(None, ctx) {
ListItem::Conversation { entry, .. } => {
if let Some(action) = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(entry.id),
None,
ctx,
) {
Self::send_open_telemetry(&entry.id, ctx);
ctx.dispatch_typed_action(&action);
}
@@ -614,6 +694,58 @@ impl ConversationListView {
ctx.notify();
}
fn start_rename(&mut self, id: AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else {
return;
};
let Some(conversation_id) = entry.identity.local_conversation_id else {
return;
};
// Renaming requires the conversation to be loaded in the history model, which holds
// for active conversations and any conversation open in an agent view (an open
// conversation stays in the Past section until a prompt is sent).
let is_open = ActiveAgentViewsModel::as_ref(ctx)
.get_terminal_view_id_for_conversation(conversation_id, ctx)
.is_some();
if !self.is_in_active_section(id) && !is_open {
return;
}
self.overflow_menu_state = None;
self.renaming_conversation_id = Some(conversation_id);
self.selected_index = self.get_index_of_conversation_id(id);
let title = entry.display.title;
self.rename_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.insert_selected_text(&title, ctx);
});
ctx.focus(&self.rename_editor);
ctx.notify();
}
fn finish_rename(&mut self, ctx: &mut ViewContext<Self>) {
let Some(conversation_id) = self.renaming_conversation_id.take() else {
return;
};
let title = self.rename_editor.as_ref(ctx).buffer_text(ctx);
self.rename_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
rename_conversation(conversation_id, title, ctx);
ctx.notify();
}
fn cancel_rename(&mut self, ctx: &mut ViewContext<Self>) {
if self.renaming_conversation_id.take().is_none() {
return;
}
self.rename_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
ctx.notify();
}
fn toggle_section_collapse(
&mut self,
section: ConversationSection,
@@ -851,6 +983,7 @@ impl TypedActionView for ConversationListView {
terminal_view_id,
} => {
let window_id = ctx.window_id();
// A conversation can only be deleted once it's done.
let conversation_is_done = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.is_none_or(|c| c.status().is_done());
@@ -867,12 +1000,12 @@ impl TypedActionView for ConversationListView {
return;
}
let id = ConversationOrTaskId::ConversationId(*conversation_id);
let id = AgentConversationEntryId::Conversation(*conversation_id);
let conversation_title = self
.view_model
.as_ref(ctx)
.get_item_by_id(&id, ctx)
.map(|c| c.title(ctx).to_string())
.map(|entry| entry.display.title)
.unwrap_or_else(|| "Conversation".to_string());
ctx.emit(Event::ShowDeleteConfirmationDialog {
conversation_id: *conversation_id,
@@ -896,41 +1029,27 @@ impl TypedActionView for ConversationListView {
});
let conversation_id = *conversation_id;
let is_ambient_agent_conversation =
matches!(conversation_id, ConversationOrTaskId::TaskId(_));
let Some(entry) = self
.view_model
.as_ref(ctx)
.get_item_by_id(&conversation_id, ctx)
else {
return;
};
let mut delete_item = MenuItemFields::new("Delete")
.with_override_text_color(Appearance::as_ref(ctx).theme().ansi_fg_red())
.with_on_select_action(ConversationListViewAction::DeleteFromOverflowMenu {
conversation_id,
})
.with_disabled(is_ambient_agent_conversation);
if is_ambient_agent_conversation {
delete_item = delete_item
.with_tooltip("Ambient agent conversations cannot be deleted");
.with_disabled(!entry.capabilities.can_delete);
if !entry.capabilities.can_delete {
delete_item =
delete_item.with_tooltip("This conversation cannot be deleted");
}
// Check if conversation is shareable:
// - For tasks: check if there's an associated conversation_id
// - For conversations: check if synced to cloud
let is_shareable = match conversation_id {
ConversationOrTaskId::TaskId(task_id) => {
if let Some(ConversationOrTask::Task(task)) =
AgentConversationsModel::as_ref(ctx).get_task(&task_id)
{
task.conversation_id.is_some()
} else {
false
}
}
ConversationOrTaskId::ConversationId(conv_id) => {
BlocklistAIHistoryModel::as_ref(ctx)
.can_conversation_be_shared(&conv_id)
}
};
// Only show share item if the conversation is shareable
let share_item = if is_shareable {
let share_item = if entry.capabilities.can_share {
Some(
MenuItemFields::new("Share conversation")
.with_on_select_action(
@@ -944,7 +1063,7 @@ impl TypedActionView for ConversationListView {
let fork_items: Option<[MenuItem<ConversationListViewAction>; 2]> =
// Forking from a closed ambient agent conversation is not supported at this point.
if !is_ambient_agent_conversation {
if entry.capabilities.can_fork_locally {
Some([
MenuItemFields::new("Fork in new pane")
.with_on_select_action(
@@ -988,27 +1107,13 @@ impl TypedActionView for ConversationListView {
ConversationListViewAction::OpenShareDialog { conversation_id } => {
// Clear selection state when opening share dialog
self.selected_index = None;
// Resolve the AIConversationId for the shareable object
let ai_conversation_id: Option<AIConversationId> = match conversation_id {
ConversationOrTaskId::TaskId(task_id) => {
// For tasks, look up the associated conversation_id by server token
if let Some(ConversationOrTask::Task(task)) =
AgentConversationsModel::as_ref(ctx).get_task(task_id)
{
task.conversation_id.as_ref().and_then(|token_str| {
let server_token = ServerConversationToken::new(token_str.clone());
BlocklistAIHistoryModel::as_ref(ctx)
.find_conversation_id_by_server_token(&server_token)
})
} else {
None
}
}
ConversationOrTaskId::ConversationId(conv_id) => Some(*conv_id),
};
let Some(ai_conversation_id) = ai_conversation_id else {
let Some(ai_conversation_id) = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx)
.filter(|entry| entry.capabilities.can_share)
.and_then(|entry| entry.identity.local_conversation_id)
else {
return;
};
@@ -1025,16 +1130,25 @@ impl TypedActionView for ConversationListView {
ctx.notify();
}
ConversationListViewAction::DeleteFromOverflowMenu { conversation_id } => {
let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id
let Some(entry) = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx)
else {
// For now, delete is only implemented for non-ambient conversations.
return;
};
let Some(ai_conversation_id) = entry.identity.local_conversation_id else {
return;
};
if !entry.capabilities.can_delete {
return;
};
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(ai_conversation_id);
BlocklistAIHistoryModel::as_ref(ctx).conversation(&ai_conversation_id);
if let Some(conversation) = conversation {
// Same gate as the deletion path above.
if !conversation.status().is_done() && !conversation.is_empty() {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
@@ -1053,29 +1167,21 @@ impl TypedActionView for ConversationListView {
self.selected_index = None;
let item = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx);
let terminal_view_id = item
.as_ref()
.and_then(|item| item.navigation_data().and_then(|nav| nav.terminal_view_id));
let conversation_title = item
.as_ref()
.map(|c| c.title(ctx).to_string())
.unwrap_or_else(|| "Conversation".to_string());
let terminal_view_id = ActiveAgentViewsModel::as_ref(ctx)
.get_terminal_view_id_for_conversation(ai_conversation_id, ctx);
let conversation_title = entry.display.title;
ctx.emit(Event::ShowDeleteConfirmationDialog {
conversation_id: *ai_conversation_id,
conversation_id: ai_conversation_id,
conversation_title,
terminal_view_id,
});
}
ConversationListViewAction::OpenItem { id } => {
let model = self.view_model.as_ref(ctx);
let Some(item) = model.get_item_by_id(id, ctx) else {
return;
};
let Some(action) = item.get_open_action(None, ctx) else {
let Some(action) = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(*id),
None,
ctx,
) else {
return;
};
@@ -1139,20 +1245,35 @@ impl TypedActionView for ConversationListView {
conversation_id,
destination,
} => {
let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id
let Some(ai_conversation_id) = self
.view_model
.as_ref(ctx)
.get_item_by_id(conversation_id, ctx)
.filter(|entry| entry.capabilities.can_fork_locally)
.and_then(|entry| entry.identity.local_conversation_id)
else {
return;
};
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
conversation_id: *ai_conversation_id,
conversation_id: ai_conversation_id,
fork_from_exchange: None,
summarize_after_fork: false,
summarization_prompt: None,
initial_prompt: None,
initial_attachments: vec![],
destination: *destination,
});
}
ConversationListViewAction::StartRename { id } => {
self.start_rename(*id, ctx);
}
ConversationListViewAction::FinishRename => {
self.finish_rename(ctx);
}
ConversationListViewAction::CancelRename => {
self.cancel_rename(ctx);
}
}
}
}
@@ -1203,9 +1324,14 @@ impl View for ConversationListView {
let list_items = self.list_items.clone();
let overflow_menu = self.item_overflow_menu.clone();
let overflow_menu_state = self.overflow_menu_state;
let focused_conversation =
ActiveAgentViewsModel::as_ref(app).get_focused_conversation(self.window_id);
let focused_conversation = ActiveAgentViewsModel::as_ref(app)
.get_focused_conversation(self.window_id)
.map(AgentConversationEntryId::from);
let sharing_dialog = self.sharing_dialog.clone();
let rename_editor = self.rename_editor.clone();
let renaming_conversation_id = self.renaming_conversation_id;
let open_conversation_ids =
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
let share_dialog_open_for = self.share_dialog_open_for;
let list_position_id = self.get_position_id();
let tooltip_opens_right = TabSettings::as_ref(app)
@@ -1245,16 +1371,35 @@ impl View for ConversationListView {
app,
))
}
ListItem::Conversation(entry) => {
ListItem::Conversation { entry, section } => {
let conversation = model.get_item_by_id(&entry.id, app)?;
let is_focused_conversation = focused_conversation
.is_some_and(|focused| entry.id == focused);
let local_conversation_entry_id = conversation
.identity
.local_conversation_id
.map(AgentConversationEntryId::Conversation);
let is_focused_conversation =
focused_conversation.is_some_and(|focused| {
entry.id == focused
|| local_conversation_entry_id == Some(focused)
});
let state = item_states.get(&entry.id)?;
let highlight_ref = if entry.highlight_indices.is_empty() {
None
} else {
Some(&entry.highlight_indices)
};
let local_conversation_id =
conversation.identity.local_conversation_id;
let is_renaming = renaming_conversation_id.is_some()
&& local_conversation_id == renaming_conversation_id;
// Renaming is allowed for active conversations and for open
// ones (an open conversation stays in the Past section until
// a prompt is sent).
let can_rename = local_conversation_id.is_some_and(|id| {
*section == ConversationSection::Active
|| open_conversation_ids
.contains(&ConversationOrTaskId::ConversationId(id))
});
let overflow_menu_display = match overflow_menu_state {
Some(s) if s.conversation_id == entry.id => {
@@ -1279,6 +1424,9 @@ impl View for ConversationListView {
overflow_menu: &overflow_menu,
overflow_menu_display,
conversation_id: entry.id,
is_renaming,
can_rename,
rename_editor: is_renaming.then_some(&rename_editor),
sharing_dialog: &sharing_dialog,
is_share_dialog_open,
list_position_id: &list_position_id,
@@ -1,23 +1,23 @@
use crate::ai::active_agent_views_model::ConversationOrTaskId;
use crate::ai::agent_conversations_model::{
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
ConversationOrTask, CreatedOnFilter, CreatorFilter, OwnerFilter, SessionStatus, SourceFilter,
StatusFilter,
};
use fuzzy_match::match_indices_case_insensitive;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationsModel,
AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, ConversationUpdateKind,
CreatedOnFilter, CreatorFilter, OwnerFilter, SourceFilter, StatusFilter,
};
pub struct ConversationListViewModelEvent;
#[derive(Clone, Debug)]
pub struct ConversationEntry {
pub id: ConversationOrTaskId,
pub id: AgentConversationEntryId,
pub highlight_indices: Vec<usize>,
}
pub struct ConversationListViewModel {
conversations_model: ModelHandle<AgentConversationsModel>,
cached_conversation_or_task_ids: Vec<ConversationOrTaskId>,
cached_entry_ids: Vec<AgentConversationEntryId>,
filtered_items: Vec<ConversationEntry>,
search_query: String,
}
@@ -30,20 +30,27 @@ impl ConversationListViewModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let conversations_model = AgentConversationsModel::handle(ctx);
ctx.subscribe_to_model(&conversations_model, |me, event, ctx| {
ctx.subscribe_to_model(&conversations_model, |me, _, event, ctx| {
match event {
// These events change the set of items in the list, so we need
// to rebuild the cached ID list.
AgentConversationsModelEvent::ConversationsLoaded
| AgentConversationsModelEvent::NewTasksReceived
| AgentConversationsModelEvent::TasksUpdated
| AgentConversationsModelEvent::TaskManuallyOpened => {
| AgentConversationsModelEvent::TasksUpdated => {
me.refresh_cached_items(ctx);
}
// Status changes don't affect the set of IDs (status is read
// at render time via get_item_by_id); just signal a re-render.
AgentConversationsModelEvent::ConversationUpdated => {
ctx.emit(ConversationListViewModelEvent);
AgentConversationsModelEvent::ConversationUpdated { kind } => {
if matches!(
kind,
ConversationUpdateKind::MetadataChanged
| ConversationUpdateKind::TitleChanged
) {
me.refresh_cached_items(ctx);
} else {
ctx.emit(ConversationListViewModelEvent);
}
}
// Artifact updates don't affect the conversation list
AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } => {}
@@ -52,7 +59,7 @@ impl ConversationListViewModel {
let mut model = Self {
conversations_model,
cached_conversation_or_task_ids: Vec::new(),
cached_entry_ids: Vec::new(),
filtered_items: Vec::new(),
search_query: String::new(),
};
@@ -62,15 +69,15 @@ impl ConversationListViewModel {
/// Rebuilds the cached list of IDs from the current task/conversation set.
///
/// The cache stores only `ConversationOrTaskId`s; per-item fields like
/// The cache stores only `AgentConversationEntryId`s; per-item fields like
/// status, title, and last-updated are read fresh at render time via
/// `get_item_by_id`. Callers should therefore avoid invoking this on
/// events that only mutate per-item state (e.g. `ConversationUpdated`);
/// emitting `ConversationListViewModelEvent` is sufficient there.
fn refresh_cached_items(&mut self, ctx: &mut ModelContext<Self>) {
let model = self.conversations_model.as_ref(ctx);
self.cached_conversation_or_task_ids = model
.get_tasks_and_conversations(
self.cached_entry_ids = model
.get_entries(
&AgentManagementFilters {
owners: OwnerFilter::PersonalOnly,
status: StatusFilter::All,
@@ -83,28 +90,9 @@ impl ConversationListViewModel {
},
ctx,
)
// Expired and Unavailable ambient agent sessions can't be opened, so we filter them out.
// Regular conversations have None session_status
.filter(|item| {
item.get_session_status()
.is_none_or(|status| status == SessionStatus::Available)
})
// Only show user-initiated sources (Slack, Linear, Interactive) or tasks that have
// been manually opened from the management page.
.filter(|item| {
let is_user_initiated = item.source().is_some_and(|s| s.is_user_initiated());
let is_manually_opened = match item {
ConversationOrTask::Task(task) => model.is_task_manually_opened(&task.task_id),
ConversationOrTask::Conversation(_) => false,
};
is_user_initiated || is_manually_opened
})
.map(|item| match item {
ConversationOrTask::Task(task) => ConversationOrTaskId::TaskId(task.task_id),
ConversationOrTask::Conversation(conv) => {
ConversationOrTaskId::ConversationId(conv.nav_data.id)
}
})
.into_iter()
.filter(|entry| entry.capabilities.can_open)
.map(|entry| entry.id)
.collect();
self.apply_search_filter(ctx);
@@ -127,7 +115,7 @@ impl ConversationListViewModel {
if search_query.is_empty() {
self.filtered_items = self
.cached_conversation_or_task_ids
.cached_entry_ids
.iter()
.map(|id| ConversationEntry {
id: *id,
@@ -136,27 +124,22 @@ impl ConversationListViewModel {
.collect();
} else {
let mut matched_items: Vec<(i64, ConversationEntry)> = self
.cached_conversation_or_task_ids
.cached_entry_ids
.iter()
.filter_map(|id| {
let item = match id {
ConversationOrTaskId::TaskId(task_id) => {
conversations_model.get_task(task_id)
}
ConversationOrTaskId::ConversationId(conv_id) => {
conversations_model.get_conversation(conv_id)
}
}?;
let item = conversations_model.get_entry_by_id(id, ctx)?;
match_indices_case_insensitive(&item.title(ctx), &search_query).map(|result| {
(
result.score,
ConversationEntry {
id: *id,
highlight_indices: result.matched_indices,
},
)
})
match_indices_case_insensitive(&item.display.title, &search_query).map(
|result| {
(
result.score,
ConversationEntry {
id: *id,
highlight_indices: result.matched_indices,
},
)
},
)
})
.collect();
@@ -167,7 +150,7 @@ impl ConversationListViewModel {
/// Returns the total number of conversations in the model before any filtering is applied.
pub fn unfiltered_item_count(&self) -> usize {
self.cached_conversation_or_task_ids.len()
self.cached_entry_ids.len()
}
/// Returns the filtered items with their highlight indices.
@@ -175,20 +158,17 @@ impl ConversationListViewModel {
&self.filtered_items
}
/// Look up a conversation or task by ID.
pub fn get_item_by_id<'a>(
/// Look up a normalized conversation entry by ID.
pub fn get_item_by_id(
&self,
id: &ConversationOrTaskId,
ctx: &'a AppContext,
) -> Option<ConversationOrTask<'a>> {
id: &AgentConversationEntryId,
ctx: &AppContext,
) -> Option<AgentConversationEntry> {
let model = self.conversations_model.as_ref(ctx);
match id {
ConversationOrTaskId::TaskId(task_id) => model.get_task(task_id),
ConversationOrTaskId::ConversationId(conv_id) => model.get_conversation(conv_id),
}
model.get_entry_by_id(id, ctx)
}
pub fn current_ids(&self) -> impl Iterator<Item = &ConversationOrTaskId> {
pub fn current_ids(&self) -> impl Iterator<Item = &AgentConversationEntryId> {
self.filtered_items.iter().map(|item| &item.id)
}
}
+1 -2
View File
@@ -1,9 +1,8 @@
use galaxy_cli::RecoveryMechanism;
use galaxyui::{AppContext, SingletonEntity as _, ViewContext};
use crate::crash_recovery::CrashRecovery;
use super::{Workspace, WorkspaceBannerFields};
use crate::crash_recovery::CrashRecovery;
pub fn banner_metadata(ctx: &AppContext) -> Option<WorkspaceBannerFields> {
let crash_recovery = CrashRecovery::as_ref(ctx);
@@ -0,0 +1,433 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use warpui::elements::{
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DropShadow, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack,
};
use warpui::fonts::Weight;
use warpui::keymap::FixedBinding;
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::auth::AuthStateProvider;
use crate::settings_view::SettingsSection;
use crate::ui_components::blended_colors;
use crate::workspace::WorkspaceAction;
use crate::workspaces::user_workspaces::UserWorkspaces;
const MODAL_WIDTH: f32 = 480.;
const CORNER_RADIUS: f32 = 12.;
const PANEL_PADDING: f32 = 24.;
const CLOSE_BUTTON_DIAMETER: f32 = 20.;
const NOTICE_TITLE_TEXT: &str = "Warp is no longer providing inference on the free plan.";
const NOTICE_BODY_TEXT: &str = "To keep using Warp's AI features, please upgrade to a paid plan, \
bring your own API key or endpoint, or log in with your Grok subscription.";
const NOTICE_BONUS_CREDITS_TEXT: &str = "If you have any unused bonus credits, AI will keep \
working until these run out.";
const PROMPT_SUGGESTIONS_TITLE_TEXT: &str = "How to use AI features in Warp";
const PROMPT_SUGGESTIONS_BODY_TEXT: &str = "To use AI features in Warp, subscribe to a paid plan, \
add an API key (OpenAI, Anthropic, or Google), add a custom inference endpoint (OpenRouter, \
LiteLLM), or log in using your SuperGrok subscription.";
/// Which surface opened the modal. Selects the copy and disambiguates telemetry;
/// the layout and CTAs are identical across variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FreeAiRemovalModalVariant {
/// One-time notice shown to Free users when Warp-provided AI is removed from their plan.
Notice,
/// Shown on demand when a Free user activates Prompt Suggestions while out of credits.
PromptSuggestions,
}
impl FreeAiRemovalModalVariant {
fn title(self) -> &'static str {
match self {
Self::Notice => NOTICE_TITLE_TEXT,
Self::PromptSuggestions => PROMPT_SUGGESTIONS_TITLE_TEXT,
}
}
fn body(self) -> &'static str {
match self {
Self::Notice => NOTICE_BODY_TEXT,
Self::PromptSuggestions => PROMPT_SUGGESTIONS_BODY_TEXT,
}
}
/// Secondary note rendered under the body. The on-demand Prompt Suggestions
/// variant only fires once the user is already out of credits, so the
/// bonus-credits note doesn't apply there.
fn secondary(self) -> Option<&'static str> {
match self {
Self::Notice => Some(NOTICE_BONUS_CREDITS_TEXT),
Self::PromptSuggestions => None,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Notice => "notice",
Self::PromptSuggestions => "prompt_suggestions",
}
}
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
FreeAiRemovalModalAction::Close,
id!("FreeAiRemovalModal"),
)]);
}
#[derive(Clone, Debug)]
pub enum FreeAiRemovalModalAction {
Close,
SetUpByok,
Upgrade,
}
#[derive(Clone, Copy, Debug)]
pub enum FreeAiRemovalModalEvent {
Close,
}
#[derive(Default)]
struct StateHandles {
close_button: MouseStateHandle,
byok_button: MouseStateHandle,
upgrade_button: MouseStateHandle,
}
/// Notice shown to Free-plan users about the removal of Warp-provided AI. The
/// `variant` selects the copy: a one-time rollout notice, or an on-demand prompt
/// when a Free user activates a gated feature (e.g. Prompt Suggestions).
pub struct FreeAiRemovalModal {
variant: FreeAiRemovalModalVariant,
state_handles: StateHandles,
}
impl FreeAiRemovalModal {
pub fn new(variant: FreeAiRemovalModalVariant, _ctx: &mut ViewContext<Self>) -> Self {
Self {
variant,
state_handles: Default::default(),
}
}
fn upgrade_url(ctx: &ViewContext<Self>) -> String {
if let Some(team) = UserWorkspaces::as_ref(ctx).current_team() {
UserWorkspaces::upgrade_link_for_team(team.uid)
} else {
let user_id = AuthStateProvider::as_ref(ctx)
.get()
.user_id()
.unwrap_or_default();
UserWorkspaces::upgrade_link(user_id)
}
}
fn render_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
let byok_button = appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.state_handles.byok_button.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(14.),
height: Some(32.),
..Default::default()
})
.with_centered_text_label("Bring your own AI".to_string())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(FreeAiRemovalModalAction::SetUpByok);
})
.finish();
let upgrade_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.state_handles.upgrade_button.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(14.),
height: Some(32.),
..Default::default()
})
.with_centered_text_label("View pricing".to_string())
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(FreeAiRemovalModalAction::Upgrade);
})
.finish();
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(byok_button)
.with_child(upgrade_button)
.finish()
}
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
appearance
.ui_builder()
.close_button(
CLOSE_BUTTON_DIAMETER,
self.state_handles.close_button.clone(),
)
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(FreeAiRemovalModalAction::Close))
.finish()
}
}
impl Entity for FreeAiRemovalModal {
type Event = FreeAiRemovalModalEvent;
}
impl View for FreeAiRemovalModal {
fn ui_name() -> &'static str {
"FreeAiRemovalModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let bg = blended_colors::neutral_1(theme);
let font_family = appearance.ui_font_family();
let title = FormattedTextElement::from_str(self.variant.title(), font_family, 18.)
.with_color(blended_colors::text_main(theme, bg))
.with_weight(Weight::Bold)
.finish();
let body_color = blended_colors::text_sub(theme, bg);
let body = FormattedTextElement::from_str(self.variant.body(), font_family, 14.)
.with_color(body_color)
.finish();
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Container::new(title).with_margin_bottom(12.).finish());
// Tighten the gap below the body when a secondary note follows; otherwise
// keep the full gap above the buttons.
let body_margin_bottom = if self.variant.secondary().is_some() {
8.
} else {
20.
};
content.add_child(
Container::new(body)
.with_margin_bottom(body_margin_bottom)
.finish(),
);
if let Some(secondary_text) = self.variant.secondary() {
let secondary = FormattedTextElement::from_str(secondary_text, font_family, 14.)
.with_color(body_color)
.finish();
content.add_child(Container::new(secondary).with_margin_bottom(20.).finish());
}
let content = content.with_child(self.render_buttons(appearance)).finish();
let mut modal = Stack::new();
modal.add_child(
Container::new(
ConstrainedBox::new(content)
.with_width(MODAL_WIDTH)
.finish(),
)
.with_background_color(bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_uniform_padding(PANEL_PADDING)
.with_drop_shadow(DropShadow::default())
.finish(),
);
modal.add_positioned_child(
self.render_close_button(appearance),
OffsetPositioning::offset_from_parent(
vec2f(-8., 8.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
let mut stack = Stack::new();
stack.add_positioned_child(
modal.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
Container::new(Align::new(stack.finish()).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for FreeAiRemovalModal {
type Action = FreeAiRemovalModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
FreeAiRemovalModalAction::Close => {
send_telemetry_from_ctx!(
FreeAiRemovalModalTelemetryEvent::Dismissed {
variant: self.variant,
},
ctx
);
ctx.emit(FreeAiRemovalModalEvent::Close);
}
FreeAiRemovalModalAction::SetUpByok => {
send_telemetry_from_ctx!(
FreeAiRemovalModalTelemetryEvent::CtaClicked {
variant: self.variant,
cta: FreeAiRemovalModalCta::SetUpByok,
},
ctx
);
// Deferred so the close-driven refocus below doesn't steal focus from
// the settings page this opens.
ctx.dispatch_typed_action_deferred(WorkspaceAction::ShowSettingsPageWithSearch {
search_query: "api".to_string(),
section: Some(SettingsSection::WarpAgent),
});
ctx.emit(FreeAiRemovalModalEvent::Close);
}
FreeAiRemovalModalAction::Upgrade => {
send_telemetry_from_ctx!(
FreeAiRemovalModalTelemetryEvent::CtaClicked {
variant: self.variant,
cta: FreeAiRemovalModalCta::Upgrade,
},
ctx
);
let upgrade_url = Self::upgrade_url(ctx);
ctx.open_url(&upgrade_url);
ctx.emit(FreeAiRemovalModalEvent::Close);
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum FreeAiRemovalModalCta {
SetUpByok,
Upgrade,
}
impl FreeAiRemovalModalCta {
fn as_str(&self) -> &'static str {
match self {
Self::SetUpByok => "set_up_byok",
Self::Upgrade => "upgrade",
}
}
}
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum FreeAiRemovalModalTelemetryEvent {
Shown {
variant: FreeAiRemovalModalVariant,
},
Dismissed {
variant: FreeAiRemovalModalVariant,
},
CtaClicked {
variant: FreeAiRemovalModalVariant,
cta: FreeAiRemovalModalCta,
},
}
impl TelemetryEvent for FreeAiRemovalModalTelemetryEvent {
fn name(&self) -> &'static str {
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
Self::Shown { variant } | Self::Dismissed { variant } => Some(json!({
"variant": variant.as_str(),
})),
Self::CtaClicked { variant, cta } => Some(json!({
"variant": variant.as_str(),
"cta": cta.as_str(),
})),
}
}
fn description(&self) -> &'static str {
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
match self {
Self::Shown { .. } | Self::Dismissed { .. } | Self::CtaClicked { .. } => false,
}
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
galaxy_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for FreeAiRemovalModalTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::Shown => "FreeAiRemovalModal.Shown",
Self::Dismissed => "FreeAiRemovalModal.Dismissed",
Self::CtaClicked => "FreeAiRemovalModal.CtaClicked",
}
}
fn description(&self) -> &'static str {
match self {
Self::Shown => "The free AI removal notice modal was shown to the user",
Self::Dismissed => "The user dismissed the free AI removal notice modal",
Self::CtaClicked => "The user clicked a CTA in the free AI removal notice modal",
}
}
fn enablement_state(&self) -> EnablementState {
match self {
Self::Shown | Self::Dismissed | Self::CtaClicked => EnablementState::Always,
}
}
}
galaxy_core::register_telemetry_event!(FreeAiRemovalModalTelemetryEvent);
@@ -1,10 +1,3 @@
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::auth::AuthStateProvider;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::TelemetryEvent;
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
@@ -27,6 +20,14 @@ use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::auth::AuthStateProvider;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::TelemetryEvent;
const BUTTON_DIAMETER: f32 = 20.;
const MODAL_HEIGHT: f32 = 440.;
const LEFT_PANEL_WIDTH: f32 = 360.;
@@ -71,6 +72,7 @@ impl FreeTierLimitHitModal {
ctx.emit(FreeTierLimitHitModalEvent::MaybeOpen);
}
AIRequestUsageModelEvent::RequestBonusRefunded { .. } => {}
AIRequestUsageModelEvent::AmbientCreditsBannerDismissed => {}
},
);
@@ -1,8 +1,25 @@
use warp_ripgrep::search::Submatch;
use warp_util::local_or_remote_path::LocalOrRemotePath;
pub struct SearchConfig {
pub use_regex: bool,
pub use_case_sensitivity: bool,
}
/// A single global search match: one line in one file, which may live on
/// the local filesystem or on a remote host.
#[derive(Clone, Debug)]
pub struct GlobalSearchMatch {
pub location: LocalOrRemotePath,
pub line_number: u32,
/// Original 1-based character column in the file. This is captured
/// before display-only whitespace trimming so opening a result navigates
/// to the correct location.
pub column_num: Option<usize>,
pub line_text: String,
pub submatches: Vec<Submatch>,
}
#[cfg_attr(not(target_family = "wasm"), path = "model.rs")]
#[cfg_attr(target_family = "wasm", path = "model_wasm.rs")]
pub mod model;
+362 -44
View File
@@ -1,5 +1,7 @@
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::SearchConfig;
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use anyhow::Result;
use futures::StreamExt as _;
use galaxy_ripgrep::search::{Match as RipgrepMatch, Submatch};
@@ -8,15 +10,62 @@ use galaxyui::{Entity, ModelContext, ModelSpawner};
use instant::Instant;
use num_traits::SaturatingSub;
use regex::escape;
use std::path::PathBuf;
use remote_server::manager::{HostRequestError, RemoteServerManager, RipgrepSearchParams};
use remote_server::proto::RipgrepSearchSuccess;
use remote_server::protocol::RequestId;
use remote_server::HostId;
use string_offset::ByteOffset;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::{GlobalSearchMatch, SearchConfig};
const START_BATCH_AFTER_COUNT: usize = 50;
const MAX_BATCH_SIZE: usize = 512;
const MAX_BATCH_AGE_MS: u64 = 4000;
/// Client-requested cap on remote matches per host. The daemon clamps this
/// to its own server-side cap; both bound the single-frame response size.
const REMOTE_MAX_MATCH_COUNT: u32 = 5_000;
/// Aggregate state for one logical search across all of its sources
/// (one local ripgrep run plus one remote request per searched host).
struct ActiveSearch {
search_id: u32,
remaining_sources: usize,
completed_sources: usize,
local_source_failed: bool,
remote_source_failures: usize,
total_match_count: usize,
/// True when any remote source hit the server-side match cap.
capped: bool,
}
#[derive(Clone, Copy)]
enum SearchSource {
Local,
Remote,
}
/// Result of one search source (the local ripgrep run, or one remote
/// host's request) that ran to completion.
struct SourceResult {
match_count: usize,
capped: bool,
}
pub struct GlobalSearch {
search_handle: Option<SpawnedFutureHandle>,
/// Spawned local/remote search tasks for the current search.
search_handles: Vec<SpawnedFutureHandle>,
/// Request ids of remote searches started for the current search, so
/// they can be aborted daemon-side when the query changes. May contain
/// ids of already-resolved requests; aborting those is a no-op.
in_flight_remote_requests: Vec<RequestId>,
/// Aggregate completion state for the current search.
active_search: Option<ActiveSearch>,
// track the search ID so that we only show results for the current search
next_search_id: u32,
}
@@ -28,7 +77,7 @@ impl Entity for GlobalSearch {
async fn flush_batch(
spawner: &ModelSpawner<GlobalSearch>,
search_id: u32,
batch: &mut Vec<RipgrepMatch>,
batch: &mut Vec<GlobalSearchMatch>,
) {
if batch.is_empty() {
return;
@@ -46,35 +95,47 @@ async fn flush_batch(
impl GlobalSearch {
pub fn new() -> Self {
GlobalSearch {
search_handle: None,
search_handles: Vec::new(),
in_flight_remote_requests: Vec::new(),
active_search: None,
next_search_id: 1,
}
}
pub fn abort_search(&mut self) {
if let Some(handle) = self.search_handle.take() {
pub fn abort_search(&mut self, ctx: &mut ModelContext<Self>) {
for handle in self.search_handles.drain(..) {
handle.abort();
}
self.active_search = None;
// Cancel in-flight remote searches daemon-side as well: queries
// change on every debounced edit, so without this the daemon piles
// up wasted ripgrep runs.
let request_ids = std::mem::take(&mut self.in_flight_remote_requests);
if !request_ids.is_empty() {
RemoteServerManager::handle(ctx).update(ctx, |manager, _| {
for request_id in &request_ids {
manager.abort_host_request(request_id);
}
});
}
}
pub fn run_search(
&mut self,
pattern: String,
roots: Vec<PathBuf>,
roots: Vec<LocalOrRemotePath>,
search_config: SearchConfig,
ctx: &mut ModelContext<Self>,
) {
if let Some(handle) = self.search_handle.take() {
if !self.search_handles.is_empty() {
log::info!("GlobalSearch: aborting previous search");
handle.abort();
}
self.abort_search(ctx);
let search_id = self.next_search_id;
self.next_search_id += 1;
ctx.emit(GlobalSearchEvent::Started { search_id });
let spawner = ctx.spawner();
let effective_pattern = if search_config.use_regex {
pattern
} else {
@@ -83,36 +144,263 @@ impl GlobalSearch {
let ignore_case = !search_config.use_case_sensitivity;
let multiline = effective_pattern.contains('\n');
let handle = ctx.spawn(
// Split roots into the local filesystem source and one remote
// source per host.
let mut local_roots: Vec<PathBuf> = Vec::new();
let mut remote_roots: HashMap<HostId, Vec<StandardizedPath>> = HashMap::new();
for root in roots {
match root {
LocalOrRemotePath::Local(path) => local_roots.push(path),
LocalOrRemotePath::Remote(remote) => {
remote_roots
.entry(remote.host_id)
.or_default()
.push(remote.path);
}
}
}
let remote_host_count = remote_roots.len();
ctx.emit(GlobalSearchEvent::Started {
search_id,
remote_host_count,
});
let source_count = usize::from(!local_roots.is_empty()) + remote_roots.len();
if source_count == 0 {
ctx.emit(GlobalSearchEvent::Completed {
search_id,
total_match_count: 0,
capped: false,
local_source_failed: false,
remote_source_failures: 0,
});
return;
}
self.active_search = Some(ActiveSearch {
search_id,
remaining_sources: source_count,
completed_sources: 0,
local_source_failed: false,
remote_source_failures: 0,
total_match_count: 0,
capped: false,
});
if !local_roots.is_empty() {
self.spawn_local_search(
search_id,
effective_pattern.clone(),
local_roots,
ignore_case,
multiline,
ctx,
);
}
for (host_id, paths) in remote_roots {
let params = RipgrepSearchParams {
pattern: effective_pattern.clone(),
roots: paths,
ignore_case,
multiline,
max_matches: REMOTE_MAX_MATCH_COUNT,
};
self.spawn_remote_search(search_id, host_id, params, ctx);
}
}
fn spawn_local_search(
&mut self,
search_id: u32,
pattern: String,
roots: Vec<PathBuf>,
ignore_case: bool,
multiline: bool,
ctx: &mut ModelContext<Self>,
) {
let spawner = ctx.spawner();
self.spawn_source(
search_id,
SearchSource::Local,
async move {
Self::run_galaxy_ripgrep_cli(
let result = Self::run_galaxy_ripgrep_cli(
search_id,
effective_pattern,
pattern,
roots,
ignore_case,
multiline,
spawner,
)
.await
},
move |_, result, ctx| match result {
Ok(total_match_count) => {
ctx.emit(GlobalSearchEvent::Completed {
search_id,
total_match_count,
});
}
Err(err) => {
log::error!("GlobalSearch: galaxy_ripgrep CLI search failed or aborted: {err}");
ctx.emit(GlobalSearchEvent::Failed {
search_id,
error: "Global search failed.".to_string(),
});
.await;
match result {
Ok(match_count) => Some(SourceResult {
match_count,
capped: false,
}),
Err(err) => {
log::error!(
"GlobalSearch: galaxy_ripgrep CLI search failed or aborted: {err}"
);
None
}
}
},
ctx,
);
}
self.search_handle = Some(handle);
fn spawn_remote_search(
&mut self,
search_id: u32,
host_id: HostId,
params: RipgrepSearchParams,
ctx: &mut ModelContext<Self>,
) {
let pending = RemoteServerManager::handle(ctx).update(ctx, |manager, _| {
manager.start_ripgrep_search(&host_id, params)
});
self.in_flight_remote_requests
.push(pending.request_id().clone());
let spawner = ctx.spawner();
self.spawn_source(
search_id,
SearchSource::Remote,
async move {
match pending.result().await {
Ok(success) => {
let capped = success.capped;
let mut items = Self::remote_matches_to_global(&host_id, success);
let match_count = items.len();
flush_batch(&spawner, search_id, &mut items).await;
Some(SourceResult {
match_count,
capped,
})
}
// An abort is initiated by a newer search (or a reset),
// which already replaced the aggregate state; the stale
// search-id guard drops this outcome regardless.
Err(HostRequestError::Aborted) => None,
Err(err) => {
log::warn!("GlobalSearch: remote search failed for host {host_id}: {err}");
None
}
}
},
ctx,
);
}
/// Spawns one search source (the local ripgrep run, or one remote
/// host's request) and routes its outcome into the shared completion
/// accounting. Sources emit their matches via `Progress`/`ProgressBatch`
/// while running and log their own failures.
fn spawn_source(
&mut self,
search_id: u32,
source_kind: SearchSource,
source: impl Future<Output = Option<SourceResult>> + Send + 'static,
ctx: &mut ModelContext<Self>,
) {
let task = ctx.spawn(source, move |me, outcome, ctx| {
me.handle_source_completed(search_id, source_kind, outcome, ctx);
});
self.search_handles.push(task);
}
/// Converts a remote search response into per-submatch result rows,
/// attaching the originating host to each match location.
fn remote_matches_to_global(
host_id: &HostId,
success: RipgrepSearchSuccess,
) -> Vec<GlobalSearchMatch> {
success
.matches
.into_iter()
.filter_map(|m| {
let path = match StandardizedPath::try_new(&m.file_path) {
Ok(path) => path,
Err(err) => {
log::warn!("GlobalSearch: dropping remote match with invalid path: {err}");
return None;
}
};
let submatches = m
.submatches
.into_iter()
.map(|s| Submatch {
byte_start: ByteOffset::from(s.byte_start as usize),
byte_end: ByteOffset::from(s.byte_end as usize),
})
.collect();
Some(GlobalSearchMatch {
location: LocalOrRemotePath::Remote(RemotePath::new(host_id.clone(), path)),
line_number: m.line_number,
column_num: None,
line_text: m.line_text,
submatches,
})
})
.flat_map(Self::expand_submatches)
.collect()
}
/// Records the completion of one search source (`None` when the source
/// failed; the source already logged the failure). When all sources have
/// finished, emits `Completed` (or `Failed` when every source failed).
fn handle_source_completed(
&mut self,
search_id: u32,
source_kind: SearchSource,
outcome: Option<SourceResult>,
ctx: &mut ModelContext<Self>,
) {
let Some(active) = self.active_search.as_mut() else {
return;
};
if active.search_id != search_id {
return;
}
match outcome {
Some(SourceResult {
match_count,
capped,
}) => {
active.completed_sources += 1;
active.total_match_count += match_count;
active.capped |= capped;
}
None => match source_kind {
SearchSource::Local => active.local_source_failed = true,
SearchSource::Remote => active.remote_source_failures += 1,
},
}
active.remaining_sources = active.remaining_sources.saturating_sub(1);
if active.remaining_sources > 0 {
return;
}
let active = self
.active_search
.take()
.expect("active search was checked above");
if active.completed_sources == 0 {
ctx.emit(GlobalSearchEvent::Failed {
search_id,
error: "Global search failed.".to_string(),
});
} else {
ctx.emit(GlobalSearchEvent::Completed {
search_id,
total_match_count: active.total_match_count,
capped: active.capped,
local_source_failed: active.local_source_failed,
remote_source_failures: active.remote_source_failures,
});
}
}
async fn run_galaxy_ripgrep_cli(
@@ -135,14 +423,14 @@ impl GlobalSearch {
let mut total_match_count: usize = 0;
let mut num_unbatched_emitted: usize = 0;
let mut batch: Vec<RipgrepMatch> = Vec::new();
let mut batch: Vec<GlobalSearchMatch> = Vec::new();
let mut last_batch_flush_at = Instant::now();
while let Some(raw_match) = stream.next().await {
// Expand each submatch into its own result row (matching
// the old per-submatch behavior). Each row gets the line
// text trimmed up to that particular submatch.
for per_submatch in Self::expand_submatches(raw_match) {
for per_submatch in Self::expand_submatches(Self::local_match_to_global(raw_match)) {
total_match_count += 1;
if num_unbatched_emitted < START_BATCH_AFTER_COUNT {
@@ -178,40 +466,65 @@ impl GlobalSearch {
Ok(total_match_count)
}
/// Expand a single ripgrep match (which may contain multiple submatches
fn local_match_to_global(m: RipgrepMatch) -> GlobalSearchMatch {
GlobalSearchMatch {
location: LocalOrRemotePath::Local(m.file_path),
line_number: m.line_number,
column_num: None,
line_text: m.line_text,
submatches: m.submatches,
}
}
/// Expand a single match (which may contain multiple submatches
/// on the same line) into one result per submatch. Each result gets the
/// line text trimmed of leading whitespace up to that submatch.
fn expand_submatches(m: RipgrepMatch) -> Vec<RipgrepMatch> {
fn expand_submatches(m: GlobalSearchMatch) -> Vec<GlobalSearchMatch> {
if m.submatches.len() <= 1 {
let submatch = m.submatches.into_iter().next();
let column_num = Self::column_from_submatch(&m.line_text, submatch.as_ref());
return vec![Self::trim_leading_whitespace_for_submatch(
&m.line_text,
m.file_path,
m.location,
m.line_number,
m.submatches.into_iter().next(),
column_num,
submatch,
)];
}
m.submatches
.into_iter()
.map(|sub| {
let column_num = Self::column_from_submatch(&m.line_text, Some(&sub));
Self::trim_leading_whitespace_for_submatch(
&m.line_text,
m.file_path.clone(),
m.location.clone(),
m.line_number,
column_num,
Some(sub),
)
})
.collect()
}
/// Returns the original 1-based character column for a submatch.
fn column_from_submatch(line_text: &str, submatch: Option<&Submatch>) -> Option<usize> {
let byte_start = submatch?.byte_start.as_usize();
if byte_start > line_text.len() || !line_text.is_char_boundary(byte_start) {
return None;
}
Some(line_text[..byte_start].chars().count() + 1)
}
/// Trim leading whitespace from a line up to the given submatch,
/// adjusting the submatch offset accordingly.
fn trim_leading_whitespace_for_submatch(
original_line: &str,
file_path: PathBuf,
location: LocalOrRemotePath,
line_number: u32,
column_num: Option<usize>,
submatch: Option<Submatch>,
) -> RipgrepMatch {
) -> GlobalSearchMatch {
let submatch_start = submatch
.as_ref()
.map(|s| s.byte_start)
@@ -239,9 +552,10 @@ impl GlobalSearch {
Vec::new()
};
RipgrepMatch {
file_path,
GlobalSearchMatch {
location,
line_number,
column_num,
line_text: trimmed_line,
submatches,
}
@@ -253,3 +567,7 @@ impl Default for GlobalSearch {
Self::new()
}
}
#[cfg(test)]
#[path = "model_tests.rs"]
mod tests;
@@ -0,0 +1,121 @@
use remote_server::proto::{RipgrepSearchMatch, RipgrepSearchSubmatch, RipgrepSearchSuccess};
use remote_server::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::GlobalSearch;
fn host() -> HostId {
HostId::new("test-host".to_string())
}
fn proto_match(
path: &str,
line_number: u32,
line_text: &str,
submatches: Vec<(u64, u64)>,
) -> RipgrepSearchMatch {
RipgrepSearchMatch {
file_path: path.to_string(),
line_number,
line_text: line_text.to_string(),
submatches: submatches
.into_iter()
.map(|(byte_start, byte_end)| RipgrepSearchSubmatch {
byte_start,
byte_end,
})
.collect(),
}
}
#[test]
fn remote_matches_become_remote_locations_on_the_host() {
let success = RipgrepSearchSuccess {
matches: vec![proto_match(
"/repo/src/main.rs",
7,
"fn main() {}",
vec![(3, 7)],
)],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
match &results[0].location {
LocalOrRemotePath::Remote(remote) => {
assert_eq!(remote.host_id, host());
assert_eq!(remote.path.as_str(), "/repo/src/main.rs");
}
LocalOrRemotePath::Local(_) => panic!("expected a remote location"),
}
assert_eq!(results[0].line_number, 7);
assert_eq!(results[0].column_num, Some(4));
assert_eq!(results[0].line_text, "fn main() {}");
}
#[test]
fn remote_matches_expand_one_row_per_submatch() {
let success = RipgrepSearchSuccess {
matches: vec![proto_match(
"/repo/a.rs",
1,
"foo foo",
vec![(0, 3), (4, 7)],
)],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 2);
assert!(results.iter().all(|m| m.submatches.len() == 1));
}
#[test]
fn remote_matches_with_invalid_paths_are_dropped() {
let success = RipgrepSearchSuccess {
matches: vec![
proto_match("relative/path.rs", 1, "x", vec![(0, 1)]),
proto_match("/repo/ok.rs", 2, "x", vec![(0, 1)]),
],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
assert_eq!(results[0].location.display_path(), "/repo/ok.rs");
}
#[test]
fn remote_match_leading_whitespace_is_trimmed_per_submatch() {
// Leading whitespace before the submatch is trimmed and offsets adjusted,
// matching local search behavior.
let success = RipgrepSearchSuccess {
matches: vec![proto_match("/repo/a.rs", 1, " foo", vec![(4, 7)])],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
assert_eq!(results[0].line_text, "foo");
assert_eq!(results[0].column_num, Some(5));
assert_eq!(results[0].submatches[0].byte_start.as_usize(), 0);
assert_eq!(results[0].submatches[0].byte_end.as_usize(), 3);
}
#[test]
fn remote_match_column_counts_characters_not_bytes() {
let success = RipgrepSearchSuccess {
matches: vec![proto_match("/repo/a.rs", 1, "€foo", vec![(3, 6)])],
capped: false,
};
let results = GlobalSearch::remote_matches_to_global(&host(), success);
assert_eq!(results.len(), 1);
assert_eq!(results[0].column_num, Some(2));
}
@@ -1,8 +1,8 @@
use std::path::PathBuf;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::{Entity, ModelContext};
use crate::workspace::view::global_search::view::GlobalSearchEvent;
use crate::workspace::view::global_search::SearchConfig;
use galaxyui::{Entity, ModelContext};
pub struct GlobalSearch {}
@@ -15,12 +15,12 @@ impl GlobalSearch {
GlobalSearch {}
}
pub fn abort_search(&mut self) {}
pub fn abort_search(&mut self, _ctx: &mut ModelContext<Self>) {}
pub fn run_search(
&mut self,
_pattern: String,
_root: Vec<PathBuf>,
_roots: Vec<LocalOrRemotePath>,
_search_config: SearchConfig,
_ctx: &mut ModelContext<Self>,
) {
+232 -142
View File
@@ -5,34 +5,22 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use async_channel::Sender;
use galaxy_editor::editor::NavigationKey;
use galaxy_ripgrep::search::{Match as RipgrepMatch, Submatch};
use instant::Instant;
use pathfinder_geometry::vector::vec2f;
use remote_server::HostId;
use string_offset::{ByteOffset, CharCounter};
use crate::code::icon_from_file_path;
use crate::debounce::debounce;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
};
use crate::search::ItemHighlightState as SearchHighlightState;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon as UiIcon;
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
use crate::workspace::view::global_search::model::GlobalSearch;
use crate::workspace::view::global_search::SearchConfig;
use crate::TelemetryEvent;
use galaxy_core::r#async::debounce;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::{AnsiColorIdentifier, Fill as ThemeFill};
use galaxy_core::ui::Icon;
use galaxy_editor::editor::NavigationKey;
use galaxy_ripgrep::search::Submatch;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Fill, Flex, FormattedTextElement,
@@ -52,6 +40,23 @@ use galaxyui::{
ViewHandle, WeakViewHandle,
};
use crate::code::icon_from_file_path;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
};
use crate::search::ItemHighlightState as SearchHighlightState;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon as UiIcon;
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
use crate::util::path::{display_name_with_host, display_path_with_host};
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
use crate::workspace::view::global_search::model::GlobalSearch;
use crate::workspace::view::global_search::{GlobalSearchMatch, SearchConfig};
use crate::TelemetryEvent;
const BORDER_RADIUS: f32 = 6.;
const BORDER_WIDTH: f32 = 1.;
const DO_NOT_TRUNCATE_CHAR_COUNT: usize = 40;
@@ -77,19 +82,19 @@ enum FocusMode {
#[derive(Debug, Clone)]
pub enum GlobalSearchAction {
SelectRow {
directory_path: PathBuf,
file_path: PathBuf,
directory_path: LocalOrRemotePath,
file_path: LocalOrRemotePath,
match_index: Option<usize>,
},
ToggleFileCollapsed {
directory_path: PathBuf,
file_path: PathBuf,
directory_path: LocalOrRemotePath,
file_path: LocalOrRemotePath,
},
ToggleDirectoryCollapsed {
directory_path: PathBuf,
directory_path: LocalOrRemotePath,
},
OpenMatch {
path: PathBuf,
location: LocalOrRemotePath,
line_number: u32,
column_num: Option<usize>,
},
@@ -108,18 +113,27 @@ pub enum GlobalSearchAction {
pub enum GlobalSearchEvent {
Started {
search_id: u32,
remote_host_count: usize,
},
Progress {
search_id: u32,
result: RipgrepMatch,
result: GlobalSearchMatch,
},
ProgressBatch {
search_id: u32,
items: Vec<RipgrepMatch>,
items: Vec<GlobalSearchMatch>,
},
Completed {
search_id: u32,
total_match_count: usize,
/// True when a remote source hit the server-side match cap.
capped: bool,
/// Whether the local search source failed while another source
/// completed. Results from the surviving sources remain valid.
local_source_failed: bool,
/// Number of remote host search sources that failed while another
/// source completed. Results from the surviving sources remain valid.
remote_source_failures: usize,
},
Failed {
search_id: u32,
@@ -130,7 +144,7 @@ pub enum GlobalSearchEvent {
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub enum Event {
OpenMatch {
path: PathBuf,
location: LocalOrRemotePath,
line_number: u32,
column_num: Option<usize>,
},
@@ -167,14 +181,14 @@ enum RowIndexType {
/// A root directory containing matched files.
struct DirectoryEntry {
path: PathBuf,
path: LocalOrRemotePath,
is_collapsed: bool,
mouse_state: MouseStateHandle,
matched_paths: MatchedPaths,
}
impl DirectoryEntry {
fn new(path: PathBuf) -> Self {
fn new(path: LocalOrRemotePath) -> Self {
Self {
path,
is_collapsed: false,
@@ -207,7 +221,7 @@ impl DirectoryEntry {
/// Collection of matched files within a directory.
struct MatchedPaths {
paths: Vec<MatchedPath>,
index_by_path: HashMap<PathBuf, usize>,
index_by_path: HashMap<LocalOrRemotePath, usize>,
}
impl MatchedPaths {
@@ -223,21 +237,21 @@ impl MatchedPaths {
self.paths.iter().map(|p| p.visible_count()).sum()
}
/// Gets or creates a MatchedPath entry for the given file path.
/// Gets or creates a MatchedPath entry for the given file location.
/// Returns a mutable reference to the entry and its index.
fn get_or_create(&mut self, path: &Path) -> (&mut MatchedPath, usize) {
fn get_or_create(&mut self, path: &LocalOrRemotePath) -> (&mut MatchedPath, usize) {
if let Some(&index) = self.index_by_path.get(path) {
(&mut self.paths[index], index)
} else {
let index = self.paths.len();
self.paths.push(MatchedPath::new(path.to_path_buf()));
self.index_by_path.insert(path.to_path_buf(), index);
self.paths.push(MatchedPath::new(path.clone()));
self.index_by_path.insert(path.clone(), index);
(&mut self.paths[index], index)
}
}
/// Gets a mutable MatchedPath entry by file path.
fn get_mut(&mut self, path: &Path) -> Option<&mut MatchedPath> {
/// Gets a mutable MatchedPath entry by file location.
fn get_mut(&mut self, path: &LocalOrRemotePath) -> Option<&mut MatchedPath> {
self.index_by_path
.get(path)
.copied()
@@ -247,14 +261,14 @@ impl MatchedPaths {
/// A file containing matches.
struct MatchedPath {
path: PathBuf,
path: LocalOrRemotePath,
is_collapsed: bool,
mouse_state: MouseStateHandle,
matches: Vec<Match>,
}
impl MatchedPath {
fn new(path: PathBuf) -> Self {
fn new(path: LocalOrRemotePath) -> Self {
Self {
path,
is_collapsed: false,
@@ -279,15 +293,22 @@ impl MatchedPath {
struct Match {
line_text: String,
line_number: u32,
column_num: Option<usize>,
submatches: Vec<Submatch>,
mouse_state: MouseStateHandle,
}
impl Match {
fn new(line_text: String, line_number: u32, submatches: Vec<Submatch>) -> Self {
fn new(
line_text: String,
line_number: u32,
column_num: Option<usize>,
submatches: Vec<Submatch>,
) -> Self {
Self {
line_text,
line_number,
column_num,
submatches,
mouse_state: MouseStateHandle::default(),
}
@@ -299,17 +320,20 @@ pub struct GlobalSearchView {
query_editor: ViewHandle<EditorView>,
query_change_tx: Sender<()>,
/// All terminal working directories for display grouping (preserved as-is)
root_directories: Vec<PathBuf>,
/// Deduplicated roots for ripgrep search (excludes nested subdirectories)
search_roots: Vec<PathBuf>,
root_directories: Vec<LocalOrRemotePath>,
/// Deduplicated roots for search (excludes nested subdirectories)
search_roots: Vec<LocalOrRemotePath>,
last_searched_pattern: Option<String>,
directory_entries: Vec<DirectoryEntry>,
directory_path_to_directory_index_entry: HashMap<PathBuf, usize>,
directory_path_to_directory_index_entry: HashMap<LocalOrRemotePath, usize>,
selected_row: Option<RowIndex>,
total_match_count: usize,
is_search_in_progress: bool,
capped_matches: bool,
last_error: Option<String>,
/// When the current search started, for completion telemetry.
search_started_at: Option<Instant>,
active_search_remote_host_count: usize,
scroll_state: ScrollStateHandle,
uniform_list_state: UniformListState,
handle: WeakViewHandle<GlobalSearchView>,
@@ -353,12 +377,12 @@ impl TypedActionView for GlobalSearchView {
self.toggle_directory_collapsed(directory_path, ctx);
}
GlobalSearchAction::OpenMatch {
path,
location,
line_number,
column_num,
} => {
ctx.emit(Event::OpenMatch {
path: path.clone(),
location: location.clone(),
line_number: *line_number,
column_num: *column_num,
});
@@ -486,24 +510,6 @@ impl TypedActionView for GlobalSearchView {
}
impl GlobalSearchView {
/// Calculate the 1-indexed column number from the first submatch.
/// Returns None if there are no submatches or the line is empty.
fn column_from_submatches(line_text: &str, submatches: &[Submatch]) -> Option<usize> {
if line_text.is_empty() || submatches.is_empty() {
return None;
}
let first_submatch = &submatches[0];
let max_byte = ByteOffset::from(line_text.len());
let start_b = first_submatch.byte_start.min(max_byte);
let mut char_counter = CharCounter::new(line_text);
let start_char = char_counter.char_offset(start_b)?;
// Return 1-indexed column number
Some(start_char.as_usize() + 1)
}
/// Convert submatch byte ranges into character indices for highlighting.
fn highlight_indices_from_submatches(line_text: &str, submatches: &[Submatch]) -> Vec<usize> {
if line_text.is_empty() || submatches.is_empty() {
@@ -705,6 +711,8 @@ impl GlobalSearchView {
is_search_in_progress: false,
capped_matches: false,
last_error: None,
search_started_at: None,
active_search_remote_host_count: 0,
scroll_state: ScrollStateHandle::default(),
uniform_list_state: UniformListState::new(),
handle,
@@ -746,32 +754,30 @@ impl GlobalSearchView {
ctx.notify();
}
/// Returns an iterator over all directory paths where the given file path should appear.
/// A file matches a directory if the file path starts with that directory.
/// Returns an iterator over all directory locations where the given file should appear.
/// A file matches a directory if the file location starts with that directory
/// (remote files only match directories on the same host).
fn find_matching_directories<'a>(
&'a self,
file_path: &'a Path,
) -> impl Iterator<Item = &'a PathBuf> {
location: &'a LocalOrRemotePath,
) -> impl Iterator<Item = &'a LocalOrRemotePath> {
self.root_directories
.iter()
.filter(move |root| file_path.starts_with(root))
.filter(move |root| location.starts_with(root))
}
fn apply_progress_item(&mut self, result: RipgrepMatch, ctx: &mut ViewContext<Self>) {
fn apply_progress_item(&mut self, result: GlobalSearchMatch, ctx: &mut ViewContext<Self>) {
if self.total_match_count >= MAX_MATCH_COUNT {
return;
}
let file_path = result.file_path.clone();
let location = result.location.clone();
// Find all directories that this file belongs to
let mut matching_directories = self.find_matching_directories(&file_path).peekable();
let mut matching_directories = self.find_matching_directories(&location).peekable();
if matching_directories.peek().is_none() {
// File doesn't match any root directory, skip it
let file_path_name = file_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| std::borrow::Cow::Borrowed("<unknown>"));
let file_path_name = location.file_name().unwrap_or("<unknown>");
log::warn!("[Global search] file {file_path_name} was not found in directories");
return;
}
@@ -795,12 +801,13 @@ impl GlobalSearchView {
// Get or create the matched path entry within this directory
let dir_entry = &mut directory_entries[dir_index];
let (matched_path, _path_index) = dir_entry.matched_paths.get_or_create(&file_path);
let (matched_path, _path_index) = dir_entry.matched_paths.get_or_create(&location);
// Add the match
matched_path.matches.push(Match::new(
result.line_text.clone(),
result.line_number,
result.column_num,
result.submatches.clone(),
));
}
@@ -813,11 +820,16 @@ impl GlobalSearchView {
}
fn abort_search(&mut self, ctx: &mut ViewContext<Self>) {
self.capped_matches = true;
self.cancel_search(ctx);
}
fn cancel_search(&mut self, ctx: &mut ViewContext<Self>) {
self.is_search_in_progress = false;
self.current_search_id = None;
self.search_started_at = None;
self.find_model.update(ctx, |model, _| {
model.abort_search();
self.find_model.update(ctx, |model, model_ctx| {
model.abort_search(model_ctx);
});
}
fn handle_debounced_query_change(&mut self, _event: (), ctx: &mut ViewContext<Self>) {
@@ -840,8 +852,7 @@ impl GlobalSearchView {
| EditorEvent::BufferReinitialized => {
let query_text = self.query_editor.as_ref(ctx).buffer_text(ctx);
if query_text.is_empty() {
self.current_search_id = None;
self.is_search_in_progress = false;
self.cancel_search(ctx);
self.reset_search_state(true);
ctx.notify();
return;
@@ -881,8 +892,7 @@ impl GlobalSearchView {
let pattern = self.query_editor.as_ref(ctx).buffer_text(ctx);
if pattern.is_empty() {
self.current_search_id = None;
self.is_search_in_progress = false;
self.cancel_search(ctx);
self.reset_search_state(true);
ctx.notify();
return;
@@ -924,10 +934,15 @@ impl GlobalSearchView {
fn handle_find_model_event(&mut self, event: &GlobalSearchEvent, ctx: &mut ViewContext<Self>) {
match event {
GlobalSearchEvent::Started { search_id } => {
GlobalSearchEvent::Started {
search_id,
remote_host_count,
} => {
send_telemetry_from_ctx!(TelemetryEvent::GlobalSearchQueryStarted, ctx);
self.current_search_id = Some(*search_id);
self.search_started_at = Some(Instant::now());
self.active_search_remote_host_count = *remote_host_count;
self.is_search_in_progress = true;
self.reset_search_state(false);
@@ -958,6 +973,9 @@ impl GlobalSearchView {
GlobalSearchEvent::Completed {
search_id,
total_match_count,
capped,
local_source_failed,
remote_source_failures,
} => {
if Some(*search_id) != self.current_search_id {
return;
@@ -965,6 +983,21 @@ impl GlobalSearchView {
self.is_search_in_progress = false;
self.total_match_count = *total_match_count;
self.capped_matches |= capped;
if let Some(started_at) = self.search_started_at.take() {
send_telemetry_from_ctx!(
TelemetryEvent::GlobalSearchQueryCompleted {
duration_ms: started_at.elapsed().as_millis() as u64,
remote_host_count: self.active_search_remote_host_count,
total_match_count: *total_match_count,
capped: self.capped_matches,
local_source_failed: *local_source_failed,
remote_source_failures: *remote_source_failures,
},
ctx
);
}
ctx.notify();
}
GlobalSearchEvent::Failed { search_id, error } => {
@@ -973,6 +1006,7 @@ impl GlobalSearchView {
}
self.is_search_in_progress = false;
self.search_started_at = None;
self.reset_search_state(false);
self.last_error = Some(error.clone());
ctx.notify();
@@ -980,11 +1014,53 @@ impl GlobalSearchView {
}
}
pub fn set_root_directories(&mut self, roots: Vec<PathBuf>, _ctx: &mut ViewContext<Self>) {
pub fn set_root_directories(
&mut self,
roots: Vec<LocalOrRemotePath>,
_ctx: &mut ViewContext<Self>,
) {
// Ancestor-dedup search roots so we don't search the same file twice
// when terminal directories are nested (e.g. `~/code` + `~/code/a`).
// Shared with `FileTreeView` for consistency.
self.search_roots = galaxy_util::path::group_roots_by_common_ancestor(&roots).roots;
// Local and remote roots share `group_roots_by_common_ancestor` with
// `FileTreeView` for consistency; remote roots are grouped per host
// and deduped within each host independently.
let local_roots: Vec<PathBuf> = roots
.iter()
.filter_map(|root| root.to_local_path().map(Path::to_path_buf))
.collect();
let deduped_local = galaxy_util::path::group_roots_by_common_ancestor(&local_roots).roots;
let mut remote_roots_by_host: Vec<(HostId, Vec<StandardizedPath>)> = Vec::new();
for root in &roots {
let LocalOrRemotePath::Remote(remote) = root else {
continue;
};
match remote_roots_by_host
.iter_mut()
.find(|(host_id, _)| host_id == &remote.host_id)
{
Some((_, paths)) => paths.push(remote.path.clone()),
None => {
remote_roots_by_host.push((remote.host_id.clone(), vec![remote.path.clone()]))
}
}
}
let deduped_remote = remote_roots_by_host
.into_iter()
.flat_map(|(host_id, paths)| {
galaxy_util::path::group_roots_by_common_ancestor(&paths)
.roots
.into_iter()
.map(move |path| {
LocalOrRemotePath::Remote(RemotePath::new(host_id.clone(), path))
})
});
self.search_roots = deduped_local
.into_iter()
.map(LocalOrRemotePath::Local)
.chain(deduped_remote)
.collect();
self.root_directories = roots;
}
@@ -1030,7 +1106,7 @@ impl GlobalSearchView {
match &row_index.index_type {
RowIndexType::DirectoryHeader => {
self.render_directory_header_from_entry(index, dir_entry, appearance, theme)
self.render_directory_header_from_entry(index, dir_entry, appearance, theme, app)
}
RowIndexType::FileHeader { path_index } => {
let Some(matched_path) = dir_entry.matched_paths.paths.get(*path_index) else {
@@ -1071,7 +1147,7 @@ impl GlobalSearchView {
fn render_file_header(
&self,
index: usize,
directory_path: &Path,
directory_path: &LocalOrRemotePath,
matched_path: &MatchedPath,
appearance: &Appearance,
theme: &galaxy_core::ui::theme::GalaxyTheme,
@@ -1083,14 +1159,14 @@ impl GlobalSearchView {
let file_path = matched_path.path.clone();
let match_count = matched_path.matches.len();
let directory_path_for_select = directory_path.to_path_buf();
let directory_path_for_select = directory_path.clone();
let file_path_clone = file_path.clone();
let directory_path_for_toggle = directory_path.to_path_buf();
let directory_path_for_toggle = directory_path.clone();
let display_path = file_path
.strip_prefix(directory_path)
.unwrap_or(file_path.as_path());
let display_path = display_path.to_path_buf();
let display_path = directory_path
.strip_repo_prefix(&file_path)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(file_path.display_path()));
Hoverable::new(file_mouse_state, move |mouse_state| {
let item_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
@@ -1113,7 +1189,7 @@ impl GlobalSearchView {
.finish();
let chevron_container = Container::new(chevron_icon).with_margin_right(8.).finish();
let tooltip_text = file_path.to_string_lossy().to_string();
let tooltip_text = file_path.display_path();
let header_text_fill = match list_highlight_state {
ItemHighlightState::None => {
@@ -1150,7 +1226,7 @@ impl GlobalSearchView {
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let icon_from_file_path = icon_from_file_path(&file_path.to_string_lossy(), appearance)
let icon_from_file_path = icon_from_file_path(&file_path.display_path(), appearance)
.map(ImageOrIcon::Image)
.unwrap_or(ImageOrIcon::Icon(Icon::File));
@@ -1244,7 +1320,7 @@ impl GlobalSearchView {
fn render_match_row(
&self,
index: usize,
directory_path: &Path,
directory_path: &LocalOrRemotePath,
matched_path: &MatchedPath,
matched: &Match,
match_index: usize,
@@ -1253,17 +1329,14 @@ impl GlobalSearchView {
) -> Box<dyn Element> {
let is_selected = self.is_row_at_index_selected(index);
let line_number = matched.line_number;
let column_num = matched.column_num;
let line_text = matched.line_text.clone();
let submatches = matched.submatches.clone();
let mouse_state = matched.mouse_state.clone();
let directory_path_for_select = directory_path.to_path_buf();
let directory_path_for_select = directory_path.clone();
let file_path_for_select = matched_path.path.clone();
let path_for_click = matched_path.path.clone();
// Clone for the on_click closure since line_text and submatches are moved into Hoverable
let line_text_for_click = line_text.clone();
let submatches_for_click = submatches.clone();
let location_for_click = matched_path.path.clone();
Hoverable::new(mouse_state, move |mouse_state| {
let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
@@ -1319,12 +1392,8 @@ impl GlobalSearchView {
file_path: file_path_for_select.clone(),
match_index: Some(match_index),
});
let column_num = GlobalSearchView::column_from_submatches(
&line_text_for_click,
&submatches_for_click,
);
ctx.dispatch_typed_action(GlobalSearchAction::OpenMatch {
path: path_for_click.clone(),
location: location_for_click.clone(),
line_number,
column_num,
});
@@ -1483,18 +1552,21 @@ impl GlobalSearchView {
.sum()
}
/// Gets or creates a DirectoryEntry for the given path.
/// Gets or creates a DirectoryEntry for the given location.
/// Returns a mutable reference to the entry and its index.
#[allow(dead_code)] // Will be used in later PRs
fn get_or_create_directory_entry(&mut self, path: &Path) -> (&mut DirectoryEntry, usize) {
fn get_or_create_directory_entry(
&mut self,
path: &LocalOrRemotePath,
) -> (&mut DirectoryEntry, usize) {
if let Some(&index) = self.directory_path_to_directory_index_entry.get(path) {
(&mut self.directory_entries[index], index)
} else {
let index = self.directory_entries.len();
self.directory_entries
.push(DirectoryEntry::new(path.to_path_buf()));
.push(DirectoryEntry::new(path.clone()));
self.directory_path_to_directory_index_entry
.insert(path.to_path_buf(), index);
.insert(path.clone(), index);
(&mut self.directory_entries[index], index)
}
}
@@ -1503,8 +1575,8 @@ impl GlobalSearchView {
/// Returns None if the paths are not found
fn path_to_row_index(
&self,
directory_path: &Path,
file_path: &Path,
directory_path: &LocalOrRemotePath,
file_path: &LocalOrRemotePath,
match_index: Option<usize>,
) -> Option<RowIndex> {
let &directory_index = self
@@ -1527,15 +1599,15 @@ impl GlobalSearchView {
})
}
/// Gets the directory path for a given RowIndex.
fn directory_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> {
/// Gets the directory location for a given RowIndex.
fn directory_path_for_row_index(&self, row: &RowIndex) -> Option<&LocalOrRemotePath> {
self.directory_entries
.get(row.directory_index)
.map(|e| &e.path)
}
/// Gets the file path for a given RowIndex (if it refers to a file or match).
fn file_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> {
/// Gets the file location for a given RowIndex (if it refers to a file or match).
fn file_path_for_row_index(&self, row: &RowIndex) -> Option<&LocalOrRemotePath> {
let dir_entry = self.directory_entries.get(row.directory_index)?;
match &row.index_type {
RowIndexType::DirectoryHeader => None,
@@ -1724,12 +1796,10 @@ impl GlobalSearchView {
let Some(matched) = matched_path.matches.get(*match_index) else {
return;
};
let column_num =
Self::column_from_submatches(&matched.line_text, &matched.submatches);
ctx.emit(Event::OpenMatch {
path: matched_path.path.clone(),
location: matched_path.path.clone(),
line_number: matched.line_number,
column_num,
column_num: matched.column_num,
});
}
RowIndexType::DirectoryHeader => {
@@ -1740,7 +1810,11 @@ impl GlobalSearchView {
}
}
fn toggle_directory_collapsed(&mut self, directory_path: &Path, ctx: &mut ViewContext<Self>) {
fn toggle_directory_collapsed(
&mut self,
directory_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
let Some(&dir_idx) = self
.directory_path_to_directory_index_entry
.get(directory_path)
@@ -1774,8 +1848,8 @@ impl GlobalSearchView {
fn toggle_file_collapsed(
&mut self,
directory_path: &Path,
file_path: &PathBuf,
directory_path: &LocalOrRemotePath,
file_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
// Get directory index
@@ -1853,7 +1927,8 @@ impl GlobalSearchView {
index: usize,
dir_entry: &DirectoryEntry,
appearance: &Appearance,
theme: &galaxy_core::ui::theme::GalaxyTheme,
theme: &galaxy_core::ui::theme::WarpTheme,
app: &AppContext,
) -> Box<dyn Element> {
let is_selected = self.is_row_at_index_selected(index);
let mouse_state = dir_entry.mouse_state.clone();
@@ -1861,15 +1936,13 @@ impl GlobalSearchView {
let is_collapsed = dir_entry.is_collapsed;
let directory_path = &dir_entry.path;
// Get the display name (last component of the path)
let display_name = directory_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory_path.to_string_lossy().to_string());
let directory_path = directory_path.clone();
let display_name = if directory_path.display_name().is_empty() {
display_path_with_host(directory_path, false, app)
} else {
display_name_with_host(directory_path, app)
};
let directory_path_for_click = directory_path.clone();
let tooltip_text = directory_path.to_string_lossy().to_string();
let tooltip_text = display_path_with_host(directory_path, false, app);
Hoverable::new(mouse_state, move |mouse_state| {
let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
@@ -1995,8 +2068,15 @@ impl View for GlobalSearchView {
fn render(&self, app: &AppContext) -> Box<dyn Element> {
match self.enablement {
CodingPanelEnablementState::RemoteSession { .. } => {
return self.render_remote_state(app);
CodingPanelEnablementState::PendingRemoteSession => {
return self.render_remote_loading_state(app);
}
CodingPanelEnablementState::RemoteSession { has_remote_server } => {
// Remote-server sessions can search via the daemon; sessions
// without one (tmux / subshell SSH) stay unavailable.
if !has_remote_server {
return self.render_remote_state(app);
}
}
CodingPanelEnablementState::UnsupportedSession => {
return self.render_unsupported_session_state(app);
@@ -2066,8 +2146,10 @@ impl View for GlobalSearchView {
let files = self.unique_match_count();
let file_word = if files == 1 { "file" } else { "files" };
let message = if self.is_search_in_progress && self.total_match_count == 0 {
"".to_string()
let message = if let Some(error) = &self.last_error {
error.clone()
} else if self.is_search_in_progress && self.total_match_count == 0 {
"Searching…".to_string()
} else if !self.is_search_in_progress && self.total_match_count == 0 {
"No results found. Review your gitignore files.".to_string()
} else {
@@ -2256,7 +2338,7 @@ impl GlobalSearchView {
self.render_zero_state(
Icon::AlertTriangle,
"Global search unavailable",
"Global search requries access to your local workspace. Open a new session or navigate to an active session to view.",
"Global search requires access to your local workspace. Open a new session or navigate to an active session to view.",
app,
)
}
@@ -2265,7 +2347,15 @@ impl GlobalSearchView {
self.render_zero_state(
Icon::AlertTriangle,
"Global search unavailable",
"Global search requires access to your local workspace, which isn't supported in remote sessions",
"Global search isn't available for this remote session.",
app,
)
}
fn render_remote_loading_state(&self, app: &AppContext) -> Box<dyn Element> {
self.render_zero_state(
Icon::Loading,
"Connecting to remote session",
"Global search will be available once the connection is ready.",
app,
)
}
@@ -1,7 +1,9 @@
use std::rc::Rc;
use warpui::ViewContext;
use super::Slide;
use crate::server::telemetry::TelemetryEvent;
use galaxyui::ViewContext;
use std::rc::Rc;
/// A callback function for custom CTA button actions.
type CustomCallback<S> = Rc<dyn Fn(&mut ViewContext<super::LaunchModal<S>>)>;
+10 -9
View File
@@ -2,14 +2,12 @@
pub mod cta_button;
pub mod oz_launch;
use std::collections::HashMap;
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
// Re-export slide types for convenience
pub use oz_launch::OzLaunchSlide;
use crate::settings::PrivacySettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme};
use crate::workspace::view::launch_modal::cta_button::{CTAButton, CTAButtonAction};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::assets::asset_cache::AssetSource;
@@ -28,9 +26,12 @@ use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
use pathfinder_color::ColorU;
use std::collections::HashMap;
use crate::settings::PrivacySettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme};
use crate::workspace::view::launch_modal::cta_button::{CTAButton, CTAButtonAction};
pub fn init<S: Slide>(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
@@ -1,3 +1,9 @@
use asset_macro::bundled_or_fetched_asset;
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
use galaxy_core::send_telemetry_from_ctx;
use warpui::assets::asset_cache::AssetSource;
use warpui::{AppContext, SingletonEntity};
use super::{CTAButton, CheckboxConfig, LaunchModalEvent, Slide};
use crate::ai::ambient_agents::telemetry::{CloudAgentTelemetryEvent, CloudModeEntryPoint};
use crate::terminal::view::OnboardingIntention;
@@ -6,11 +12,6 @@ use crate::workspace::action::WorkspaceAction;
use crate::workspace::view::OnboardingTutorial;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::{AdminEnablementSetting, UgcCollectionEnablementSetting};
use asset_macro::bundled_or_fetched_asset;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::{AppContext, SingletonEntity};
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OzLaunchSlide {
+114 -59
View File
@@ -1,38 +1,54 @@
use std::collections::HashSet;
use std::path::PathBuf;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::{send_telemetry_from_ctx, ui::Icon};
use galaxy_core::ui::Icon;
use galaxy_util::path::LineAndColumnArg;
use galaxyui::elements::{
resizable_state_handle, ChildView, ConstrainedBox, Container, CrossAxisAlignment, DragBarSide,
Element, Empty, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
Resizable, ResizableStateHandle, Shrinkable,
};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
resizable_state_handle, ChildView, ConstrainedBox, Container, CrossAxisAlignment,
DragBarSide, Element, Empty, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Resizable, ResizableStateHandle, Shrinkable,
},
platform::Cursor,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::appearance::Appearance;
use crate::code::buffer_location::LocalOrRemotePath;
#[cfg(feature = "local_fs")]
use crate::code::file_tree::FileTreeEvent;
use crate::code::file_tree::FileTreeView;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use crate::drive::panel::{DrivePanel, DrivePanelEvent};
use crate::drive::panel::{
DrivePanel, DrivePanelEvent, MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH,
};
use crate::pane_group::pane::view::header::components::HEADER_EDGE_PADDING;
use crate::pane_group::pane::view::header::PANE_HEADER_HEIGHT;
use crate::pane_group::working_directories::WorkingDirectory;
use crate::pane_group::{PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel};
use crate::pane_group::{
PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel, {self},
};
#[cfg(feature = "local_fs")]
use crate::server::telemetry::CodePanelsFileOpenEntrypoint;
use crate::server::telemetry::{FileTreeSource, WarpDriveSource};
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
use crate::terminal::resizable_data::{ModalType, ResizableData};
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
use crate::ui_components::icons;
use crate::util::bindings::keybinding_name_to_display_string;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::EditorSettings;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::resolve_file_target_with_editor_choice;
use crate::util::openable_file_type::FileTarget;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{
is_markdown_file, resolve_file_target_with_editor_choice, EditorLayout,
};
use crate::workspace::view::conversation_list::view::{
ConversationListView, Event as ConversationListViewEvent,
};
@@ -45,28 +61,15 @@ use crate::workspace::view::{
OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME,
TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME,
};
use crate::{
appearance::Appearance,
code::file_tree::FileTreeView,
drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH},
pane_group::pane::view::header::{components::HEADER_EDGE_PADDING, PANE_HEADER_HEIGHT},
pane_group::{self},
terminal::resizable_data::{ModalType, ResizableData},
ui_components::{
buttons::{icon_button, icon_button_with_color},
icons,
},
util::bindings::keybinding_name_to_display_string,
workspace::WorkspaceAction,
TelemetryEvent,
};
use crate::workspace::WorkspaceAction;
use crate::TelemetryEvent;
#[derive(Default)]
struct MouseStateHandles {
project_explorer_button: MouseStateHandle,
conversation_list_view_button: MouseStateHandle,
global_search_button: MouseStateHandle,
warp_drive_button: MouseStateHandle,
conversation_list_view_button: MouseStateHandle,
}
#[derive(Clone, Debug)]
@@ -77,13 +80,14 @@ pub enum LeftPanelAction {
ConversationListView,
}
#[allow(clippy::large_enum_variant)]
pub enum LeftPanelEvent {
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
FileTree(pane_group::Event),
WarpDrive(DrivePanelEvent),
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
OpenFileWithTarget {
path: PathBuf,
location: LocalOrRemotePath,
target: FileTarget,
line_col: Option<LineAndColumnArg>,
},
@@ -106,9 +110,10 @@ pub enum ToolPanelView {
/// Encapsulates the active view state to enforce that all mutations go through
/// `active_view_state::set`, which handles necessary side effects.
mod active_view_state {
use super::ToolPanelView;
use galaxyui::ViewContext;
use super::ToolPanelView;
pub struct ActiveViewState(ToolPanelView);
impl ActiveViewState {
@@ -270,27 +275,45 @@ impl LeftPanelView {
}
let has_terminal_session = directories.iter().any(|dir| dir.terminal_id.is_some());
// Update GlobalSearchView root directories based on all working directories
let roots: Vec<PathBuf> = directories.iter().map(|d| d.path.clone()).collect();
// Split directories into local and remote.
let local_paths: Vec<PathBuf> = directories
.iter()
.filter_map(|d| d.path.to_local_path().map(|p| p.to_path_buf()))
.collect();
#[allow(unused_variables)]
let remote_repos: Vec<repo_metadata::RemoteRepositoryIdentifier> = directories
.iter()
.filter_map(|d| match &d.path {
LocalOrRemotePath::Remote(remote_path) => {
Some(repo_metadata::RemoteRepositoryIdentifier::new(
remote_path.host_id.clone(),
remote_path.path.clone(),
))
}
_ => None,
})
.collect();
// Update GlobalSearchView root directories (local + remote).
let all_directories: Vec<LocalOrRemotePath> =
directories.iter().map(|d| d.path.clone()).collect();
let global_search_view =
me.get_or_create_global_search_view_for_pane_group(active_pane_group.id(), ctx);
global_search_view.update(ctx, |view, view_ctx| {
view.set_root_directories(roots, view_ctx);
view.set_root_directories(all_directories, view_ctx);
});
let directories: Vec<PathBuf> =
directories.iter().map(|dir| dir.path.clone()).collect();
// Directories are already in display order (most recent first) from the model
let directories = deduplicate_by_directory_name(directories);
let local_directories = deduplicate_by_directory_name(local_paths);
let file_tree_view =
me.get_or_create_file_tree_view_for_pane_group(active_pane_group.id(), ctx);
let is_visible =
active_pane_group.as_ref(ctx).left_panel_open && me.is_file_tree_active();
file_tree_view.update(ctx, |view, ctx| {
view.set_root_directories(directories, ctx);
view.set_root_directories(local_directories, ctx);
#[cfg(feature = "local_fs")]
view.set_remote_root_directories(&remote_repos, ctx);
view.set_has_terminal_session(has_terminal_session, ctx);
view.set_is_active(is_visible, ctx);
@@ -591,26 +614,44 @@ impl LeftPanelView {
.iter()
.any(|dir| dir.terminal_id.is_some());
// Update GlobalSearchView root directories based on all working directories
let roots: Vec<PathBuf> = active_directories.iter().map(|d| d.path.clone()).collect();
// Split directories into local and remote.
let local_paths: Vec<PathBuf> = active_directories
.iter()
.filter_map(|d| d.path.to_local_path().map(|p| p.to_path_buf()))
.collect();
#[allow(unused_variables)]
let remote_repos: Vec<repo_metadata::RemoteRepositoryIdentifier> = active_directories
.iter()
.filter_map(|d| match &d.path {
LocalOrRemotePath::Remote(remote_path) => {
Some(repo_metadata::RemoteRepositoryIdentifier::new(
remote_path.host_id.clone(),
remote_path.path.clone(),
))
}
_ => None,
})
.collect();
// Update GlobalSearchView root directories (local + remote).
let all_directories: Vec<LocalOrRemotePath> =
active_directories.iter().map(|d| d.path.clone()).collect();
let global_search_view =
self.get_or_create_global_search_view_for_pane_group(pane_group_id, ctx);
global_search_view.update(ctx, |view, view_ctx| {
view.set_root_directories(roots, view_ctx);
view.set_root_directories(all_directories, view_ctx);
});
let directories: Vec<PathBuf> = active_directories
.iter()
.map(|dir| dir.path.clone())
.collect();
let directories = deduplicate_by_directory_name(directories);
let local_directories = deduplicate_by_directory_name(local_paths);
let active_file_model = pane_group.as_ref(ctx).active_file_model().clone();
let file_tree_view = self.get_or_create_file_tree_view_for_pane_group(pane_group_id, ctx);
let left_panel_open = pane_group.as_ref(ctx).left_panel_open;
let is_visible = left_panel_open && self.is_file_tree_active();
file_tree_view.update(ctx, |view, ctx| {
view.set_root_directories(directories, ctx);
view.set_root_directories(local_directories, ctx);
#[cfg(feature = "local_fs")]
view.set_remote_root_directories(&remote_repos, ctx);
view.set_has_terminal_session(has_terminal_session, ctx);
view.set_active_file_model(active_file_model, ctx);
view.set_is_active(is_visible, ctx);
@@ -701,7 +742,7 @@ impl LeftPanelView {
) {
match event {
GlobalSearchViewEvent::OpenMatch {
path,
location,
line_number,
column_num,
} => {
@@ -711,13 +752,27 @@ impl LeftPanelView {
};
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target_with_editor_choice(
path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
);
let target = match location {
LocalOrRemotePath::Local(path) => resolve_file_target_with_editor_choice(
path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
),
// Local-fs-based target resolution can't inspect remote
// files; mirror the file tree's remote handling (code
// editor, or markdown viewer by extension + preference).
LocalOrRemotePath::Remote(remote) => {
let is_markdown =
is_markdown_file(std::path::Path::new(remote.path.as_str()));
if is_markdown && *settings.prefer_markdown_viewer {
FileTarget::MarkdownViewer(EditorLayout::SplitPane)
} else {
FileTarget::CodeEditor(EditorLayout::SplitPane)
}
}
};
send_telemetry_from_ctx!(
TelemetryEvent::CodePanelsFileOpened {
@@ -728,7 +783,7 @@ impl LeftPanelView {
);
ctx.emit(LeftPanelEvent::OpenFileWithTarget {
path: path.clone(),
location: location.clone(),
target,
line_col: Some(line_col),
});
@@ -761,7 +816,7 @@ impl LeftPanelView {
line_col,
} => {
ctx.emit(LeftPanelEvent::OpenFileWithTarget {
path: path.clone(),
location: path.clone(),
target: target.clone(),
line_col: *line_col,
});
@@ -1083,11 +1138,11 @@ impl View for LeftPanelView {
let mouse_state_handles = vec![
self.mouse_state_handles.project_explorer_button.clone(),
self.mouse_state_handles.global_search_button.clone(),
self.mouse_state_handles.warp_drive_button.clone(),
self.mouse_state_handles
.conversation_list_view_button
.clone(),
self.mouse_state_handles.global_search_button.clone(),
self.mouse_state_handles.warp_drive_button.clone(),
];
// If there is only one button in the toolbelt row,
+27 -8
View File
@@ -1,16 +1,18 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
use galaxy_core::execution_mode::AppExecutionMode;
use warpui::{SingletonEntity as _, ViewContext};
use crate::pane_group::{NewTerminalOptions, PanesLayout};
use crate::settings::AISettings;
use crate::terminal;
use crate::terminal::view::{
AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction,
};
use crate::workspace::Workspace;
use crate::FeatureFlag;
use galaxyui::{SingletonEntity as _, ViewContext};
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::{terminal, FeatureFlag};
/// Configuration for starting the agent onboarding tutorial.
#[derive(Debug, Clone)]
@@ -87,6 +89,12 @@ impl Workspace {
tutorial: OnboardingTutorial,
ctx: &mut ViewContext<Self>,
) {
// Onboarding requires a real user to interact with it; skip when running
// in a headless mode like the SDK/CLI.
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
return;
}
match tutorial {
OnboardingTutorial::InitProject {
ref path,
@@ -143,6 +151,12 @@ impl Workspace {
intention: OnboardingIntention,
ctx: &mut ViewContext<Self>,
) {
// Onboarding requires a real user to interact with it; skip when running
// in a headless mode like the SDK/CLI.
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
return;
}
// With new onboarding, skip the guided tour when AI is not enabled
// (e.g. terminal-intent users or users who disabled AI).
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
@@ -237,7 +251,12 @@ impl Workspace {
);
}
pub(crate) fn should_show_agent_onboarding(&self, _ctx: &mut ViewContext<Self>) -> bool {
pub(crate) fn should_show_agent_onboarding(&self, ctx: &mut ViewContext<Self>) -> bool {
// Onboarding requires a real user to interact with it; suppress when
// running in a headless mode like the SDK/CLI.
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
return false;
}
FeatureFlag::AgentOnboarding.is_enabled()
}
}
@@ -1,4 +1,10 @@
use galaxy_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
use galaxy_core::ui::theme::Fill;
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::elements::{
Align, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
@@ -14,8 +20,6 @@ use galaxyui::{
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
@@ -190,15 +194,23 @@ impl OpenWarpLaunchModal {
}
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
Container::new(
Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_badge_text())
.finish(),
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(PhenomenonStyle::modal_badge_text())
.finish();
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(text)
.finish(),
)
.with_horizontal_padding(8.)
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_horizontal_padding(8.)
.with_vertical_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
.with_height(24.)
.finish()
}
@@ -0,0 +1,3 @@
mod view;
pub use view::{init, OrchestrationLaunchModal, OrchestrationLaunchModalEvent};
@@ -0,0 +1,445 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{
Align, CacheOption, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Expanded, Flex, Image, MainAxisSize, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::FixedBinding;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
const MODAL_WIDTH: f32 = 420.;
const HERO_HEIGHT: f32 = 92.;
const HERO_IMAGE_PATH: &str = "async/png/onboarding/orchestration_launch_banner.png";
const LEARN_MORE_URL: &str = "https://www.warp.dev/blog/multi-harness-cloud-agent-orchestration";
fn modal_background(appearance: &Appearance) -> Fill {
appearance.theme().surface_3()
}
fn modal_text_main(appearance: &Appearance) -> ColorU {
appearance
.theme()
.main_text_color(modal_background(appearance))
.into_solid()
}
fn modal_text_sub(appearance: &Appearance) -> ColorU {
appearance
.theme()
.sub_text_color(modal_background(appearance))
.into_solid()
}
fn modal_overlay_1(appearance: &Appearance) -> Fill {
appearance.theme().surface_overlay_1()
}
fn modal_overlay_2(appearance: &Appearance) -> Fill {
appearance.theme().surface_overlay_2()
}
fn modal_terminal_magenta(appearance: &Appearance) -> ColorU {
appearance.theme().terminal_colors().normal.magenta.into()
}
fn modal_terminal_magenta_overlay_1(appearance: &Appearance) -> ColorU {
let magenta = appearance.theme().terminal_colors().normal.magenta;
appearance.theme().ansi_overlay_1(magenta)
}
struct FeatureItem {
icon: Icon,
title: &'static str,
description: &'static str,
badge: Option<&'static str>,
}
const FEATURE_ITEMS: &[FeatureItem] = &[
FeatureItem {
icon: Icon::Cloud,
title: "Run any agent harness in the cloud",
description: "Use Oz to spin up Claude Code or Codex agents in the cloud; Oz will help you track and steer the agents.",
badge: None,
},
FeatureItem {
icon: Icon::Atom,
title: "Multi-agent orchestration",
description: "Warp Agents will now orchestrate swarms of subagents, allowing you to parallelize tasks.",
badge: None,
},
FeatureItem {
icon: Icon::Cognition,
title: "Agent Memory",
description: "Agents will now store and access long-term memories, enabling self-improvement over time.",
badge: Some("Research preview"),
},
];
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
OrchestrationLaunchModalAction::Close,
id!(OrchestrationLaunchModal::ui_name()),
)]);
}
#[derive(Clone, Debug)]
pub enum OrchestrationLaunchModalAction {
Close,
LearnMore,
}
#[derive(Clone, Debug)]
pub enum OrchestrationLaunchModalEvent {
Close,
}
struct CloseButtonTheme;
impl ActionButtonTheme for CloseButtonTheme {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(modal_overlay_1(appearance))
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
_appearance: &Appearance,
) -> ColorU {
ColorU::white()
}
}
struct LearnMoreButtonTheme;
impl ActionButtonTheme for LearnMoreButtonTheme {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
if hovered {
Some(modal_overlay_2(appearance))
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
modal_text_main(appearance)
}
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
Some(appearance.theme().outline().into_solid())
}
}
struct CtaButtonTheme;
impl ActionButtonTheme for CtaButtonTheme {
fn background(&self, _hovered: bool, appearance: &Appearance) -> Option<Fill> {
Some(Fill::Solid(appearance.theme().foreground().into_solid()))
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
appearance.theme().background().into_solid()
}
}
pub struct OrchestrationLaunchModal {
close_button: ViewHandle<ActionButton>,
learn_more_button: ViewHandle<ActionButton>,
go_to_warp_button: ViewHandle<ActionButton>,
}
impl OrchestrationLaunchModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let close_button = ctx.add_view(|_ctx| {
ActionButton::new("", CloseButtonTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(OrchestrationLaunchModalAction::Close))
});
let learn_more_button = ctx.add_view(|_ctx| {
ActionButton::new("Learn more", LearnMoreButtonTheme)
.with_icon(Icon::LinkExternal)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(OrchestrationLaunchModalAction::LearnMore)
})
});
let go_to_warp_button = ctx.add_view(|_ctx| {
ActionButton::new("Close", CtaButtonTheme)
.with_full_width(true)
.on_click(|ctx| ctx.dispatch_typed_action(OrchestrationLaunchModalAction::Close))
});
Self {
close_button,
learn_more_button,
go_to_warp_button,
}
}
fn render_hero(&self) -> Box<dyn Element> {
let hero = Clipped::new(
ConstrainedBox::new(
Image::new(
AssetSource::Bundled {
path: HERO_IMAGE_PATH,
},
CacheOption::Original,
)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
.cover()
.top_aligned()
.finish(),
)
.with_width(MODAL_WIDTH)
.with_height(HERO_HEIGHT)
.finish(),
)
.finish();
let close_el = Container::new(ChildView::new(&self.close_button).finish())
.with_uniform_padding(4.)
.with_padding_right(2.)
.finish();
let mut hero_stack = Stack::new();
hero_stack.add_child(hero);
hero_stack.add_positioned_child(
close_el,
OffsetPositioning::offset_from_parent(
vec2f(-4., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
hero_stack.finish()
}
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
let text_color = modal_terminal_magenta(appearance);
let background_color = modal_terminal_magenta_overlay_1(appearance);
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
.with_color(text_color)
.finish();
ConstrainedBox::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(text)
.finish(),
)
.with_horizontal_padding(8.)
.with_background(Fill::Solid(background_color))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_height(24.)
.finish()
}
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"Orchestrate any agent, anywhere",
appearance.ui_font_family(),
20.,
)
.with_color(modal_text_main(appearance))
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
}
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"We've made major improvements to Warp's cloud agent orchestration platform, Oz.",
appearance.ui_font_family(),
14.,
)
.with_color(modal_text_sub(appearance))
.finish()
}
fn render_feature_badge(label: &'static str, appearance: &Appearance) -> Box<dyn Element> {
let font_family = appearance.ui_font_family();
let color = modal_text_sub(appearance);
Container::new(
Text::new_inline(label.to_string(), font_family, 11.)
.with_color(color)
.finish(),
)
.with_horizontal_padding(6.)
.with_vertical_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(modal_overlay_1(appearance))
.finish()
}
fn render_feature_row(&self, item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
let icon_el = ConstrainedBox::new(
item.icon
.to_warpui_icon(Fill::Solid(modal_text_sub(appearance)))
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let mut title_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(6.);
title_row.add_child(
Text::new_inline(item.title.to_string(), appearance.ui_font_family(), 14.)
.with_color(modal_text_main(appearance))
.finish(),
);
if let Some(badge_label) = item.badge {
title_row.add_child(Self::render_feature_badge(badge_label, appearance));
}
let text_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(2.)
.with_child(title_row.finish())
.with_child(
Text::new(item.description, appearance.ui_font_family(), 14.)
.with_color(modal_text_sub(appearance))
.finish(),
)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(10.)
.with_child(icon_el)
.with_child(Expanded::new(1., text_col).finish())
.finish()
}
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut features_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(12.);
for item in FEATURE_ITEMS {
features_col.add_child(self.render_feature_row(item, appearance));
}
let footer = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(
Expanded::new(1., ChildView::new(&self.learn_more_button).finish()).finish(),
)
.with_child(
Expanded::new(1., ChildView::new(&self.go_to_warp_button).finish()).finish(),
)
.finish();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(8.)
.with_child(Self::render_badge(appearance))
.with_child(Self::render_title(appearance))
.with_child(Self::render_description(appearance))
.finish(),
)
.with_child(
Container::new(features_col.finish())
.with_margin_top(16.)
.finish(),
)
.with_child(Container::new(footer).with_margin_top(32.).finish())
.finish(),
)
.with_horizontal_padding(32.)
.with_vertical_padding(32.)
.with_background(modal_background(appearance))
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
}
}
impl Entity for OrchestrationLaunchModal {
type Event = OrchestrationLaunchModalEvent;
}
impl View for OrchestrationLaunchModal {
fn ui_name() -> &'static str {
"OrchestrationLaunchModal"
}
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
ctx.focus_self();
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let card = ConstrainedBox::new(
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_hero())
.with_child(self.render_body(appearance))
.finish(),
)
.with_background(modal_background(appearance))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
.with_width(MODAL_WIDTH)
.finish();
Container::new(Align::new(card).finish())
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
.finish()
}
}
impl TypedActionView for OrchestrationLaunchModal {
type Action = OrchestrationLaunchModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
OrchestrationLaunchModalAction::Close => {
ctx.emit(OrchestrationLaunchModalEvent::Close);
}
OrchestrationLaunchModalAction::LearnMore => {
ctx.open_url(LEARN_MORE_URL);
}
}
}
}
+328 -163
View File
@@ -1,64 +1,63 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use dunce::canonicalize;
use itertools::Itertools;
use pathfinder_color::ColorU;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::Icon;
use warp_util::path::LineAndColumnArg;
use warpui::elements::{
resizable_state_handle, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container,
CrossAxisAlignment, DragBarSide, Element, Empty, Flex, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, PositionedElementAnchor, Resizable, ResizableStateHandle,
Shrinkable, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::EditableBinding;
use warpui::platform::Cursor;
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle, WeakViewHandle,
};
use crate::ai::agent::AgentReviewCommentBatch;
use crate::appearance::{Appearance, AppearanceEvent};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code_review::code_review_header::HEADER_BUTTON_PADDING;
#[cfg(feature = "local_fs")]
use crate::code_review::code_review_view::CodeReviewAction;
use crate::code_review::code_review_view::{
render_file_navigation_button, CodeReviewView, CONTENT_LEFT_MARGIN, CONTENT_RIGHT_MARGIN,
render_file_navigation_button, CodeReviewCommentDebugState, CodeReviewView,
CodeReviewViewEvent, ReviewActionTargetProvider, CONTENT_LEFT_MARGIN, CONTENT_RIGHT_MARGIN,
};
use crate::code_review::code_review_view::{CodeReviewCommentDebugState, CodeReviewViewEvent};
use crate::code_review::diff_state::DiffStateModel;
use crate::code_review::telemetry_event::CodeReviewContextDestination;
use crate::pane_group::pane::view::header::{components::HEADER_EDGE_PADDING, PANE_HEADER_HEIGHT};
use crate::pane_group::WorkingDirectoriesEvent;
use crate::pane_group::{Event as PaneGroupEvent, PaneGroup, WorkingDirectoriesModel};
use crate::drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH};
use crate::pane_group::pane::view::header::components::HEADER_EDGE_PADDING;
use crate::pane_group::pane::view::header::PANE_HEADER_HEIGHT;
use crate::pane_group::{
Event as PaneGroupEvent, PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel,
};
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::input::MenuPositioning;
use crate::terminal::resizable_data::{ModalType, ResizableData};
use crate::terminal::view::TerminalView;
use crate::terminal::CLIAgent;
use crate::ui_components::{buttons::icon_button_with_color, icons};
use crate::ui_components::buttons::icon_button_with_color;
use crate::ui_components::icons;
use crate::util::bindings::{keybinding_name_to_display_string, CustomAction};
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::FileTarget;
use crate::util::path::{display_name_with_host, display_path_with_host};
use crate::view_components::action_button::{ActionButton, PaneHeaderTheme};
#[cfg(feature = "local_fs")]
use crate::view_components::action_button::{NakedTheme, TooltipAlignment};
use crate::view_components::{Dropdown, DropdownItem};
use crate::workspace::view::TOGGLE_RIGHT_PANEL_BINDING_NAME;
use crate::workspace::WorkspaceAction;
use crate::{
appearance::Appearance,
drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH},
terminal::resizable_data::{ModalType, ResizableData},
};
use crate::{code_review::diff_state::DiffStateModel, terminal::view::TerminalView};
use dunce::canonicalize;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::Icon;
use galaxy_util::path::LineAndColumnArg;
use galaxyui::elements::{ChildAnchor, Empty, PositionedElementAnchor};
use galaxyui::keymap::EditableBinding;
use galaxyui::EntityId;
use galaxyui::{
elements::{
resizable_state_handle, Container, DragBarSide, Element, MainAxisSize, MouseStateHandle,
Resizable, ResizableStateHandle,
},
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle, WeakViewHandle,
};
use galaxyui::{
elements::{
ChildView, Clipped, ConstrainedBox, CrossAxisAlignment, Flex, MainAxisAlignment,
ParentElement, Shrinkable, Text,
},
fonts::{Properties, Weight},
platform::Cursor,
ui_components::components::UiComponent,
};
use itertools::Itertools;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
/// Describes which agent destination is available for sending review comments.
#[derive(Clone, Debug, PartialEq)]
@@ -107,7 +106,7 @@ impl ReviewTerminalUnavailableReason {
#[derive(Debug)]
struct ReviewTerminalStatus {
active_session_path: Option<PathBuf>,
current_repo_path: Option<PathBuf>,
current_repo_path: Option<LocalOrRemotePath>,
active_cli_agent: Option<String>,
is_executing: bool,
is_input_box_visible: bool,
@@ -119,13 +118,71 @@ impl ReviewTerminalStatus {
}
}
/// `ReviewActionTargetProvider` backed by the right panel's active pane group,
/// so code review actions resolve their target terminal at action time instead
/// of using a handle captured when the review view was created.
struct RightPanelReviewActionTargetProvider {
right_panel: WeakViewHandle<RightPanelView>,
}
impl ReviewActionTargetProvider for RightPanelReviewActionTargetProvider {
fn attach_terminal(
&self,
repo_path: &LocalOrRemotePath,
app: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
let right_panel = self.right_panel.upgrade(app)?;
right_panel.read(app, |panel, app| {
let pane_group = panel.active_pane_group.as_ref()?;
let ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app);
panel
.find_review_terminal(pane_group, repo_path, ai_enabled, app)
.or_else(|| {
// No terminal is available (e.g. all candidates are
// executing). Fall back to the focused terminal when it is
// inside the repo, so per-action handling for busy
// terminals still targets the focused conversation.
let focused = pane_group
.read(app, |pane_group, app| pane_group.focused_session_view(app))?;
let status = RightPanelView::review_terminal_status(
&focused,
Some(repo_path),
ai_enabled,
app,
);
let in_repo = !status.unavailable_reasons.iter().any(|reason| {
matches!(
reason,
ReviewTerminalUnavailableReason::NoSelectedRepo
| ReviewTerminalUnavailableReason::SessionPathUnavailable
| ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo
)
});
in_repo.then_some(focused)
})
})
}
fn focused_terminal(&self, app: &AppContext) -> Option<ViewHandle<TerminalView>> {
let right_panel = self.right_panel.upgrade(app)?;
right_panel.read(app, |panel, app| {
let pane_group = panel.active_pane_group.as_ref()?;
pane_group.read(app, |pane_group, app| {
pane_group
.focused_session_view(app)
.or_else(|| pane_group.active_session_view(app))
})
})
}
}
struct CodeReviewState {
dropdown: ViewHandle<Dropdown<RightPanelAction>>,
available_repos: Vec<PathBuf>,
available_repos: Vec<LocalOrRemotePath>,
/// The repository path of the focused terminal
focused_repo_path: Option<PathBuf>,
focused_repo_path: Option<LocalOrRemotePath>,
/// The repository path of the repository selected in the dropdown
selected_repo_path: Option<PathBuf>,
selected_repo_path: Option<LocalOrRemotePath>,
/// Avoid showing the jump-to-repo button if the focused repo has not changed
did_focused_repo_change: bool,
}
@@ -136,16 +193,27 @@ struct CodeReviewSessionEnv {
is_wsl: bool,
}
/// Resolve the repo-switcher dropdown's text color from the current theme.
/// Kept as a free function so the construction site and the
/// `AppearanceEvent::ThemeChanged` subscription compute the exact same color.
fn repo_dropdown_font_color(appearance: &Appearance) -> ColorU {
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid()
}
impl CodeReviewState {
pub fn new(ctx: &mut ViewContext<RightPanelView>) -> Self {
CodeReviewState {
dropdown: ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let font_color = appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid();
let ui_font_size = appearance.ui_font_size();
let (font_color, ui_font_size) = {
let appearance = Appearance::as_ref(ctx);
(
repo_dropdown_font_color(appearance),
appearance.ui_font_size(),
)
};
let mut dropdown = Dropdown::new(ctx);
dropdown.set_menu_position(
PositionedElementAnchor::BottomRight,
@@ -158,6 +226,19 @@ impl CodeReviewState {
dropdown.set_vertical_margin(0., ctx);
dropdown.set_top_bar_height(galaxy_core::ui::icons::ICON_DIMENSIONS, ctx);
dropdown.set_padding(HEADER_BUTTON_PADDING, ctx);
// The font color above is derived from the active theme and
// cached inside the dropdown. Without this subscription, the
// cached value goes stale across light/dark switches and the
// header label becomes unreadable on the new background
// (e.g. white-on-white in light mode after starting in dark).
ctx.subscribe_to_model(&Appearance::handle(ctx), |dropdown, _, event, ctx| {
if matches!(event, AppearanceEvent::ThemeChanged) {
let font_color = repo_dropdown_font_color(Appearance::as_ref(ctx));
dropdown.set_font_color(font_color, ctx);
}
});
dropdown
}),
available_repos: vec![],
@@ -170,13 +251,17 @@ impl CodeReviewState {
#[cfg(not(feature = "local_fs"))]
fn set_available_repos(
&mut self,
_repos: Vec<PathBuf>,
_repos: Vec<LocalOrRemotePath>,
_ctx: &mut ViewContext<RightPanelView>,
) {
}
#[cfg(feature = "local_fs")]
fn set_available_repos(&mut self, repos: Vec<PathBuf>, ctx: &mut ViewContext<RightPanelView>) {
fn set_available_repos(
&mut self,
repos: Vec<LocalOrRemotePath>,
ctx: &mut ViewContext<RightPanelView>,
) {
let should_clear = self
.selected_repo_path
.as_ref()
@@ -200,19 +285,23 @@ impl CodeReviewState {
#[cfg(not(feature = "local_fs"))]
pub fn set_selected_repo(
&mut self,
_repo_path: PathBuf,
_repo_path: LocalOrRemotePath,
_ctx: &mut ViewContext<RightPanelView>,
) {
}
#[cfg(feature = "local_fs")]
pub fn set_selected_repo(&mut self, repo_path: PathBuf, ctx: &mut ViewContext<RightPanelView>) {
pub fn set_selected_repo(
&mut self,
repo_path: LocalOrRemotePath,
ctx: &mut ViewContext<RightPanelView>,
) {
self.set_selected_repo_internal(repo_path, true, ctx);
}
pub fn set_focused_repo(
&mut self,
repo_path: Option<PathBuf>,
repo_path: Option<LocalOrRemotePath>,
ctx: &mut ViewContext<RightPanelView>,
) {
self.did_focused_repo_change = true;
@@ -226,10 +315,13 @@ impl CodeReviewState {
#[cfg(feature = "local_fs")]
fn set_selected_repo_internal(
&mut self,
repo_path: PathBuf,
repo_path: LocalOrRemotePath,
update_dropdown: bool,
ctx: &mut ViewContext<RightPanelView>,
) {
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
return;
}
if self.selected_repo_path.as_ref() == Some(&repo_path) {
return;
}
@@ -246,11 +338,13 @@ impl CodeReviewState {
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn get_repo_display_name(&self, repo_path: &Path) -> Option<String> {
repo_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
fn get_repo_display_name(
&self,
repo_path: &LocalOrRemotePath,
ctx: &AppContext,
) -> Option<String> {
let name = display_name_with_host(repo_path, ctx);
(!name.is_empty()).then_some(name)
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
@@ -262,7 +356,7 @@ impl CodeReviewState {
.iter()
.map(|repo_path| {
let display_name = self
.get_repo_display_name(repo_path)
.get_repo_display_name(repo_path, ctx)
.unwrap_or_else(|| "Unknown".to_string());
DropdownItem::new(
display_name,
@@ -277,7 +371,7 @@ impl CodeReviewState {
let selected_display_name = self
.selected_repo_path
.as_ref()
.and_then(|selected| self.get_repo_display_name(selected));
.and_then(|selected| self.get_repo_display_name(selected, ctx));
(items, selected_display_name)
};
@@ -294,12 +388,12 @@ impl CodeReviewState {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub enum RightPanelAction {
ToggleFileSidebar,
SelectRepo {
repo_path: PathBuf,
repo_path: LocalOrRemotePath,
from_dropdown: bool,
},
OpenRepository,
@@ -317,7 +411,7 @@ pub enum RightPanelEvent {
line_col: Option<LineAndColumnArg>,
},
OpenFileInNewTab {
path: PathBuf,
path: LocalOrRemotePath,
line_and_column: Option<LineAndColumnArg>,
},
#[cfg(not(target_family = "wasm"))]
@@ -464,14 +558,18 @@ impl RightPanelView {
ctx.notify();
}
pub fn selected_repo_path(&self) -> Option<&PathBuf> {
pub fn selected_repo_path(&self) -> Option<&LocalOrRemotePath> {
self.code_review_state
.as_ref()
.and_then(|s| s.selected_repo_path.as_ref())
}
#[cfg(feature = "local_fs")]
pub fn update_selected_repo(&mut self, repo_path: PathBuf, ctx: &mut ViewContext<Self>) {
pub fn update_selected_repo(
&mut self,
repo_path: LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
self.handle_action(
&RightPanelAction::SelectRepo {
repo_path,
@@ -503,7 +601,7 @@ impl RightPanelView {
.and_then(|s| s.selected_repo_path.clone());
if let Some(state) = self.code_review_state.as_mut() {
state.set_available_repos(repositories.to_owned(), ctx);
state.set_available_repos(repositories.clone(), ctx);
}
let new_selected = self
@@ -570,12 +668,22 @@ impl RightPanelView {
self.active_pane_group = Some(pane_group);
if let Some(state) = &mut self.code_review_state {
let active_repositories = working_directories_model.read(ctx, |model, _| {
model
.most_recent_repositories_for_pane_group(pane_group_id)
.map(|repos| repos.collect())
.unwrap_or_default()
});
let (active_repositories, saved_selection) =
working_directories_model.read(ctx, |model, _| {
let repos: Vec<LocalOrRemotePath> = model
.most_recent_repositories_for_pane_group(pane_group_id)
.map(|repos| repos.collect())
.unwrap_or_default();
let saved = model.get_selected_review_repo(pane_group_id).cloned();
(repos, saved)
});
// Replace the carried-over selection from a different pane group
// with whatever was saved for this pane group (if anything). This
// ensures `set_available_repos` either keeps the saved selection
// (when it's still in the repo list) or falls back to auto-selecting
// the first repo, instead of preserving the previous tab's repo.
state.selected_repo_path = saved_selection;
state.set_available_repos(active_repositories, ctx);
}
@@ -598,9 +706,8 @@ impl RightPanelView {
/// Will only update repo_path if one is not already set
pub fn open_code_review(
&mut self,
repo_path: Option<PathBuf>,
repo_path: Option<LocalOrRemotePath>,
diff_state_model: ModelHandle<DiffStateModel>,
terminal_view: WeakViewHandle<TerminalView>,
ctx: &mut ViewContext<Self>,
) {
let Some(repo_dropdown_state) = &mut self.code_review_state else {
@@ -610,6 +717,9 @@ impl RightPanelView {
else {
return;
};
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
return;
}
let pane_group_id = active_pane_group.id();
if repo_dropdown_state.selected_repo_path.is_none() {
@@ -622,19 +732,14 @@ impl RightPanelView {
.get_code_review_view(pane_group_id, repo_path);
if let Some(view) = existing_view {
view.update(ctx, |view, ctx| {
view.set_terminal_view(terminal_view);
view.on_open(Some(repo_path.clone()), ctx);
view.on_open(ctx);
});
self.recompute_terminal_availability(ctx);
} else if let Some(view) = self.create_code_review_view(
repo_path,
diff_state_model.clone(),
pane_group_id,
terminal_view.clone(),
ctx,
) {
} else if let Some(view) =
self.create_code_review_view(repo_path, diff_state_model.clone(), pane_group_id, ctx)
{
view.update(ctx, |view, ctx| {
view.on_open(Some(repo_path.clone()), ctx);
view.on_open(ctx);
});
self.recompute_terminal_availability(ctx);
};
@@ -646,7 +751,7 @@ impl RightPanelView {
fn close_code_review_view(
&self,
pane_group_id: EntityId,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
if let Some(code_review_view) = self
@@ -771,27 +876,34 @@ impl RightPanelView {
.finish();
};
let selected_repo_path = state
.selected_repo_path
.as_ref()
.filter(|repo_path| state.available_repos.contains(repo_path));
let selected_repo_path = state.selected_repo_path.as_ref().filter(|repo_path| {
if repo_path.is_remote() {
FeatureFlag::RemoteCodeReview.is_enabled()
} else {
state.available_repos.contains(repo_path)
}
});
let Some(selected_repo_path) = selected_repo_path else {
let simple_header = self.render_simple_header(close_button);
#[cfg(feature = "local_fs")]
let no_repo_body = {
let button = Some(ChildView::new(&self.open_repository_button).finish());
let open_repo_button =
|| Some(ChildView::new(&self.open_repository_button).finish());
if let Some(env) = &self.code_review_session_env {
if env.is_remote {
CodeReviewView::render_remote_state(appearance, button)
// No "Open repository" CTA when the session is remote — the
// button navigates to a local folder, which is not meaningful
// in a remote session.
CodeReviewView::render_remote_state(appearance, None)
} else if env.is_wsl {
CodeReviewView::render_wsl_state(appearance, button)
CodeReviewView::render_wsl_state(appearance, open_repo_button())
} else {
CodeReviewView::render_not_repo_state(appearance, button)
CodeReviewView::render_not_repo_state(appearance, open_repo_button())
}
} else {
CodeReviewView::render_not_repo_state(appearance, button)
CodeReviewView::render_not_repo_state(appearance, open_repo_button())
}
};
@@ -856,14 +968,11 @@ impl RightPanelView {
let repo_path = crv.repo_path();
let branch_name = crv
.diff_state_model()
.read(app, |model, _| model.get_current_branch_name());
.read(app, |model, ctx| model.get_current_branch_name(ctx));
let diff_stats = crv.loaded_diff_stats();
let repo_path_element = repo_path.map(|repo_path| {
let display_path = dirs::home_dir()
.and_then(|home| repo_path.strip_prefix(&home).ok())
.map(|relative| format!("~/{}", relative.display()))
.unwrap_or_else(|| repo_path.display().to_string());
let display_path = display_path_with_host(repo_path, true, app);
Container::new(
Text::new_inline(
format!("{display_path}:"),
@@ -1097,18 +1206,22 @@ impl RightPanelView {
fn create_code_review_view(
&self,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
diff_state_model: ModelHandle<DiffStateModel>,
pane_group_id: EntityId,
terminal_view: WeakViewHandle<TerminalView>,
ctx: &mut ViewContext<Self>,
) -> Option<ViewHandle<CodeReviewView>> {
// Early check: if pane group has no active repositories, don't create a view
let has_active_repos = self
.working_directories_model
.as_ref(ctx)
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|repos| repos.count() > 0);
// Early check: if pane group has no active repositories, don't create a view.
// Remote repos require the RemoteCodeReview feature flag; local repos go
// through the active-repos check.
let has_active_repos = if repo_path.is_remote() {
FeatureFlag::RemoteCodeReview.is_enabled()
} else {
self.working_directories_model
.as_ref(ctx)
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|mut repos| repos.any(|r| &r == repo_path))
};
if !has_active_repos {
return None;
@@ -1120,12 +1233,16 @@ impl RightPanelView {
.update(ctx, |working_directories, ctx| {
working_directories.get_or_create_code_review_comments(repo_path, ctx)
});
let action_target_provider: Box<dyn ReviewActionTargetProvider> =
Box::new(RightPanelReviewActionTargetProvider {
right_panel: ctx.handle(),
});
let code_review_view = ctx.add_typed_action_view(|ctx| {
CodeReviewView::new(
Some(repo_path.to_path_buf()),
Some(repo_path.clone()),
diff_state_model_clone,
code_review_comment_batch,
Some(terminal_view),
Some(action_target_provider),
ctx,
)
});
@@ -1134,7 +1251,7 @@ impl RightPanelView {
self.working_directories_model.update(ctx, |model, _ctx| {
model.store_code_review_view(
pane_group_id,
repo_path.to_path_buf(),
repo_path.clone(),
code_review_view.clone(),
);
});
@@ -1198,7 +1315,7 @@ impl RightPanelView {
&mut self,
code_review_view: &ViewHandle<CodeReviewView>,
comments: AgentReviewCommentBatch,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
let Some(pane_group) = &self.active_pane_group else {
@@ -1226,7 +1343,7 @@ impl RightPanelView {
.filter_map(|c| {
c.target
.absolute_file_path()
.map(|p| p.to_string_lossy().to_string())
.map(LocalOrRemotePath::display_path)
})
.collect::<std::collections::HashSet<_>>()
.len();
@@ -1274,9 +1391,14 @@ impl RightPanelView {
.unwrap_or_else(|| "<none>".to_string())
}
fn format_optional_location(path: Option<&LocalOrRemotePath>) -> String {
path.map(LocalOrRemotePath::display_path)
.unwrap_or_else(|| "<none>".to_string())
}
fn review_terminal_status(
tv: &ViewHandle<TerminalView>,
repo_path: Option<&Path>,
repo_path: Option<&LocalOrRemotePath>,
ai_enabled: bool,
ctx: &AppContext,
) -> ReviewTerminalStatus {
@@ -1290,16 +1412,25 @@ impl RightPanelView {
let mut unavailable_reasons = Vec::new();
match repo_path {
Some(repo_path) => match active_session_path.as_ref() {
// Canonicalize the CWD, note that repo_path has already been canonicalized.
Some(cwd)
if canonicalize(cwd)
.as_deref()
.unwrap_or(cwd)
.starts_with(repo_path) => {}
Some(_) => unavailable_reasons
Some(repo_path) => match (repo_path, t.current_repo_path()) {
(LocalOrRemotePath::Local(repo_path), _) => {
match active_session_path.as_ref() {
Some(cwd)
if canonicalize(cwd)
.as_deref()
.unwrap_or(cwd)
.starts_with(repo_path) => {}
Some(_) => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo),
None => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionPathUnavailable),
}
}
(repo_path @ LocalOrRemotePath::Remote(_), Some(current_repo_path))
if repo_path.strip_repo_prefix(current_repo_path).is_some() => {}
(LocalOrRemotePath::Remote(_), Some(_)) => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo),
None => unavailable_reasons
(LocalOrRemotePath::Remote(_), None) => unavailable_reasons
.push(ReviewTerminalUnavailableReason::SessionPathUnavailable),
},
None => unavailable_reasons.push(ReviewTerminalUnavailableReason::NoSelectedRepo),
@@ -1331,7 +1462,7 @@ impl RightPanelView {
fn log_code_review_debug_state(debug_state: &CodeReviewCommentDebugState) {
log::info!(
"Active code review view: repo_path={}, has_active_comment_model={}, review_destination={:?}, total_comments={}, sendable_comments={}, is_collapsed={}, is_outdated_section_collapsed={:?}, ai_available={}, ai_enabled={}, send_button_tooltip={}",
Self::format_optional_path(debug_state.repo_path.as_deref()),
Self::format_optional_location(debug_state.repo_path.as_ref()),
debug_state.has_active_comment_model,
debug_state.comment_list.review_destination,
debug_state.comment_list.total_comments,
@@ -1356,7 +1487,7 @@ impl RightPanelView {
let Some(pane_group) = &self.active_pane_group else {
log::info!(
"Review comment send status for active tab: no active pane group, selected_repo_path={}, ai_enabled={}",
Self::format_optional_path(selected_repo_path.as_deref()),
Self::format_optional_location(selected_repo_path.as_ref()),
ai_enabled,
);
if let Some(debug_state) = &code_review_debug_state {
@@ -1381,7 +1512,7 @@ impl RightPanelView {
log::info!(
"Review comment send status for active tab: pane_group_id={pane_group_id}, selected_repo_path={}, ai_enabled={}, focused_pane_id={focused_pane_id}, preferred_terminal_id={preferred_terminal_id:?}, chosen_terminal_id={chosen_terminal_id:?}, visible_pane_count={}",
Self::format_optional_path(selected_repo_path.as_deref()),
Self::format_optional_location(selected_repo_path.as_ref()),
ai_enabled,
visible_pane_ids.len(),
);
@@ -1418,7 +1549,7 @@ impl RightPanelView {
let terminal_id = terminal_view.id();
let terminal_status = Self::review_terminal_status(
&terminal_view,
selected_repo_path.as_deref(),
selected_repo_path.as_ref(),
ai_enabled,
ctx,
);
@@ -1439,7 +1570,7 @@ impl RightPanelView {
chosen_terminal_id == Some(terminal_id),
terminal_status.is_available(),
Self::format_optional_path(terminal_status.active_session_path.as_deref()),
Self::format_optional_path(terminal_status.current_repo_path.as_deref()),
Self::format_optional_location(terminal_status.current_repo_path.as_ref()),
terminal_status
.active_cli_agent
.as_deref()
@@ -1460,7 +1591,7 @@ impl RightPanelView {
/// considered available (non-CLI Warp terminals require AI to be on).
fn is_terminal_available_for_review(
tv: &ViewHandle<TerminalView>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ai_enabled: bool,
ctx: &AppContext,
) -> bool {
@@ -1474,7 +1605,7 @@ impl RightPanelView {
terminal_views: &[ViewHandle<TerminalView>],
focused_terminal: Option<&ViewHandle<TerminalView>>,
preferred_terminal_id: Option<EntityId>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ai_enabled: bool,
ctx: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
@@ -1508,11 +1639,11 @@ impl RightPanelView {
fn find_review_terminal(
&self,
pane_group: &ViewHandle<PaneGroup>,
repo_path: &Path,
repo_path: &LocalOrRemotePath,
ai_enabled: bool,
ctx: &AppContext,
) -> Option<ViewHandle<TerminalView>> {
let terminal_views = pane_group.read(ctx, |pg, ctx| pg.terminal_views(ctx));
let terminal_views = pane_group.read(ctx, |pg, ctx| pg.visible_terminal_views(ctx));
let focused_terminal = pane_group.read(ctx, |pg, ctx| pg.focused_session_view(ctx));
let pane_group_id = pane_group.id();
let preferred_terminal_id = self
@@ -1570,7 +1701,14 @@ impl RightPanelView {
});
}
fn ensure_code_review_view_exists(&mut self, repo_path: &Path, ctx: &mut ViewContext<Self>) {
fn ensure_code_review_view_exists(
&mut self,
repo_path: &LocalOrRemotePath,
ctx: &mut ViewContext<Self>,
) {
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
return;
}
let Some(pane_group) = &self.active_pane_group else {
return;
};
@@ -1588,46 +1726,59 @@ impl RightPanelView {
if is_panel_open {
// on_open is idempotent (guards on is_open), so this is safe for
// already-open views and correctly re-opens cached-but-closed ones.
let repo_path = repo_path.to_path_buf();
view.update(ctx, |view, ctx| {
view.on_open(Some(repo_path), ctx);
view.on_open(ctx);
});
}
} else {
// Prefer the pane group's active session so the diff request rides
// the connection actually showing the review; the manager falls
// back to any connected session for the host when unavailable.
let preferred_session = pane_group
.read(ctx, |pg, ctx| pg.active_session_view(ctx))
.and_then(|tv| tv.as_ref(ctx).active_block_session_id());
let diff_state_model = self.working_directories_model.update(ctx, |model, ctx| {
model.get_or_create_diff_state_model(repo_path.to_path_buf(), ctx)
model.get_or_create_diff_state_model(repo_path.clone(), preferred_session, ctx)
});
let Some(diff_state_model) = diff_state_model else {
return;
};
let working_directories_model = self.working_directories_model.as_ref(ctx);
let Some(terminal_view_id) =
working_directories_model.get_terminal_id_for_root_path(pane_group_id, repo_path)
else {
return;
let is_known_repo = self
.working_directories_model
.as_ref(ctx)
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|mut repos| repos.any(|r| &r == repo_path));
// Only create a view when a terminal exists for the repo. The view
// resolves its target terminal lazily via `ReviewActionTargetProvider`,
// so this is purely a creation gate.
let has_review_terminal = if is_known_repo {
let Some(terminal_view_id) = self
.working_directories_model
.as_ref(ctx)
.get_terminal_id_for_root_path(pane_group_id, repo_path)
else {
return;
};
ctx.view_with_id::<TerminalView>(ctx.window_id(), terminal_view_id)
.is_some()
} else {
// For repos not yet tracked (e.g. remote repos from direct open),
// fall back to the active session.
pane_group
.read(ctx, |pane_group, ctx| pane_group.active_session_view(ctx))
.is_some()
};
if working_directories_model
.most_recent_repositories_for_pane_group(pane_group_id)
.is_some_and(|mut repos| repos.contains(repo_path))
{
if let Some(terminal_view) =
ctx.view_with_id::<TerminalView>(ctx.window_id(), terminal_view_id)
if has_review_terminal {
if let Some(view) =
self.create_code_review_view(repo_path, diff_state_model, pane_group_id, ctx)
{
if let Some(view) = self.create_code_review_view(
repo_path,
diff_state_model,
pane_group_id,
terminal_view.downgrade(),
ctx,
) {
if is_panel_open {
let repo_path = repo_path.to_path_buf();
view.update(ctx, |view, ctx| {
view.on_open(Some(repo_path), ctx);
});
}
if is_panel_open {
view.update(ctx, |view, ctx| {
view.on_open(ctx);
});
}
}
}
@@ -1685,6 +1836,20 @@ impl TypedActionView for RightPanelView {
ctx,
);
self.ensure_code_review_view_exists(repo_path, ctx);
// Persist the user's manual selection so it can be restored when
// they leave this pane group's session and come back. We only
// persist explicit `SelectRepo` actions (i.e. dropdown picks or
// contextual opens) so that the auto-selected default doesn't
// overwrite an earlier manual choice for a different pane group.
if let Some(pane_group) = &self.active_pane_group {
let pane_group_id = pane_group.id();
let repo_path = repo_path.clone();
self.working_directories_model.update(ctx, |model, _| {
model.set_selected_review_repo(pane_group_id, repo_path);
});
}
ctx.notify();
}
}
+4 -3
View File
@@ -1,14 +1,15 @@
//! Logic to determine the working directory for new terminal sessions.
use std::path::PathBuf;
use warpui::{AppContext, SingletonEntity, ViewContext, WindowId};
use super::Workspace;
use crate::terminal::available_shells::AvailableShell;
#[cfg(feature = "local_tty")]
use crate::terminal::available_shells::AvailableShells;
use crate::terminal::session_settings::{NewSessionSource, SessionSettings};
use crate::terminal::ShellLaunchData;
use galaxyui::SingletonEntity;
use galaxyui::{AppContext, ViewContext, WindowId};
use std::path::PathBuf;
impl Workspace {
/// Helper function to compute the initial directory for a new session
+717
View File
@@ -0,0 +1,717 @@
use std::collections::HashSet;
use itertools::{Either, Itertools};
use galaxy_core::features::FeatureFlag;
use warpui::{EntityId, UpdateView, ViewContext};
use super::{group_member_indices, Workspace};
use crate::menu::{MenuItem, MenuItemFields};
use crate::tab::{TabData, MOVE_TO_GROUP_LABEL};
use crate::workspace::action::{TabContextMenuAnchor, WorkspaceAction};
use crate::workspace::tab_group::{TabGroup, TabGroupId};
use crate::workspace::util::PaneViewLocator;
// TODO(johnturcoo) move tab grouping helpers here from workspace/view.rs.
impl Workspace {
/// Clears the multi-selection on every tab.
pub(super) fn clear_tab_multi_selection(&mut self, ctx: &mut ViewContext<Self>) {
for tab in &mut self.tabs {
tab.in_multi_selection = false;
}
ctx.notify();
}
/// Adds the inclusive range between `anchor_index` and `clicked_index` to
/// the multi-selection, expanding any collapsed groups the range crosses.
/// Existing multi-selection outside the range is preserved (additive
/// semantics), so cmd-click selections survive a subsequent shift-click.
fn set_tab_range_selection(
&mut self,
anchor_index: usize,
clicked_index: usize,
ctx: &mut ViewContext<Self>,
) {
// Determine the bounds for our range selection.
let lo_index = anchor_index.min(clicked_index);
let hi_index = anchor_index.max(clicked_index);
// Identify groups in the selection range.
let crossed_group_ids: HashSet<TabGroupId> = self
.tabs
.get(lo_index..=hi_index)
.into_iter()
.flatten()
.filter_map(|tab| tab.group_id)
.collect();
// Expand any groups within the selected range, so user can see what they are selecting.
for group_id in &crossed_group_ids {
self.expand_tab_group(*group_id, ctx);
}
// Add tabs in the selected range to the multi-selection.
self.tabs
.iter_mut()
.enumerate()
.filter(|(index, _)| (lo_index..=hi_index).contains(index))
.for_each(|(_, tab)| tab.in_multi_selection = true);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Shift-click on a vertical tab row: selects every tab between the
/// active tab and `locator` (inclusive).
pub(super) fn shift_select_tab_range(
&mut self,
locator: PaneViewLocator,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
// Identify index of the tab that was shift-clicked.
if let Some(clicked_index) = self
.tabs
.iter()
.position(|tab| tab.pane_group.id() == locator.pane_group_id)
{
self.set_tab_range_selection(self.active_tab_index, clicked_index, ctx);
}
}
/// Cmd-click on a tab: toggles the multi-selection flag
/// for a single tab.
pub(super) fn toggle_tab_multi_selection(
&mut self,
locator: PaneViewLocator,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
if let Some(tab) = self
.tabs
.iter_mut()
.find(|tab| tab.pane_group.id() == locator.pane_group_id)
{
// Toggle multi selection flag for this tab.
tab.in_multi_selection = !tab.in_multi_selection;
ctx.notify();
}
}
/// Returns all tabs that are part of the multi tab selection.
/// The active tab index is always included if any other tab is marked
/// as selected. This is to handle the edge case where we only mark other
/// tabs as selected via command click.
pub(super) fn selected_tab_indices(&self) -> Vec<usize> {
let any_flagged = self.tabs.iter().any(|tab| tab.in_multi_selection);
// If no tab is part of the multi selection, return empty list.
if !any_flagged {
return Vec::new();
}
// Otherwise, the active tab must always be part of the multi tab selection.
self.tabs
.iter()
.enumerate()
.filter(|(index, tab)| tab.in_multi_selection || *index == self.active_tab_index)
.map(|(index, _)| index)
.collect()
}
/// Drives right-click menu dispatch: when a selected tab is right-clicked
/// and the selection covers multiple tabs, show the multi-tab menu;
/// otherwise fall through to the normal single-pane menu.
pub(super) fn is_tab_in_multi_tab_selection(&self, tab_index: usize) -> bool {
if !FeatureFlag::GroupedTabs.is_enabled() {
return false;
}
let indices = self.selected_tab_indices();
indices.len() > 1 && indices.contains(&tab_index)
}
/// Gates the "Remove from group" menu item. All selected tabs
/// must be in the same group in order to display this option.
pub(super) fn selection_shared_group(&self) -> Option<TabGroupId> {
let indices = self.selected_tab_indices();
let mut group_ids = indices
.iter()
.filter_map(|index| self.tabs.get(*index))
.map(|tab| tab.group_id);
let first = group_ids.next()??;
group_ids.all(|gid| gid == Some(first)).then_some(first)
}
/// Re-seats `active_tab_index` so the previously-active pane group stays
/// visually active across a tab reorder. Pass the pane group id captured
/// before the reorder; no-op if it can't be found.
pub(super) fn restore_active_tab_index(&mut self, pane_group_id: Option<EntityId>) {
if let Some(active_id) = pane_group_id {
if let Some(new_index) = self
.tabs
.iter()
.position(|tab| tab.pane_group.id() == active_id)
{
self.active_tab_index = new_index;
}
}
}
/// Context-aware "create group" entry point used by the
/// `workspace:new_tab_group_from_active_or_selected_tabs` keybinding. When
/// the multi-selection covers 2+ tabs, groups the selection; otherwise
/// groups just the active tab. `selected_tab_indices` already folds the
/// active tab into the selection, so a lone flagged active tab (or no
/// selection at all) takes the single-tab path.
pub(super) fn new_tab_group_from_active_or_selected_tabs(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
if self.selected_tab_indices().len() >= 2 {
self.new_tab_group_from_selected_tabs(ctx);
} else {
self.new_tab_group_from_tab(self.active_tab_index, ctx);
}
}
/// Context-aware "remove from group" entry point used by the
/// `workspace:remove_active_or_selected_tabs_from_group` keybinding. With a
/// 2+ multi-selection, removes the whole selection from its group;
/// otherwise removes just the active tab.
pub(super) fn remove_active_or_selected_tabs_from_group(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
if self.selected_tab_indices().len() >= 2 {
self.remove_selected_tabs_from_group(ctx);
} else {
self.remove_tab_from_group(self.active_tab_index, ctx);
}
}
/// "Create group from tabs" menu action. Group membership requires
/// tabs to be contiguous in the bar, so we gather the selected tabs into
/// a single block anchored at the earliest selected tab's position before
/// binding them to the new group. When that earliest tab was itself in a
/// group, the block is placed just past that group's last remaining
/// member so the existing group stays contiguous instead of being split.
pub(super) fn new_tab_group_from_selected_tabs(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
let selected_indices = self.selected_tab_indices();
// Should be unreachable: the multi-tab menu only opens when 2+ tabs
// are selected.
if selected_indices.len() < 2 {
log::warn!(
"new_tab_group_from_selected_tabs called with {} selected tab(s); expected at least 2",
selected_indices.len()
);
return;
}
// Remember the groups the selected tabs are leaving so we can prune
// any that become empty after the move.
let previous_group_ids: HashSet<TabGroupId> = selected_indices
.iter()
.filter_map(|index| self.tabs[*index].group_id)
.collect();
let group = TabGroup::new();
let group_id = group.id;
self.tab_groups.insert(group_id, group);
// Store the active tab (pane group).
let active_pane_group_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
// Anchor the group block at the earliest selected tab. `selected_indices`
// is ascending, so its first entry is the earliest tab in the list, and
// we remember the group it currently belongs to (if any).
let anchor_index = selected_indices[0];
let anchor_previous_group_id = self.tabs[anchor_index].group_id;
// Assign membership and clear flags for every selected tab. The new
// group is unpinned, so any selected tab in set as unpinned.
for &index in &selected_indices {
let tab = &mut self.tabs[index];
tab.group_id = Some(group_id);
tab.pinned = false;
tab.in_multi_selection = false;
}
// Split tabs into the new group's members and all other tabs.
let (selected_tabs, mut other_tabs): (Vec<_>, Vec<_>) = self
.tabs
.drain(..)
.partition(|tab| tab.group_id == Some(group_id));
// Compute where to splice the new group block into `other_tabs`.
//
// Simple case — anchor tab was NOT in a group:
// Every tab before `anchor_index` in the original list was unselected
// (because `anchor_index` is the smallest selected index), so those
// tabs are still at the front of `other_tabs` in their original order.
// Inserting at `anchor_index` in `other_tabs` places the block exactly
// where the anchor tab used to be.
//
// Edge case — anchor tab WAS in an existing group G:
// Other surviving members of G are still in `other_tabs`. Inserting at
// `anchor_index` could land in the middle of G's run and split it.
// Example: tabs = [A(G), B(G), C(G), D] and we select B and D.
// other_tabs = [A(G), C(G), D]. anchor_index = 1, which points at C —
// inserting there would produce [A(G), B(new), D(new), C(G)], breaking
// G's contiguity. Instead we search other_tabs from the right for the
// last surviving G member (C, at index 1) and insert after it (index 2),
// giving [A(G), C(G), B(new), D(new)].
let insert_at = anchor_previous_group_id
.and_then(|prev_group_id| {
other_tabs
.iter()
.rposition(|tab| tab.group_id == Some(prev_group_id))
.map(|last| last + 1)
})
.unwrap_or(anchor_index);
// Our insertion index for this group should be below any pinned items.
let insert_at = self.clamp_to_unpinned_region(&other_tabs, insert_at);
other_tabs.splice(insert_at..insert_at, selected_tabs);
self.tabs = other_tabs;
self.restore_active_tab_index(active_pane_group_id);
// Prune any groups that are now empty.
for previous_group_id in previous_group_ids {
self.prune_empty_tab_group(previous_group_id, ctx);
}
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
ctx.dispatch_typed_action_deferred(WorkspaceAction::RenameTabGroup(group_id));
}
/// "Move to group" menu action. The destination group's first-member
/// position is preserved so the group doesn't visually jump while the
/// selected tabs are folded in.
pub(super) fn move_selected_tabs_to_group(
&mut self,
group_id: TabGroupId,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::GroupedTabs.is_enabled() || !self.tab_groups.contains_key(&group_id) {
return;
}
let selected_indices = self.selected_tab_indices();
// Should be unreachable: the multi-tab menu only opens when 2+ tabs
// are selected.
if selected_indices.len() < 2 {
log::warn!(
"move_selected_tabs_to_group called with {} selected tab(s); expected at least 2",
selected_indices.len()
);
return;
}
// Store all groups that tabs previously belonged to, excluding the
// group that we are moving tabs to. In order to prune these groups later.
let previous_group_ids: HashSet<TabGroupId> = selected_indices
.iter()
.filter_map(|index| self.tabs[*index].group_id)
.filter(|gid| *gid != group_id)
.collect();
// Anchor the block at the existing first member so the group doesn't jump.
let first_existing_member = self
.tabs
.iter()
.position(|tab| tab.group_id == Some(group_id));
// Store the active tab (pane group).
let active_pane_group_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
// Assign membership and clear flags for every selected tab. Entering
// the group removes any per-tab pinned flag — the destination group's
// own `pinned` flag now governs the member's position.
for &index in &selected_indices {
let tab = &mut self.tabs[index];
tab.group_id = Some(group_id);
tab.pinned = false;
tab.in_multi_selection = false;
}
// Anchor the group block at its original first-member position, shifted
// left by the count of newly-added members from before that position.
let insert_at = first_existing_member.map_or(0, |first| {
first - selected_indices.iter().filter(|&&i| i < first).count()
});
// Split tabs into the destination group's members (existing + newly
// added) and the rest.
let (members, mut rest): (Vec<_>, Vec<_>) = self
.tabs
.drain(..)
.partition(|tab| tab.group_id == Some(group_id));
// Drop the group block into rest at the anchored position.
rest.splice(insert_at..insert_at, members);
self.tabs = rest;
self.restore_active_tab_index(active_pane_group_id);
self.expand_tab_group(group_id, ctx);
// Prune any groups that are now empty.
for previous_group_id in previous_group_ids {
self.prune_empty_tab_group(previous_group_id, ctx);
}
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// "Remove from group" menu action. Removed tabs land just below the
/// group's remaining members so the user can still see where they came
/// from; if the group ends up empty it's pruned and the removed block
/// anchors at the original position instead.
pub(super) fn remove_selected_tabs_from_group(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::GroupedTabs.is_enabled() {
return;
}
let Some(group_id) = self.selection_shared_group() else {
// Only a single-group selection has an unambiguous group to leave.
self.clear_tab_multi_selection(ctx);
return;
};
// Capture the group's first index before clearing membership so we can
// anchor the removed block if the group ends up empty.
let group_first_index = self
.tabs
.iter()
.position(|tab| tab.group_id == Some(group_id))
.unwrap_or(0);
// Store the active tab (pane group).
let active_pane_group_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
let selected_indices = self.selected_tab_indices();
let selected_set: HashSet<usize> = selected_indices.iter().copied().collect();
// Clear the group that all selected tabs belonged to.
for &index in &selected_indices {
self.tabs[index].group_id = None;
}
// Non-selected tabs originally before the group's first member; if the
// group ends up empty we fall back to inserting at this position.
let kept_before_group = group_first_index
- selected_indices
.iter()
.filter(|&&i| i < group_first_index)
.count();
// Split tabs by index into the removed (selected) block and the rest.
let (removed, mut rest): (Vec<_>, Vec<_>) =
self.tabs
.drain(..)
.enumerate()
.partition_map(|(index, tab)| {
if selected_set.contains(&index) {
Either::Left(tab)
} else {
Either::Right(tab)
}
});
// Anchor the removed block just after the group's remaining members;
// if none remain, fall back to the pre-computed prefix position.
let natural_insert_at = match rest.iter().rposition(|tab| tab.group_id == Some(group_id)) {
Some(last) => last + 1,
None => kept_before_group,
};
// The removed tabs are now unpinned (they left a possibly-pinned
// group); they must land past every effectively pinned tab in
// not just past the source group's remaining members.
let insert_at = self.clamp_to_unpinned_region(&rest, natural_insert_at);
rest.splice(insert_at..insert_at, removed);
self.tabs = rest;
self.clear_tab_multi_selection(ctx);
self.restore_active_tab_index(active_pane_group_id);
self.prune_empty_tab_group(group_id, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Items shown in the multi-tab right-click menu. Composition depends on
/// the selection: "Create group from tabs" is always there; "Remove from
/// group" only when the selection has an unambiguous group; "Move to
/// group" only when there's a destination group worth offering.
fn tab_selection_menu_items(&self) -> Vec<MenuItem<WorkspaceAction>> {
let shared_group = self.selection_shared_group();
let mut menu_items = vec![MenuItemFields::new("Create group from tabs")
.with_on_select_action(WorkspaceAction::NewTabGroupFromSelectedTabs)
.into_item()];
// Only single-group selections have an unambiguous group to leave.
if shared_group.is_some() {
menu_items.push(
MenuItemFields::new("Remove from group")
.with_on_select_action(WorkspaceAction::RemoveSelectedTabsFromGroup)
.into_item(),
);
}
// Offer "Move to group" only when another group is available.
let has_destination_group = self
.tab_groups
.keys()
.any(|group_id| Some(*group_id) != shared_group);
if has_destination_group {
menu_items.push(MenuItemFields::new_submenu(MOVE_TO_GROUP_LABEL).into_item());
}
menu_items
}
/// Opens (or closes) the multi-tab right-click menu. Reuses the shared
/// `tab_right_click_menu` view — the menu rendering pipeline doesn't need
/// to know which item set is loaded, only which `show_*` flag is set.
pub fn toggle_tab_selection_right_click_menu(
&mut self,
tab_index: usize,
anchor: TabContextMenuAnchor,
ctx: &mut ViewContext<Self>,
) {
if self.show_tab_selection_right_click_menu.is_some() {
self.show_tab_selection_right_click_menu = None;
self.hide_move_to_group_sidecar(ctx);
ctx.notify();
return;
}
let menu_items = self.tab_selection_menu_items();
ctx.update_view(&self.tab_right_click_menu, |context_menu, view_ctx| {
context_menu.set_items(menu_items, view_ctx);
});
self.show_tab_right_click_menu = None;
self.show_tab_group_right_click_menu = None;
self.hide_move_to_group_sidecar(ctx);
self.show_tab_selection_right_click_menu = Some((tab_index, anchor));
ctx.focus(&self.tab_right_click_menu);
ctx.notify();
}
/// True when `tab` is positioned in the pinned region of the tab list —
/// either because its own `pinned` flag is set (ungrouped pinned tab) or
/// because it belongs to a pinned group.
pub(super) fn is_tab_effectively_pinned(&self, tab: &TabData) -> bool {
// Safety net, ensures no behavioral changes if feature flag
// is off and some tabs have a pinned state saved.
if !FeatureFlag::PinnedTabs.is_enabled() {
return false;
}
tab.pinned
|| tab
.group_id
.is_some_and(|gid| self.tab_groups.get(&gid).is_some_and(|g| g.pinned))
}
/// Index where the unpinned region begins within `tabs`: the count of
/// leading tabs that belong to the pinned region.
pub(super) fn pinned_boundary_index(&self, tabs: &[TabData]) -> usize {
tabs.iter()
.take_while(|tab| self.is_tab_effectively_pinned(tab))
.count()
}
/// Pushes `idx` past the leading effectively-pinned tabs in `tabs` if it
/// falls inside that prefix.
pub(super) fn clamp_to_unpinned_region(&self, tabs: &[TabData], idx: usize) -> usize {
idx.max(self.pinned_boundary_index(tabs))
}
/// Returns the slot just past the last member of `group_id`, suitable as
/// an insert/move target that keeps the group contiguous. `None` when the
/// group has no members.
pub(super) fn index_after_group(&self, group_id: TabGroupId) -> Option<usize> {
group_member_indices(&self.tabs, group_id)
.last()
.map(|last| last + 1)
}
/// Pins the tab. Grouped tabs are extracted from their group first
/// regardless of whether that group itself is pinned — tab pinning and
/// group pinning are independent concepts.
pub(super) fn pin_tab(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(tab) = self.tabs.get(tab_index) else {
log::debug!("pin_tab: tab_index {tab_index} out of bounds");
return;
};
if tab.pinned {
log::debug!("pin_tab: tab {tab_index} is already pinned");
return;
}
let previous_group_id = tab.group_id;
// Identify where this newly pinned tab should land (after the last pinned item).
let target = self.pinned_boundary_index(&self.tabs);
self.tabs[tab_index].group_id = None;
self.tabs[tab_index].pinned = true;
self.move_tab_to_index(tab_index, target, ctx);
if let Some(prev) = previous_group_id {
self.prune_empty_tab_group(prev, ctx);
}
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Unpins a pinned tab and moves it to the start of the unpinned region.
pub(super) fn unpin_tab(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(tab) = self.tabs.get(tab_index) else {
log::debug!("unpin_tab: tab_index {tab_index} out of bounds");
return;
};
if !tab.pinned {
log::debug!("unpin_tab: tab {tab_index} is not pinned");
return;
}
// This tab should land right after all pinned items.
let target = self.pinned_boundary_index(&self.tabs);
self.tabs[tab_index].pinned = false;
self.move_tab_to_index(tab_index, target, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Pins the entire tab group: flips the group's `pinned` flag and moves
/// its contiguous block of members to the end of the pinned region. We
/// don't touch individual member `tab.pinned` flags because the block
/// always travels as a unit, and we want to support pinning a tab even if
/// it already belongs to a (pinned) group.
pub(super) fn pin_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(group) = self.tab_groups.get(&group_id) else {
log::debug!("pin_tab_group: unknown group {group_id:?}");
return;
};
if group.pinned {
log::debug!("pin_tab_group: group {group_id:?} is already pinned");
return;
}
let target = self.pinned_boundary_index(&self.tabs);
if let Some(group) = self.tab_groups.get_mut(&group_id) {
group.pinned = true;
}
self.move_group_block(group_id, target, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Unpins the entire tab group: clears the group's `pinned` flag and
/// moves the group's block to the start of the unpinned region.
pub(super) fn unpin_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::PinnedTabs.is_enabled() {
return;
}
let Some(group) = self.tab_groups.get(&group_id) else {
log::debug!("unpin_tab_group: unknown group {group_id:?}");
return;
};
if !group.pinned {
log::debug!("unpin_tab_group: group {group_id:?} is not pinned");
return;
}
let target = self.pinned_boundary_index(&self.tabs);
if let Some(group) = self.tab_groups.get_mut(&group_id) {
group.pinned = false;
}
self.move_group_block(group_id, target, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
/// Builds the "Move to group" submenu. One builder serves both parent
/// menus: `Some(tab_index)` for the single-tab pane menu, `None` for the
/// multi-tab selection menu. Destination groups exclude the source's own
/// group (no useful move) and follow panel order so the submenu visually
/// matches what the user sees in the tabs sidebar.
pub(super) fn build_move_to_group_sidecar_items(
&self,
tab_index: Option<usize>,
) -> Vec<MenuItem<WorkspaceAction>> {
// Exclude the source's current group (if any) — there's nowhere to
// move it to. For a mixed selection (no shared group) every
// destination stays available.
let excluded_group = match tab_index {
Some(idx) => self.tabs.get(idx).and_then(|tab| tab.group_id),
None => self.selection_shared_group(),
};
let mut groups_with_first_index: Vec<(TabGroupId, usize)> = self
.tab_groups
.keys()
.copied()
.filter(|gid| Some(*gid) != excluded_group)
.filter_map(|gid| {
group_member_indices(&self.tabs, gid)
.next()
.map(|idx| (gid, idx))
})
.collect();
groups_with_first_index.sort_by_key(|(_, idx)| *idx);
groups_with_first_index
.into_iter()
.map(|(group_id, _)| {
let label = self
.tab_groups
.get(&group_id)
.and_then(|g| g.name.clone())
.unwrap_or_else(|| "Untitled group".to_string());
let action = match tab_index {
Some(tab_index) => WorkspaceAction::MoveTabToGroup {
tab_index,
group_id,
},
None => WorkspaceAction::MoveSelectedTabsToGroup { group_id },
};
MenuItemFields::new(label)
.with_on_select_action(action)
.into_item()
})
.collect()
}
}
File diff suppressed because it is too large Load Diff
+198 -35
View File
@@ -1,29 +1,40 @@
use std::path::PathBuf;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use galaxyui::elements::PositionedElementOffsetBounds;
use galaxyui::EntityId;
use super::{
branch_label_display, coalesce_summary_branch_entries, code_detail_kind_label,
compact_branch_subtitle_display, detail_sidecar_width_and_bounds,
detail_target_for_hovered_row, non_terminal_search_text_fragments,
pane_ids_for_display_granularity, pane_search_text_fragments, preferred_agent_tab_titles,
push_normalized_unique_summary_label, search_fragments_contain_query,
select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position,
sort_summary_primary_labels_status_first, summary_overflow_count,
summary_search_text_fragments, terminal_kind_badge_label, terminal_primary_line_data,
terminal_pull_request_badge_label, terminal_search_text_fragments,
terminal_title_fallback_font, uses_outer_group_container, visible_pane_ids_for_detail_target,
vtab_diff_stats_text, AgentTabTextPreference, SummaryPaneKind, SummaryPaneKindIcons,
TerminalAgentText, TerminalPrimaryLineData, TerminalPrimaryLineFont, VerticalTabsDetailTarget,
VerticalTabsDetailTargetKind, VerticalTabsSummaryBranchEntry, VerticalTabsSummaryData,
VerticalTabsSummaryPrimaryLabel,
};
use crate::ai::agent::conversation::ConversationStatus;
use crate::context_chips::display_chip::GitLineChanges;
use crate::pane_group::pane::IPaneType;
use crate::pane_group::{PaneId, TerminalPaneId};
use crate::safe_triangle::SafeTriangle;
use crate::terminal::CLIAgent;
use crate::workspace::tab_settings::VerticalTabsDisplayGranularity;
use galaxyui::elements::PositionedElementOffsetBounds;
use galaxyui::EntityId;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use std::path::PathBuf;
use super::{
branch_label_display, coalesce_summary_branch_entries, code_detail_kind_label,
compact_branch_subtitle_display, detail_sidecar_width_and_bounds,
detail_target_for_hovered_row, format_summary_primary_labels,
non_terminal_search_text_fragments, pane_ids_for_display_granularity,
pane_search_text_fragments, preferred_agent_tab_titles, search_fragments_contain_query,
select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position,
summary_overflow_count, summary_search_text_fragments, terminal_kind_badge_label,
terminal_primary_line_data, terminal_pull_request_badge_label, terminal_search_text_fragments,
terminal_title_fallback_font, uses_outer_group_container, visible_pane_ids_for_detail_target,
vtab_diff_stats_text, AgentTabTextPreference, SummaryPaneKind, SummaryPaneKindIcons,
TerminalAgentText, TerminalPrimaryLineData, TerminalPrimaryLineFont, VerticalTabsDetailTarget,
VerticalTabsDetailTargetKind, VerticalTabsSummaryBranchEntry, VerticalTabsSummaryData,
};
fn label(text: &str) -> VerticalTabsSummaryPrimaryLabel {
VerticalTabsSummaryPrimaryLabel {
text: text.to_string(),
status: None,
}
}
fn pane_id() -> PaneId {
TerminalPaneId::dummy_terminal_pane_id().into()
@@ -87,6 +98,7 @@ fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
EntityId::from_usize(20),
SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
),
(
@@ -98,6 +110,41 @@ fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
primary: SummaryPaneKind::Terminal,
secondary: SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
})
);
}
#[test]
fn summary_pane_kind_icons_distinguish_ambient_claude_from_local_claude() {
// A local Claude session and a cloud-mode Claude session should count as distinct kinds
// so they render with different icons (claude.svg vs claude_cloud.svg).
assert_eq!(
select_summary_pane_kind_icons([
(
EntityId::from_usize(10),
SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
),
(
EntityId::from_usize(20),
SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: true,
},
),
]),
Some(SummaryPaneKindIcons::Pair {
primary: SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: false,
},
secondary: SummaryPaneKind::CLIAgent {
agent: CLIAgent::Claude,
is_ambient: true,
},
})
);
@@ -869,6 +916,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
branch_name: "main".to_string(),
diff_stats: None,
pull_request_label: None,
pull_request_url: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_a.clone(),
@@ -879,6 +927,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_b.clone(),
@@ -889,6 +938,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 6,
}),
pull_request_label: Some("#456".to_string()),
pull_request_url: Some("https://github.com/acme/repo-b/pull/456".to_string()),
},
];
@@ -904,6 +954,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: repo_b,
@@ -914,37 +965,143 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
lines_removed: 6,
}),
pull_request_label: Some("#456".to_string()),
pull_request_url: Some("https://github.com/acme/repo-b/pull/456".to_string()),
},
]
);
}
#[test]
fn format_summary_primary_labels_appends_overflow_count() {
let labels = vec![
"Claude".to_string(),
"Oz".to_string(),
"cargo".to_string(),
"code review".to_string(),
"tests".to_string(),
];
fn summary_overflow_count_caps_visible_region() {
assert_eq!(summary_overflow_count(5, 3), 2);
assert_eq!(summary_overflow_count(3, 3), 0);
assert_eq!(summary_overflow_count(2, 3), 0);
}
#[test]
fn primary_labels_dedupe_preserves_first_seen_status() {
let mut values = Vec::new();
let mut seen = std::collections::HashMap::new();
push_normalized_unique_summary_label(&mut values, &mut seen, " cargo test ", None);
push_normalized_unique_summary_label(
&mut values,
&mut seen,
"cargo test",
Some(ConversationStatus::InProgress),
);
assert_eq!(
format_summary_primary_labels(&labels, 4),
Some("Claude • Oz • cargo • code review + 1 more".to_string())
values,
vec![VerticalTabsSummaryPrimaryLabel {
text: "cargo test".to_string(),
status: None,
}]
);
}
#[test]
fn primary_labels_preserve_status_through_aggregation() {
let mut values = Vec::new();
let mut seen = std::collections::HashMap::new();
push_normalized_unique_summary_label(
&mut values,
&mut seen,
"Plan a refactor",
Some(ConversationStatus::InProgress),
);
push_normalized_unique_summary_label(
&mut values,
&mut seen,
"Investigate failure",
Some(ConversationStatus::Success),
);
push_normalized_unique_summary_label(&mut values, &mut seen, "cargo build", None);
assert_eq!(
values,
vec![
VerticalTabsSummaryPrimaryLabel {
text: "Plan a refactor".to_string(),
status: Some(ConversationStatus::InProgress),
},
VerticalTabsSummaryPrimaryLabel {
text: "Investigate failure".to_string(),
status: Some(ConversationStatus::Success),
},
VerticalTabsSummaryPrimaryLabel {
text: "cargo build".to_string(),
status: None,
},
]
);
}
#[test]
fn sort_summary_primary_labels_moves_status_first_and_preserves_order() {
let mut values = vec![
VerticalTabsSummaryPrimaryLabel {
text: "plain terminal".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "first conversation".to_string(),
status: Some(ConversationStatus::InProgress),
},
VerticalTabsSummaryPrimaryLabel {
text: "code pane".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "second conversation".to_string(),
status: Some(ConversationStatus::Success),
},
VerticalTabsSummaryPrimaryLabel {
text: "last terminal".to_string(),
status: None,
},
];
sort_summary_primary_labels_status_first(&mut values);
assert_eq!(
values,
vec![
VerticalTabsSummaryPrimaryLabel {
text: "first conversation".to_string(),
status: Some(ConversationStatus::InProgress),
},
VerticalTabsSummaryPrimaryLabel {
text: "second conversation".to_string(),
status: Some(ConversationStatus::Success),
},
VerticalTabsSummaryPrimaryLabel {
text: "plain terminal".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "code pane".to_string(),
status: None,
},
VerticalTabsSummaryPrimaryLabel {
text: "last terminal".to_string(),
status: None,
},
]
);
assert_eq!(summary_overflow_count(labels.len(), 4), 1);
}
#[test]
fn summary_search_fragments_include_hidden_overflow_values() {
let summary = VerticalTabsSummaryData {
primary_labels: vec![
"Claude".to_string(),
"Oz".to_string(),
"cargo".to_string(),
"code review".to_string(),
"hidden work".to_string(),
VerticalTabsSummaryPrimaryLabel {
text: "Claude".to_string(),
status: Some(ConversationStatus::InProgress),
},
label("Oz"),
label("cargo"),
label("code review"),
label("hidden work"),
],
working_directories: vec!["~/warp-internal".to_string(), "~/warp-server".to_string()],
branch_entries: vec![
@@ -957,31 +1114,37 @@ fn summary_search_fragments_include_hidden_overflow_values() {
lines_removed: 3,
}),
pull_request_label: Some("#123".to_string()),
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-b"),
branch_name: "feature/hidden".to_string(),
diff_stats: None,
pull_request_label: None,
pull_request_url: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-c"),
branch_name: "cleanup".to_string(),
diff_stats: None,
pull_request_label: None,
pull_request_url: None,
},
VerticalTabsSummaryBranchEntry {
repo_path: PathBuf::from("/tmp/repo-d"),
branch_name: "hidden-branch".to_string(),
diff_stats: None,
pull_request_label: Some("#789".to_string()),
pull_request_url: Some("https://github.com/acme/repo-d/pull/789".to_string()),
},
],
has_unread_activity: false,
};
let fragments = summary_search_text_fragments(&summary, Some("Custom tab"));
assert!(search_fragments_contain_query(&fragments, "custom tab"));
assert!(search_fragments_contain_query(&fragments, "claude"));
assert!(search_fragments_contain_query(&fragments, "hidden work"));
assert!(search_fragments_contain_query(&fragments, "hidden-branch"));
assert!(search_fragments_contain_query(&fragments, "#789"));
+25 -11
View File
@@ -1,20 +1,17 @@
//! WASM-only view functions for the Workspace.
use galaxy_core::channel::ChannelState;
use galaxyui::elements::{ChildView, Element};
use galaxyui::{AppContext, SingletonEntity, ViewContext, ViewHandle};
use galaxy_core::channel::ChannelState;
use crate::uri::browser_url_handler::parse_current_url;
use super::PanelPosition;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::ai::conversation_details_panel::{
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
};
use crate::terminal::TerminalView;
use crate::ui_components::icons;
use crate::uri::browser_url_handler::parse_current_url;
use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
@@ -114,24 +111,28 @@ impl Workspace {
/// Check if we should show the conversation details panel, given the focused terminal view.
/// Returns true for:
/// - Conversation transcript viewers (always)
/// - Shared sessions with an ambient agent task ID, OR an active conversation
/// - Restored ambient cloud tasks
/// - Shared sessions with an active conversation
pub(super) fn should_show_conversation_details_panel(
focused_terminal_view: &ViewHandle<TerminalView>,
ctx: &AppContext,
) -> bool {
let terminal_view_ref = focused_terminal_view.as_ref(ctx);
if terminal_view_ref
.ambient_agent_task_id_for_details_panel(ctx)
.is_some()
{
return true;
}
let model = terminal_view_ref.model.lock();
// Always show for conversation transcript viewers
if model.is_conversation_transcript_viewer() {
return true;
}
// For shared sessions, show if there's an ambient agent task_id or an active conversation
// For shared sessions, show if there's an active conversation.
if model.shared_session_status().is_sharer_or_viewer() {
if model.ambient_agent_task_id().is_some() {
return true;
}
drop(model); // Release lock before accessing BlocklistAIHistoryModel
return BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(focused_terminal_view.id())
@@ -194,6 +195,19 @@ impl Workspace {
ctx.notify();
return;
}
// Task not yet available - check if the fetch failed and show error state
if let Some(error_message) = conversations_model_handle
.as_ref(ctx)
.task_fetch_error(&task_id)
.cloned()
{
let details =
ConversationDetailsData::from_task_id(task_id, Some(error_message));
panel.set_conversation_details(details, ctx);
ctx.notify();
return;
}
}
// Otherwise, populate from conversation