use std::borrow::Cow; use crate::ai::AIRequestUsageModel; use crate::code::editor::comment_editor::DEFAULT_COMMENT_MAX_WIDTH; use crate::code::editor::view::{CodeEditorEvent, CodeEditorView}; use crate::code_review::comment_rendering::CommentViewCard; use crate::code_review::comments::{ AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin, ReviewCommentBatch, ReviewCommentBatchEvent, }; use crate::code_review::CodeReviewTelemetryEvent; use crate::menu::{Event, Menu, MenuItem, MenuItemFields}; use crate::notebooks::editor::view::{EditorViewEvent, RichTextEditorView}; use crate::send_telemetry_from_ctx; use crate::settings::AISettings; use crate::view_components::action_button::{ ActionButton, ActionButtonTheme, ButtonSize, NakedTheme, SecondaryTheme, }; use crate::{ appearance::Appearance, code_review::code_review_view::CodeReviewView, ui_components::icons::Icon, workspace::view::right_panel::ReviewDestination, }; use indexmap::IndexMap; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use std::path::PathBuf; use string_offset::CharOffset; use vec1::vec1; use warp_core::features::FeatureFlag; use warp_core::ui::color::blend::Blend; use warp_editor::model::CoreEditorModel; use warp_core::ui::theme::color::internal_colors::{ accent_overlay_2, accent_overlay_3, neutral_1, neutral_3, neutral_4, neutral_6, text_main, text_sub, }; use warp_core::ui::theme::Fill; use warpui::{ clipboard::ClipboardContent, elements::{ new_scrollable::{NewScrollable, ScrollableAppearance, SingleAxisConfig}, resizable::{resizable_state_handle, DragBarSide, Resizable, ResizableStateHandle}, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, Element, Empty, EventHandler, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack, Text, }, platform::Cursor, ui_components::{ button::{ButtonTooltipPosition, ButtonVariant}, components::{UiComponent, UiComponentStyles}, }, units::Pixels, AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle, }; /// Header text for the outdated section when there is exactly one outdated comment. const OUTDATED_SECTION_HEADER_SINGULAR: &str = "1 comment will be omitted because it is outdated."; /// Header text format for the outdated section when there are multiple outdated comments. /// Use with `format!` to insert the count. const OUTDATED_SECTION_HEADER_PLURAL_FMT: &str = " comments will be omitted because they are outdated."; /// Returns the header text for the outdated section based on the number of outdated comments. fn outdated_section_header_text(count: usize) -> Cow<'static, str> { if count == 1 { Cow::Borrowed(OUTDATED_SECTION_HEADER_SINGULAR) } else { Cow::Owned(format!("{count}{OUTDATED_SECTION_HEADER_PLURAL_FMT}")) } } /// Convert markdown text to HTML using the editor's buffer serialization. /// This function takes a comment editor view that has already been created with markdown content /// and extracts the HTML representation from its buffer. fn markdown_to_html( editor_view: &ViewHandle, ctx: &AppContext, ) -> Option { editor_view.read(ctx, |view, ctx| { let model = view.model(); model.read(ctx, |model, ctx| { let buffer = model.content(); let range = CharOffset::from(1)..buffer.as_ref(ctx).max_charoffset(); buffer.as_ref(ctx).ranges_as_html(vec1![range], ctx) }) }) } #[derive(Clone, Debug, PartialEq)] pub enum CommentListAction { ToggleCollapsed, ToggleOutdatedCollapsed, CopyCommentText, EditComment, JumpToCommentLocation(CommentId), Cancel, Submit, ShowOverflow { comment_id: CommentId }, DeleteComment, DismissOverflowMenu, ViewInGitHub { url: String }, } #[derive(Clone, Debug)] pub enum CommentListEvent { Submitted, Cancelled, DeleteComment { comment_id: CommentId }, EditComment(CommentId), JumpToCommentLocation(CommentId), } #[derive(Clone, Debug, PartialEq)] pub struct CommentListDebugState { pub review_destination: ReviewDestination, pub total_comments: usize, pub sendable_comments: usize, pub is_collapsed: bool, pub is_outdated_section_collapsed: Option, pub ai_available: bool, pub ai_enabled: bool, pub send_button_tooltip_text: String, } struct ViewState { scroll_state: ClippedScrollStateHandle, chevron_mouse_state: MouseStateHandle, outdated_chevron_mouse_state: MouseStateHandle, cancel_button_mouse_state: MouseStateHandle, submit_button_mouse_state: MouseStateHandle, resizable_state: ResizableStateHandle, } impl Default for ViewState { fn default() -> Self { Self { scroll_state: Default::default(), chevron_mouse_state: Default::default(), outdated_chevron_mouse_state: Default::default(), cancel_button_mouse_state: Default::default(), submit_button_mouse_state: Default::default(), resizable_state: resizable_state_handle(300.0), } } } struct CommentDisplayState { card: CommentViewCard, icon_button: ViewHandle, mouse_state: MouseStateHandle, } impl CommentDisplayState { fn save_position_id(&self) -> String { Self::overflow_menu_position_id(self.card.source().id) } fn comment_position_id(&self) -> String { Self::comment_position_id_for(self.card.source().id) } fn comment_position_id_for(comment_id: CommentId) -> String { format!("comment_list_view:comment:{comment_id}") } fn overflow_menu_position_id(comment_id: CommentId) -> String { format!("comment_list_view:{comment_id}") } } pub struct CommentListView { parent: WeakViewHandle, comment_model: Option>, comments_by_id: IndexMap, is_collapsed: bool, /// Set once the user has manually collapsed or expanded the outdated section. is_outdated_section_collapsed: Option, repo_path: PathBuf, view_state: ViewState, /// The best available destination for sending review comments. /// Pushed down from RightPanelView. review_destination: ReviewDestination, overflow_menu: ViewHandle>, active_overflow_comment_id: Option, pending_scroll_to_comment: Option, comments_button: ViewHandle, } impl CommentListView { pub fn new( initial_repo_path: Option, parent: WeakViewHandle, ctx: &mut ViewContext, ) -> Self { let menu = ctx.add_view(|_| Menu::new()); let comments_button = ctx.add_view(|_| { ActionButton::new("1 Comment", CustomSecondaryActionTheme) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(CommentListAction::ToggleCollapsed); }) }); ctx.subscribe_to_view(&menu, |me, _, event, ctx| match event { Event::ItemSelected => {} Event::Close { .. } => { me.close_overflow_menu(ctx); } Event::ItemHovered => {} }); Self { parent, comment_model: None, comments_by_id: IndexMap::new(), is_collapsed: true, is_outdated_section_collapsed: None, repo_path: initial_repo_path.unwrap_or_default(), view_state: ViewState::default(), overflow_menu: menu, review_destination: ReviewDestination::None, active_overflow_comment_id: None, pending_scroll_to_comment: None, comments_button, } } fn recompute_comment_button_label(&mut self, ctx: &mut ViewContext) { let total_count = self.comments_by_id.len(); let label_text = if FeatureFlag::PRCommentsSlashCommand.is_enabled() { let non_outdated_count = self .comments_by_id .values() .filter(|state| !state.card.source().outdated) .count(); if non_outdated_count == 0 && total_count > 0 { format!( "{} outdated comment{}", total_count, if total_count == 1 { "" } else { "s" } ) } else { format!( "{} comment{}", non_outdated_count, if non_outdated_count == 1 { "" } else { "s" } ) } } else { format!( "{} comment{}", total_count, if total_count == 1 { "" } else { "s" } ) }; self.comments_button .update(ctx, |view, ctx| view.set_label(label_text, ctx)); } pub fn set_review_destination( &mut self, destination: ReviewDestination, ctx: &mut ViewContext, ) { if self.review_destination != destination { self.review_destination = destination; ctx.notify(); } } pub fn debug_state(&self, ctx: &AppContext) -> CommentListDebugState { let ai_available = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx); let ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx); let sendable_comments = self .comments_by_id .values() .filter(|state| !state.card.source().outdated) .count(); let send_button_tooltip_text = Self::send_button_tooltip_text( &self.review_destination, sendable_comments > 0, ai_available, ai_enabled, ) .into_owned(); CommentListDebugState { review_destination: self.review_destination.clone(), total_comments: self.comments_by_id.len(), sendable_comments, is_collapsed: self.is_collapsed, is_outdated_section_collapsed: self.is_outdated_section_collapsed, ai_available, ai_enabled, send_button_tooltip_text, } } pub fn set_comment_model( &mut self, comment_model: Option>, ctx: &mut ViewContext, ) { if self.comment_model == comment_model { return; } if let Some(old_model) = self.comment_model.take() { ctx.unsubscribe_to_model(&old_model); } self.comment_model = comment_model.clone(); if let Some(model) = comment_model { ctx.subscribe_to_model(&model, Self::handle_comment_model_event); self.refresh_from_model(&model, ctx); } else { self.clear_comments(ctx); } } fn handle_comment_model_event( &mut self, model: ModelHandle, _event: &ReviewCommentBatchEvent, ctx: &mut ViewContext, ) { if self.comment_model.as_ref() != Some(&model) { // Only handle events from the current model. return; } self.refresh_from_model(&model, ctx); } fn refresh_from_model( &mut self, model: &ModelHandle, ctx: &mut ViewContext, ) { let comments = model.read(ctx, |batch, _| batch.comments.clone()); self.set_comments_internal(comments, ctx); } fn set_comments_internal( &mut self, comments: Vec, ctx: &mut ViewContext, ) { let mut new_comments_by_id = IndexMap::with_capacity(comments.len()); for comment in comments { let id = comment.id; let entry = if let Some(mut existing) = self.comments_by_id.shift_remove(&id) { existing .card .update_source(comment, Some(&self.repo_path), ctx); existing } else { let card = CommentViewCard::new( comment, false, /* always_use_static_diff */ false, /* disable_scrolling */ Some(Pixels::new(DEFAULT_COMMENT_MAX_WIDTH)), Some(&self.repo_path), ctx, ); ctx.subscribe_to_view( card.comment_editor(), Self::handle_comment_editor_selection_events, ); if let Some(diff_editor) = card.static_diff_editor() { ctx.subscribe_to_view( diff_editor, Self::handle_static_diff_editor_selection_events, ); } let comment_id = id; let action_button = ActionButton::new("", NakedTheme) .with_icon(Icon::DotsVertical) .with_size(ButtonSize::Small) .on_click(move |ctx| { ctx.dispatch_typed_action(CommentListAction::ShowOverflow { comment_id }) }); let action_button = ctx.add_view(|_| action_button); CommentDisplayState { card, icon_button: action_button, mouse_state: Default::default(), } }; new_comments_by_id.insert(id, entry); } self.comments_by_id = new_comments_by_id; if self .active_overflow_comment_id .is_some_and(|id| !self.comments_by_id.contains_key(&id)) { self.active_overflow_comment_id = None; } if self .pending_scroll_to_comment .is_some_and(|id| self.comments_by_id.contains_key(&id)) { let comment_id = self .pending_scroll_to_comment .take() .expect("pending scroll target was verified as present"); self.ensure_outdated_section_expanded(comment_id, ctx); self.view_state .scroll_state .scroll_to_position(ScrollTarget { position_id: CommentDisplayState::comment_position_id_for(comment_id), mode: ScrollToPositionMode::TopIntoView, }); } self.recompute_comment_button_label(ctx); ctx.notify(); } pub fn set_repo_path(&mut self, repo_path: PathBuf, ctx: &mut ViewContext) { self.repo_path = repo_path; for state in self.comments_by_id.values_mut() { state.card.update_title(Some(&self.repo_path)); } ctx.notify(); } pub fn expand(&mut self, ctx: &mut ViewContext) { if self.is_collapsed { self.is_collapsed = false; ctx.notify(); } } pub fn scroll_to_comment(&mut self, comment_id: CommentId, ctx: &mut ViewContext) { self.pending_scroll_to_comment = Some(comment_id); self.expand(ctx); self.ensure_outdated_section_expanded(comment_id, ctx); if self.comments_by_id.contains_key(&comment_id) { self.view_state .scroll_state .scroll_to_position(ScrollTarget { position_id: CommentDisplayState::comment_position_id_for(comment_id), mode: ScrollToPositionMode::TopIntoView, }); self.pending_scroll_to_comment = None; } ctx.notify(); } /// If the given comment is outdated, force-expand the outdated section so /// the comment is visible and the scroll position can be calculated. fn ensure_outdated_section_expanded( &mut self, comment_id: CommentId, ctx: &mut ViewContext, ) { let is_outdated = self .comments_by_id .get(&comment_id) .is_some_and(|state| state.card.source().outdated); if is_outdated && self.outdated_section_should_be_collapsed(ctx) { self.is_outdated_section_collapsed = Some(false); } } fn handle_comment_editor_selection_events( &mut self, view: ViewHandle, event: &EditorViewEvent, ctx: &mut ViewContext, ) { match event { EditorViewEvent::TextSelectionChanged => { if view.as_ref(ctx).selected_text(ctx).is_some() { self.clear_other_comment_selections(Some(view.id()), ctx); } } EditorViewEvent::Focused => { self.clear_other_comment_selections(Some(view.id()), ctx); } _ => {} } } fn handle_static_diff_editor_selection_events( &mut self, view: ViewHandle, event: &CodeEditorEvent, ctx: &mut ViewContext, ) { match event { CodeEditorEvent::SelectionChanged => { if view.as_ref(ctx).selected_text(ctx).is_some() { self.clear_other_comment_selections(Some(view.id()), ctx); } } CodeEditorEvent::Focused => { self.clear_other_comment_selections(Some(view.id()), ctx); } _ => {} } } fn clear_other_comment_selections( &mut self, source_view_id: Option, ctx: &mut ViewContext, ) { for state in self.comments_by_id.values() { let card = &state.card; if source_view_id.is_none_or(|id| card.comment_editor().id() != id) { card.comment_editor() .update(ctx, |view, ctx| view.clear_text_selection(ctx)); } if let Some(diff_editor) = card.static_diff_editor() { if source_view_id.is_none_or(|id| diff_editor.id() != id) { diff_editor.update(ctx, |view, ctx| view.clear_selection(ctx)); } } } } pub fn clear_comments(&mut self, ctx: &mut ViewContext) { self.comments_by_id.clear(); self.is_collapsed = true; self.pending_scroll_to_comment = None; ctx.notify(); } fn render_panel(&self, appearance: &Appearance, ctx: &AppContext) -> Box { let theme = appearance.theme(); let header = self.render_header(appearance, ctx); let mut comments_column = Flex::column() .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Stretch); if FeatureFlag::PRCommentsSlashCommand.is_enabled() { let (outdated_comments, active_comments): (Vec<_>, Vec<_>) = self .comments_by_id .values() .partition(|state| state.card.source().outdated); if !outdated_comments.is_empty() { comments_column.add_child(self.render_outdated_section( &outdated_comments, appearance, ctx, )); } for comment_render_state in active_comments { comments_column.add_child( Container::new(self.render_comment(comment_render_state, ctx)) .with_margin_bottom(12.) .finish(), ); } } else { for comment_render_state in self.comments_by_id.values() { comments_column.add_child( Container::new(self.render_comment(comment_render_state, ctx)) .with_margin_bottom(12.) .finish(), ); } } let scrollable_content = NewScrollable::vertical( SingleAxisConfig::Clipped { handle: self.view_state.scroll_state.clone(), child: Container::new(comments_column.finish()) .with_uniform_padding(16.) .finish(), }, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), warpui::elements::Fill::None, ) .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false)) .with_propagate_mousewheel_if_not_handled(true) .finish(); Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_child(header) .with_child(Expanded::new(1., scrollable_content).finish()) .finish() } fn outdated_section_should_be_collapsed(&self, ctx: &AppContext) -> bool { if let Some(manually_collapsed) = self.is_outdated_section_collapsed { return manually_collapsed; } // If there are only outdated comments, don't collapse the outdated section. if let Some(comment_model) = &self.comment_model { let has_only_outdated_comments = comment_model.as_ref(ctx).has_only_outdated_comments(); return !has_only_outdated_comments; } true } fn render_outdated_section( &self, outdated_comments: &[&CommentDisplayState], appearance: &Appearance, ctx: &AppContext, ) -> Box { let theme = appearance.theme(); let yellow_border: ColorU = theme.terminal_colors().normal.yellow.into(); let is_collapsed = self.outdated_section_should_be_collapsed(ctx); let count = outdated_comments.len(); let mut outdated_column = Flex::column() .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Stretch); outdated_column.add_child(self.render_outdated_section_header( count, is_collapsed, appearance, )); if !is_collapsed { for comment_render_state in outdated_comments { outdated_column.add_child( Container::new(self.render_comment(comment_render_state, ctx)) .with_margin_top(8.) .with_horizontal_margin(8.) .finish(), ); } } let mut outdated_section = Container::new(outdated_column.finish()) .with_border(Border::all(1.).with_border_fill(Fill::Solid(yellow_border))) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_margin_bottom(8.); if !is_collapsed { outdated_section = outdated_section.with_padding_bottom(8.) } outdated_section.finish() } fn render_outdated_section_header( &self, count: usize, is_collapsed: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let yellow_border: ColorU = theme.terminal_colors().normal.yellow.into(); let header_text = outdated_section_header_text(count); Hoverable::new( self.view_state.outdated_chevron_mouse_state.clone(), move |mouse_state| { let icon = if is_collapsed { Icon::ChevronRight } else { Icon::ChevronDown }; let icon_element = icon .to_warpui_icon(warp_core::ui::theme::Fill::Solid(text_sub( theme, neutral_1(theme), ))) .finish(); let icon_container = Container::new( ConstrainedBox::new(icon_element) .with_width(16.) .with_height(16.) .finish(), ) .with_margin_right(8.) .finish(); let text = Text::new( header_text.clone(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(text_main(theme, neutral_1(theme))) .finish(); let row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(icon_container) .with_child(text) .finish(); let mut header_container = Container::new(row) .with_vertical_padding(8.) .with_horizontal_padding(8.) .with_background(neutral_1(theme)); if !is_collapsed { header_container = header_container .with_border(Border::bottom(1.).with_border_color(yellow_border)); } let radius = Radius::Pixels(4.); let header_corners = if is_collapsed { CornerRadius::with_all(radius) } else { CornerRadius::with_top(radius) }; if mouse_state.is_hovered() { header_container = header_container.with_background_color(neutral_1(theme)) } header_container.with_corner_radius(header_corners).finish() }, ) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CommentListAction::ToggleOutdatedCollapsed); }) .finish() } fn render_header(&self, appearance: &Appearance, ctx: &AppContext) -> Box { let theme = appearance.theme(); let mut header_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center); header_row.add_child(self.render_header_left(appearance)); header_row.add_child(self.render_header_right(appearance, ctx)); Container::new(Clipped::new(Shrinkable::new(1., header_row.finish()).finish()).finish()) .with_background(neutral_3(theme)) .with_vertical_padding(8.) .with_horizontal_padding(16.) .with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.))) .with_border( Border::new(1.) .with_sides( true, /* top */ true, /* left */ false, /* bottom */ true, /* right */ ) .with_border_fill(warp_core::ui::theme::Fill::Solid(neutral_4(theme))), ) .finish() } fn render_header_left(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let mut left_section = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Center); left_section.add_child(self.render_visibility_toggle(appearance)); left_section.add_child(ChildView::new(&self.comments_button).finish()); let outdated_count = self .comments_by_id .values() .filter(|state| state.card.source().outdated) .count(); if outdated_count > 0 { let circle_separator = Container::new( ConstrainedBox::new( Container::new(Empty::new().finish()) .with_background(Fill::Solid(neutral_6(theme))) .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))) .finish(), ) .with_width(4.) .with_height(4.) .finish(), ) .with_horizontal_margin(6.) .finish(); let outdated_text = Text::new( format!("{outdated_count} outdated"), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(text_sub(theme, neutral_3(theme))) .finish(); left_section.add_child(circle_separator); left_section.add_child(outdated_text); } left_section.finish() } fn render_visibility_toggle(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let is_collapsed = self.is_collapsed; Hoverable::new( self.view_state.chevron_mouse_state.clone(), move |mouse_state| { let icon = if is_collapsed { Icon::ChevronRight } else { Icon::ChevronDown }; let icon_element = icon .to_warpui_icon(warp_core::ui::theme::Fill::Solid(text_sub( theme, neutral_3(theme), ))) .finish(); let container = Container::new( ConstrainedBox::new(icon_element) .with_width(16.) .with_height(16.) .finish(), ) .with_margin_right(8.) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); let container = if mouse_state.is_hovered() { container.with_background(theme.surface_3()) } else { container }; container.finish() }, ) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CommentListAction::ToggleCollapsed); }) .finish() } fn render_header_right(&self, appearance: &Appearance, ctx: &AppContext) -> Box { let mut right_section = Flex::row() .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center); right_section.add_child(self.render_cancel_button(appearance)); right_section.add_child(self.render_send_button(appearance, ctx)); right_section.finish() } fn render_cancel_button(&self, appearance: &Appearance) -> Box { let cancel_button = EventHandler::new( appearance .ui_builder() .button( ButtonVariant::Text, self.view_state.cancel_button_mouse_state.clone(), ) .with_text_label("Cancel".to_string()) .build() .finish(), ) .on_left_mouse_down(|ctx, _, _| { ctx.dispatch_typed_action(CommentListAction::Cancel); DispatchEventResult::StopPropagation }) .finish(); Container::new(cancel_button).with_margin_right(8.).finish() } fn has_non_outdated_comments(&self) -> bool { self.comments_by_id .values() .any(|state| !state.card.source().outdated) } /// Computes the tooltip text for the send button based on current state. fn send_button_tooltip_text( destination: &ReviewDestination, has_sendable_comments: bool, ai_available: bool, ai_enabled: bool, ) -> Cow<'static, str> { if let ReviewDestination::Cli(agent) = destination { if !has_sendable_comments { Cow::Borrowed("No non-outdated comments to send") } else { let cmd = agent.command_prefix(); let label = if cmd.is_empty() { "CLI agent" } else { cmd }; Cow::Owned(format!("Send diff comments to {label}")) } } else if !ai_enabled { Cow::Borrowed("AI must be enabled to send comments to Agent") } else if !ai_available { Cow::Borrowed("Agent code review requires AI credits") } else if matches!(destination, ReviewDestination::None) { Cow::Borrowed("All terminals are busy") } else if !has_sendable_comments { Cow::Borrowed("No non-outdated comments to send") } else { Cow::Borrowed("Send diff comments to Agent") } } fn render_send_button(&self, appearance: &Appearance, ctx: &AppContext) -> Box { let ai_available = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx); let ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx); let has_sendable_comments = self.has_non_outdated_comments(); // CLI agents don't consume AI credits, so bypass the ai_available check. let enable_send = match &self.review_destination { ReviewDestination::None => false, ReviewDestination::Cli(_) => has_sendable_comments, ReviewDestination::Warp => ai_available && has_sendable_comments, }; let tooltip_text = Self::send_button_tooltip_text( &self.review_destination, has_sendable_comments, ai_available, ai_enabled, ); let tooltip = appearance .ui_builder() .tool_tip(tooltip_text.into_owned()) .build() .finish(); let button = appearance .ui_builder() .button( ButtonVariant::Accent, self.view_state.submit_button_mouse_state.clone(), ) .with_text_label("Send to Agent".to_string()) .with_tooltip(|| tooltip) .with_tooltip_position(ButtonTooltipPosition::AboveLeft); if enable_send { EventHandler::new(button.build().finish()) .on_left_mouse_down(move |ctx, _, _| { ctx.dispatch_typed_action(CommentListAction::Submit); DispatchEventResult::StopPropagation }) .finish() } else { // Custom disabled button appearance because setting the `disabled` property // on the button itself prevents all hoverable interaction (including tooltips). let background_fill = appearance.theme().surface_3(); let foreground_color = appearance .theme() .disabled_text_color(background_fill) .into_solid(); button .with_style(UiComponentStyles { background: Some(background_fill.into_solid().into()), border_color: Some(foreground_color.into()), font_color: Some(foreground_color), ..Default::default() }) .build() .finish() } } fn render_comment( &self, comment_state: &CommentDisplayState, ctx: &AppContext, ) -> Box { let editor_lens_element = self.editor_lens_for_card(&comment_state.card, ctx); let overflow_menu_button = SavePosition::new( ChildView::new(&comment_state.icon_button).finish(), &comment_state.save_position_id(), ) .finish(); let comment_container = comment_state.card.render( editor_lens_element, None, Some(overflow_menu_button), None, ctx, ); let clickable = !comment_state.card.source().outdated && matches!( comment_state.card.source().target, AttachedReviewCommentTarget::Line { .. } | AttachedReviewCommentTarget::File { .. } ); let comment = if clickable { let comment_id = comment_state.card.source().id; Hoverable::new(comment_state.mouse_state.clone(), |_mouse_state| { comment_container }) .on_click(move |event_ctx, _, _| { event_ctx .dispatch_typed_action(CommentListAction::JumpToCommentLocation(comment_id)) }) .with_defer_events_to_children() .finish() } else { comment_container }; SavePosition::new(comment, &comment_state.comment_position_id()).finish() } fn editor_lens_for_card( &self, card: &CommentViewCard, ctx: &AppContext, ) -> Option> { if !card.uses_editor_lens() { return None; } if let AttachedReviewCommentTarget::Line { absolute_file_path, line, .. } = &card.source().target { let parent = self.parent.upgrade(ctx)?.as_ref(ctx); let location = line.clone(); parent.editor_lens_for_location(absolute_file_path, location.clone()..location, ctx) } else { None } } fn close_overflow_menu(&mut self, ctx: &mut ViewContext) { self.active_overflow_comment_id.take(); ctx.notify(); } fn menu_items_for_comment( is_file_level: bool, is_outdated: bool, html_url: Option<&str>, appearance: &Appearance, ) -> Vec> { let mut items = vec![MenuItemFields::new("Copy text") .with_icon(Icon::Copy) .with_on_select_action(CommentListAction::CopyCommentText) .into_item()]; let mut edit_item = MenuItemFields::new("Edit") .with_icon(Icon::Pencil) .with_on_select_action(CommentListAction::EditComment); if is_file_level || is_outdated { let tooltip_text = if is_file_level { "File-level comments currently can't be edited." } else { "Outdated comments can't be edited." }; edit_item = edit_item.with_disabled(true).with_tooltip(tooltip_text); } items.push(edit_item.into_item()); if let Some(url) = html_url { items.push( MenuItemFields::new("View in GitHub") .with_icon(Icon::Github) .with_on_select_action(CommentListAction::ViewInGitHub { url: url.to_string(), }) .into_item(), ); } items.push( MenuItemFields::new("Remove") .with_icon(Icon::Trash) .with_override_text_color(Fill::Solid(appearance.theme().ansi_fg_red())) .with_override_icon_color(Fill::Solid(appearance.theme().ansi_fg_red())) .with_on_select_action(CommentListAction::DeleteComment) .into_item(), ); items } } impl Entity for CommentListView { type Event = CommentListEvent; } impl View for CommentListView { fn render(&self, ctx: &AppContext) -> Box { if self.comments_by_id.is_empty() { return Empty::new().finish(); } let appearance = Appearance::as_ref(ctx); if self.is_collapsed { self.render_header(appearance, ctx) } else { let mut panel = self.render_panel(appearance, ctx); if let Some(comment) = self .active_overflow_comment_id .and_then(|id| self.comments_by_id.get(&id)) { let mut stack = Stack::new().with_child(panel); stack.add_positioned_child( Dismiss::new(ChildView::new(&self.overflow_menu).finish()) .on_dismiss(|ctx, _app| { ctx.dispatch_typed_action(CommentListAction::DismissOverflowMenu) }) .prevent_interaction_with_other_elements() .finish(), OffsetPositioning::offset_from_save_position_element( comment.save_position_id(), vec2f(0., 1.), PositionedElementOffsetBounds::ParentByPosition, PositionedElementAnchor::BottomRight, ChildAnchor::TopRight, ), ); panel = stack.finish(); } Resizable::new(self.view_state.resizable_state.clone(), panel) .with_dragbar_side(DragBarSide::Top) .with_dragbar_color(warpui::elements::Fill::Solid( warpui::color::ColorU::transparent_black(), )) .with_bounds_callback(Box::new(|window_size| (100.0, window_size.y() * 0.8))) .on_resize(|ctx, _| { ctx.notify(); }) .finish() } } fn ui_name() -> &'static str { "CommentListView" } } impl TypedActionView for CommentListView { type Action = CommentListAction; fn handle_action(&mut self, action: &CommentListAction, ctx: &mut ViewContext) { match action { CommentListAction::ToggleCollapsed => { self.is_collapsed = !self.is_collapsed; // If the comment list is now open, recompute the last updated timestamps. if !self.is_collapsed { for comment in self.comments_by_id.values_mut() { comment.card.refresh_last_updated_duration(); } // Telemetry: comment list view expanded. send_telemetry_from_ctx!( CodeReviewTelemetryEvent::CommentListExpanded { comment_count: self.comments_by_id.len(), }, ctx ); } ctx.notify(); } CommentListAction::ToggleOutdatedCollapsed => { self.is_outdated_section_collapsed = Some(!self.outdated_section_should_be_collapsed(ctx)); ctx.notify(); } CommentListAction::Cancel => { ctx.emit(CommentListEvent::Cancelled); } CommentListAction::Submit => { ctx.emit(CommentListEvent::Submitted); } CommentListAction::ShowOverflow { comment_id } => { let current_overflow = self.active_overflow_comment_id.take(); if current_overflow != Some(*comment_id) { self.active_overflow_comment_id = Some(*comment_id); // File-level comments cannot be edited (no line to jump to). // Outdated comments cannot be edited. let (is_file_level, is_outdated, html_url) = self .comments_by_id .get(comment_id) .map(|state| { let source = state.card.source(); let html_url = match &source.origin { CommentOrigin::ImportedFromGitHub(details) => { details.html_url.clone() } CommentOrigin::Native => None, }; ( matches!(source.target, AttachedReviewCommentTarget::File { .. }), source.outdated, html_url, ) }) .unwrap_or((true, true, None)); self.overflow_menu.update(ctx, |menu, ctx| { let appearance = Appearance::handle(ctx).as_ref(ctx); menu.set_items( Self::menu_items_for_comment( is_file_level, is_outdated, html_url.as_deref(), appearance, ), ctx, ); }); } ctx.notify(); } CommentListAction::DeleteComment => { if let Some(id) = self.active_overflow_comment_id.take() { ctx.emit(CommentListEvent::DeleteComment { comment_id: id }) } ctx.notify(); } CommentListAction::DismissOverflowMenu => self.close_overflow_menu(ctx), CommentListAction::CopyCommentText => { if let Some(id) = self.active_overflow_comment_id.take() { if let Some(state) = self.comments_by_id.get(&id) { let content = state.card.source().content.clone(); let mut clipboard = ClipboardContent::plain_text(content.clone()); clipboard.html = markdown_to_html(state.card.comment_editor(), ctx); ctx.clipboard().write(clipboard); } } ctx.notify(); } CommentListAction::EditComment => { if let Some(id) = self.active_overflow_comment_id.take() { ctx.emit(CommentListEvent::EditComment(id)); } ctx.notify(); } CommentListAction::ViewInGitHub { url } => { ctx.open_url(url); self.close_overflow_menu(ctx); } CommentListAction::JumpToCommentLocation(comment_id) => { send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentListItemClicked, ctx); ctx.emit(CommentListEvent::JumpToCommentLocation(*comment_id)); } } } } struct CustomSecondaryActionTheme; impl ActionButtonTheme for CustomSecondaryActionTheme { fn background(&self, hovered: bool, appearance: &Appearance) -> Option { let background = Fill::Solid(neutral_3(appearance.theme())); if hovered { Some(background.blend(&accent_overlay_3(appearance.theme()))) } else { Some(background.blend(&accent_overlay_2(appearance.theme()))) } } fn text_color( &self, hovered: bool, background: Option, appearance: &Appearance, ) -> ColorU { let theme = appearance.theme(); theme .font_color( background .or(self.background(hovered, appearance)) .unwrap_or(theme.background()), ) .into_solid() } fn adjoined_side_border(&self, appearance: &Appearance) -> Option { SecondaryTheme.adjoined_side_border(appearance) } fn border(&self, appearance: &Appearance) -> Option { Some(appearance.theme().accent().into_solid()) } fn keyboard_shortcut_border( &self, text_color: ColorU, appearance: &Appearance, ) -> Option { SecondaryTheme.keyboard_shortcut_border(text_color, appearance) } fn keyboard_shortcut_background(&self, appearance: &Appearance) -> Option { SecondaryTheme.keyboard_shortcut_background(appearance) } }