first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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.;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>,
|
||||
) {
|
||||
|
||||
@@ -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>>)>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+1771
-439
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user