1333 lines
47 KiB
Rust
1333 lines
47 KiB
Rust
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<RichTextEditorView>,
|
|
ctx: &AppContext,
|
|
) -> Option<String> {
|
|
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<bool>,
|
|
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<ActionButton>,
|
|
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<CodeReviewView>,
|
|
|
|
comment_model: Option<ModelHandle<ReviewCommentBatch>>,
|
|
|
|
comments_by_id: IndexMap<CommentId, CommentDisplayState>,
|
|
|
|
is_collapsed: bool,
|
|
|
|
/// Set once the user has manually collapsed or expanded the outdated section.
|
|
is_outdated_section_collapsed: Option<bool>,
|
|
repo_path: PathBuf,
|
|
view_state: ViewState,
|
|
/// The best available destination for sending review comments.
|
|
/// Pushed down from RightPanelView.
|
|
review_destination: ReviewDestination,
|
|
overflow_menu: ViewHandle<Menu<CommentListAction>>,
|
|
active_overflow_comment_id: Option<CommentId>,
|
|
pending_scroll_to_comment: Option<CommentId>,
|
|
comments_button: ViewHandle<ActionButton>,
|
|
}
|
|
|
|
impl CommentListView {
|
|
pub fn new(
|
|
initial_repo_path: Option<PathBuf>,
|
|
parent: WeakViewHandle<CodeReviewView>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> 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<Self>) {
|
|
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<Self>,
|
|
) {
|
|
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<ModelHandle<ReviewCommentBatch>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<ReviewCommentBatch>,
|
|
_event: &ReviewCommentBatchEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<ReviewCommentBatch>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let comments = model.read(ctx, |batch, _| batch.comments.clone());
|
|
self.set_comments_internal(comments, ctx);
|
|
}
|
|
|
|
fn set_comments_internal(
|
|
&mut self,
|
|
comments: Vec<AttachedReviewComment>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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>) {
|
|
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<Self>) {
|
|
if self.is_collapsed {
|
|
self.is_collapsed = false;
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn scroll_to_comment(&mut self, comment_id: CommentId, ctx: &mut ViewContext<Self>) {
|
|
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<Self>,
|
|
) {
|
|
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<RichTextEditorView>,
|
|
event: &EditorViewEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<CodeEditorView>,
|
|
event: &CodeEditorEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<EntityId>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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>) {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<Box<dyn Element>> {
|
|
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>) {
|
|
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<MenuItem<CommentListAction>> {
|
|
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<dyn Element> {
|
|
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<Self>) {
|
|
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<Fill> {
|
|
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<Fill>,
|
|
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<ColorU> {
|
|
SecondaryTheme.adjoined_side_border(appearance)
|
|
}
|
|
|
|
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
|
|
Some(appearance.theme().accent().into_solid())
|
|
}
|
|
|
|
fn keyboard_shortcut_border(
|
|
&self,
|
|
text_color: ColorU,
|
|
appearance: &Appearance,
|
|
) -> Option<ColorU> {
|
|
SecondaryTheme.keyboard_shortcut_border(text_color, appearance)
|
|
}
|
|
|
|
fn keyboard_shortcut_background(&self, appearance: &Appearance) -> Option<ColorU> {
|
|
SecondaryTheme.keyboard_shortcut_background(appearance)
|
|
}
|
|
}
|