Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
Copyright (c) GitHub, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Header layout used when the `GitOperationsInCodeReview` feature flag is
|
||||
//! enabled. This replaces the legacy header (which lives in the parent module)
|
||||
//! with a simplified layout: the diff-mode dropdown on the left, and file-nav /
|
||||
//! overflow / maximize buttons on the right.
|
||||
//!
|
||||
//! Separated into its own module so the two codepaths are easy to distinguish.
|
||||
|
||||
use crate::code_review::code_review_view::{
|
||||
CodeReviewAction, CodeReviewHeaderFields, PrimaryGitActionMode,
|
||||
};
|
||||
use crate::code_review::diff_selector::DiffSelector;
|
||||
use crate::menu::Menu;
|
||||
use crate::view_components::action_button::ActionButton;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::elements::{
|
||||
ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex,
|
||||
MainAxisAlignment, MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Shrinkable, Stack,
|
||||
};
|
||||
use warpui::{Element, ViewHandle};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
use super::CodeReviewHeader;
|
||||
|
||||
impl CodeReviewHeader {
|
||||
/// Entry-point for the new header layout (feature-flagged behind
|
||||
/// `GitOperationsInCodeReview`). Renders a single row: diff-mode dropdown
|
||||
/// on the left, action buttons on the right.
|
||||
pub fn render_new(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
code_review_header_fields: &CodeReviewHeaderFields,
|
||||
) -> Box<dyn Element> {
|
||||
let mut right_section = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(git_button) = Self::render_git_operations_button(code_review_header_fields) {
|
||||
right_section.add_child(git_button);
|
||||
}
|
||||
|
||||
if let Some(nav_button) = &code_review_header_fields.file_nav_button {
|
||||
right_section.add_child(Self::render_file_nav_button(nav_button));
|
||||
}
|
||||
|
||||
if code_review_header_fields.has_header_menu_items {
|
||||
right_section.add_child(self.render_new_header_dropdown_button(
|
||||
&code_review_header_fields.header_dropdown_button,
|
||||
&code_review_header_fields.header_menu,
|
||||
code_review_header_fields.header_menu_open,
|
||||
));
|
||||
}
|
||||
|
||||
if code_review_header_fields.is_in_split_pane {
|
||||
right_section = right_section.with_child(self.render_maximize_pane_button(
|
||||
&code_review_header_fields.maximize_button,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
let row = Clipped::new(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Self::render_diff_mode_dropdown(
|
||||
&code_review_header_fields.diff_selector,
|
||||
))
|
||||
.with_child(right_section.finish())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(row).with_margin_bottom(8.).finish()
|
||||
}
|
||||
|
||||
/// Renders the diff target selector in the left section of the header.
|
||||
fn render_diff_mode_dropdown(diff_selector: &ViewHandle<DiffSelector>) -> Box<dyn Element> {
|
||||
Container::new(ChildView::new(diff_selector).finish())
|
||||
.with_margin_right(8.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_file_nav_button(button: &ViewHandle<ActionButton>) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(ChildView::new(button).finish())
|
||||
.with_height(warp_core::ui::icons::ICON_DIMENSIONS)
|
||||
.with_width(warp_core::ui::icons::ICON_DIMENSIONS)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_git_operations_button(
|
||||
code_review_header_fields: &CodeReviewHeaderFields,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
if !FeatureFlag::GitOperationsInCodeReview.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut row = Flex::row().with_child(
|
||||
ChildView::new(&code_review_header_fields.git_primary_action_button).finish(),
|
||||
);
|
||||
|
||||
if matches!(
|
||||
code_review_header_fields.primary_git_action_mode,
|
||||
PrimaryGitActionMode::Commit | PrimaryGitActionMode::Push
|
||||
) {
|
||||
row.add_child(
|
||||
ChildView::new(&code_review_header_fields.git_operations_chevron).finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let button_row = Container::new(row.finish()).with_margin_right(4.).finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(button_row);
|
||||
if code_review_header_fields.git_operations_menu_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&code_review_header_fields.git_operations_menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Some(stack.finish())
|
||||
}
|
||||
|
||||
/// Like `render_header_dropdown_button` but without `margin_left(4.)`,
|
||||
/// matching the tighter spacing of the new header layout.
|
||||
fn render_new_header_dropdown_button(
|
||||
&self,
|
||||
header_dropdown_button: &ViewHandle<ActionButton>,
|
||||
header_menu: &ViewHandle<Menu<CodeReviewAction>>,
|
||||
header_menu_open: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let button_container = Container::new(
|
||||
ConstrainedBox::new(ChildView::new(header_dropdown_button).finish())
|
||||
.with_height(warp_core::ui::icons::ICON_DIMENSIONS)
|
||||
.with_width(warp_core::ui::icons::ICON_DIMENSIONS)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(button_container);
|
||||
|
||||
if header_menu_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(header_menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
mod header_revamp;
|
||||
|
||||
use crate::code_review::code_review_view::{
|
||||
CodeReviewHeaderFields, CodeReviewView, CONTENT_TOP_MARGIN,
|
||||
};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
code_review::{
|
||||
code_review_view::{get_discard_button_disabled_tooltip, CodeReviewAction, LoadedState},
|
||||
diff_state::DiffStateModel,
|
||||
},
|
||||
menu::Menu,
|
||||
ui_components::icons::Icon,
|
||||
view_components::action_button::ActionButton,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::elements::{Hoverable, ParentElement};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::{Coords, UiComponent};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment,
|
||||
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentOffsetBounds, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
ui_components::{
|
||||
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
|
||||
components::UiComponentStyles,
|
||||
},
|
||||
AppContext, Element, ModelHandle, ViewHandle,
|
||||
};
|
||||
|
||||
// This is a best effort guess of the size of all of the elements in the header to know when we should start to wrap to the second row
|
||||
const HEADER_WRAP_BREAKPOINT: f32 = 450.;
|
||||
|
||||
pub(crate) const HEADER_BUTTON_PADDING: Coords = Coords {
|
||||
top: 2.,
|
||||
bottom: 2.,
|
||||
left: 6.,
|
||||
right: 6.,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
branch_name_tooltip: MouseStateHandle,
|
||||
discard_all_button: MouseStateHandle,
|
||||
add_diff_set_context_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct CodeReviewHeader {
|
||||
state_handles: StateHandles,
|
||||
}
|
||||
|
||||
impl CodeReviewHeader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state_handles: StateHandles::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
state: &LoadedState,
|
||||
appearance: &Appearance,
|
||||
code_review_header_fields: &CodeReviewHeaderFields,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let wide_layout =
|
||||
self.render_wide_layout(state, appearance, code_review_header_fields, app);
|
||||
|
||||
let compact_layout =
|
||||
self.render_compact_layout(state, appearance, code_review_header_fields, app);
|
||||
|
||||
let header_switch = SizeConstraintSwitch::new(
|
||||
wide_layout,
|
||||
vec![(
|
||||
SizeConstraintCondition::WidthLessThan(HEADER_WRAP_BREAKPOINT),
|
||||
compact_layout,
|
||||
)],
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(Clipped::new(Shrinkable::new(1., header_switch).finish()).finish())
|
||||
.with_margin_top(CONTENT_TOP_MARGIN)
|
||||
.with_margin_bottom(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_wide_layout(
|
||||
&self,
|
||||
state: &LoadedState,
|
||||
appearance: &Appearance,
|
||||
code_review_header_fields: &CodeReviewHeaderFields,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut left_section_wide = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
left_section_wide.add_child(
|
||||
Shrinkable::new(
|
||||
100.0,
|
||||
self.create_branch_tooltip(
|
||||
&code_review_header_fields.diff_state_model,
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
left_section_wide.add_child(
|
||||
Container::new(CodeReviewView::render_diff_stats(
|
||||
&state.to_diff_stats(),
|
||||
appearance,
|
||||
))
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut right_section_wide = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(ChildView::new(&code_review_header_fields.diff_selector).finish());
|
||||
|
||||
let has_no_changes = state.to_diff_stats().has_no_changes();
|
||||
|
||||
if FeatureFlag::DiscardPerFileAndAllChanges.is_enabled() {
|
||||
right_section_wide.add_child(self.create_discard_button(
|
||||
state,
|
||||
&code_review_header_fields.diff_state_model,
|
||||
appearance,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
if FeatureFlag::DiffSetAsContext.is_enabled() && !has_no_changes {
|
||||
if FeatureFlag::FileAndDiffSetComments.is_enabled() {
|
||||
right_section_wide.add_child(self.render_header_dropdown_button(
|
||||
&code_review_header_fields.header_dropdown_button,
|
||||
&code_review_header_fields.header_menu,
|
||||
code_review_header_fields.header_menu_open,
|
||||
));
|
||||
} else {
|
||||
right_section_wide.add_child(self.render_add_diff_set_context_button(appearance));
|
||||
}
|
||||
}
|
||||
|
||||
if code_review_header_fields.is_in_split_pane {
|
||||
right_section_wide = right_section_wide.with_child(self.render_maximize_pane_button(
|
||||
&code_review_header_fields.maximize_button,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
Clipped::new(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(2., left_section_wide.finish()).finish())
|
||||
.with_child(right_section_wide.finish())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_compact_layout(
|
||||
&self,
|
||||
state: &LoadedState,
|
||||
appearance: &Appearance,
|
||||
code_review_header_fields: &CodeReviewHeaderFields,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut left_section_compact = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
left_section_compact.add_child(
|
||||
Shrinkable::new(
|
||||
100.0,
|
||||
self.create_branch_tooltip(
|
||||
&code_review_header_fields.diff_state_model,
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
left_section_compact.add_child(
|
||||
Container::new(CodeReviewView::render_diff_stats(
|
||||
&state.to_diff_stats(),
|
||||
appearance,
|
||||
))
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut right_subsection_compact = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if FeatureFlag::DiscardPerFileAndAllChanges.is_enabled() {
|
||||
right_subsection_compact.add_child(self.create_discard_button(
|
||||
state,
|
||||
&code_review_header_fields.diff_state_model,
|
||||
appearance,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
let has_no_changes = state.to_diff_stats().has_no_changes();
|
||||
|
||||
if FeatureFlag::DiffSetAsContext.is_enabled() && !has_no_changes {
|
||||
if FeatureFlag::FileAndDiffSetComments.is_enabled() {
|
||||
right_subsection_compact.add_child(self.render_header_dropdown_button(
|
||||
&code_review_header_fields.header_dropdown_button,
|
||||
&code_review_header_fields.header_menu,
|
||||
code_review_header_fields.header_menu_open,
|
||||
));
|
||||
} else {
|
||||
right_subsection_compact
|
||||
.add_child(self.render_add_diff_set_context_button(appearance));
|
||||
}
|
||||
}
|
||||
|
||||
if code_review_header_fields.is_in_split_pane {
|
||||
right_subsection_compact.add_child(self.render_maximize_pane_button(
|
||||
&code_review_header_fields.maximize_button,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
let right_section_compact = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(ChildView::new(&code_review_header_fields.diff_selector).finish())
|
||||
.with_child(Container::new(right_subsection_compact.finish()).finish());
|
||||
|
||||
Clipped::new(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Flex::column()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(Align::new(left_section_compact.finish()).left().finish())
|
||||
.with_child(
|
||||
Container::new(Align::new(right_section_compact.finish()).right().finish())
|
||||
.with_margin_top(8.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn create_branch_name_element(
|
||||
diff_state_model: &ModelHandle<DiffStateModel>,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let header_text = Self::get_header_text(diff_state_model, app);
|
||||
|
||||
Container::new(
|
||||
warpui::elements::Text::new_inline(
|
||||
header_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() + 2.,
|
||||
)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.with_color(theme.main_text_color(theme.background()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn create_branch_tooltip(
|
||||
&self,
|
||||
diff_state_model: &ModelHandle<DiffStateModel>,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let header_text = Self::get_header_text(diff_state_model, app);
|
||||
appearance.ui_builder().overlay_tool_tip_on_element(
|
||||
header_text,
|
||||
self.state_handles.branch_name_tooltip.clone(),
|
||||
Self::create_branch_name_element(diff_state_model, appearance, app),
|
||||
ParentAnchor::BottomLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
vec2f(0., 4.),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_discard_button(
|
||||
&self,
|
||||
state: &LoadedState,
|
||||
diff_state_model: &ModelHandle<DiffStateModel>,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let has_no_changes = state.to_diff_stats().has_no_changes();
|
||||
let git_operation_blocked = diff_state_model.as_ref(app).is_git_operation_blocked(app);
|
||||
let is_disabled = has_no_changes || git_operation_blocked;
|
||||
|
||||
let sub_text_color = theme.sub_text_color(theme.background());
|
||||
let mut button_builder = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.state_handles.discard_all_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles::default().set_padding(HEADER_BUTTON_PADDING))
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(sub_text_color.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_text_and_icon_label(
|
||||
TextAndIcon::new(
|
||||
TextAndIconAlignment::IconFirst,
|
||||
"Discard all".to_string(),
|
||||
Icon::ReverseLeft.to_warpui_icon(warp_core::ui::theme::Fill::Solid(
|
||||
sub_text_color.into_solid(),
|
||||
)),
|
||||
MainAxisSize::Min,
|
||||
MainAxisAlignment::SpaceBetween,
|
||||
vec2f(16., 16.),
|
||||
)
|
||||
.with_inner_padding(4.),
|
||||
);
|
||||
|
||||
if is_disabled {
|
||||
let disabled_styles = UiComponentStyles {
|
||||
font_color: Some(theme.disabled_text_color(theme.background()).into_solid()),
|
||||
..Default::default()
|
||||
};
|
||||
button_builder = button_builder.with_style(disabled_styles).with_cursor(None);
|
||||
}
|
||||
|
||||
let mut button_hoverable = button_builder.build();
|
||||
|
||||
if !is_disabled {
|
||||
button_hoverable = button_hoverable.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CodeReviewAction::ShowDiscardConfirmDialog(None));
|
||||
});
|
||||
}
|
||||
|
||||
let button_element = button_hoverable.finish();
|
||||
|
||||
if is_disabled {
|
||||
let tooltip_text = get_discard_button_disabled_tooltip(git_operation_blocked);
|
||||
Container::new(CodeReviewHeader::wrap_disabled_button_with_tooltip(
|
||||
button_element,
|
||||
tooltip_text,
|
||||
self.state_handles.discard_all_button.clone(),
|
||||
appearance,
|
||||
))
|
||||
.with_margin_left(4.)
|
||||
.finish()
|
||||
} else {
|
||||
Container::new(button_element).with_margin_left(4.).finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_disabled_button_with_tooltip(
|
||||
button_element: Box<dyn Element>,
|
||||
tooltip_text: String,
|
||||
mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
Hoverable::new(mouse_state, move |state| {
|
||||
let mut stack = Stack::new().with_child(button_element);
|
||||
if state.is_hovered() {
|
||||
let disabled_tooltip = ui_builder.tool_tip(tooltip_text).build().finish();
|
||||
let tooltip_offset = OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -8.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
);
|
||||
stack.add_positioned_overlay_child(disabled_tooltip, tooltip_offset);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.with_cursor(Cursor::Arrow)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub(super) fn render_maximize_pane_button(
|
||||
&self,
|
||||
maximize_button: &ViewHandle<ActionButton>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(ChildView::new(maximize_button).finish())
|
||||
.with_height(appearance.ui_font_size() + 10.)
|
||||
.with_width(appearance.ui_font_size() + 10.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.with_margin_right(6.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_add_diff_set_context_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
|
||||
let button = ui_builder
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.state_handles.add_diff_set_context_button.clone(),
|
||||
)
|
||||
.with_text_and_icon_label(TextAndIcon::new(
|
||||
TextAndIconAlignment::IconFirst,
|
||||
"",
|
||||
Icon::Paperclip.to_warpui_icon(warp_core::ui::theme::Fill::Solid(
|
||||
theme.main_text_color(theme.background()).into(),
|
||||
)),
|
||||
MainAxisSize::Min,
|
||||
MainAxisAlignment::SpaceBetween,
|
||||
vec2f(16., 16.),
|
||||
))
|
||||
// manual overrides so it matches the branch dropdown and discard all button
|
||||
.with_style(UiComponentStyles::default().set_padding(Coords {
|
||||
top: 6.,
|
||||
bottom: 6.,
|
||||
left: 6.,
|
||||
right: 6.,
|
||||
}))
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Add diff set as context".to_owned())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.with_tooltip_position(warpui::ui_components::button::ButtonTooltipPosition::AboveLeft)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CodeReviewAction::AddDiffSetAsContext(
|
||||
crate::code_review::DiffSetScope::All,
|
||||
));
|
||||
})
|
||||
.finish();
|
||||
|
||||
Container::new(button).with_margin_left(4.).finish()
|
||||
}
|
||||
|
||||
/// Renders the header dropdown trigger
|
||||
///
|
||||
/// This button dispatches a CodeReviewAction and, when the header menu is open, renders the
|
||||
/// attached menu in a Stack overlay positioned relative to the button (like other overflow
|
||||
/// buttons in the app, e.g. Drive's "create new" button).
|
||||
fn render_header_dropdown_button(
|
||||
&self,
|
||||
header_dropdown_button: &ViewHandle<ActionButton>,
|
||||
header_menu: &ViewHandle<Menu<CodeReviewAction>>,
|
||||
header_menu_open: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let button_container = Container::new(
|
||||
ConstrainedBox::new(ChildView::new(header_dropdown_button).finish())
|
||||
.with_height(warp_core::ui::icons::ICON_DIMENSIONS)
|
||||
.with_width(warp_core::ui::icons::ICON_DIMENSIONS)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(4.)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(button_container);
|
||||
|
||||
if header_menu_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(header_menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
}
|
||||
|
||||
fn get_header_text(diff_state_model: &ModelHandle<DiffStateModel>, app: &AppContext) -> String {
|
||||
let branch_name = diff_state_model.read(app, |model, _| model.get_current_branch_name());
|
||||
branch_name.unwrap_or("Reviewing open changes".to_string())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use warp_editor::model::CoreEditorModel;
|
||||
use warp_editor::render::model::{
|
||||
BlockItem, HitTestOptions, LineCount, Location, RenderLineLocation,
|
||||
};
|
||||
use warpui::{units::Pixels, AppContext, ViewContext};
|
||||
|
||||
use super::{CodeReviewView, CodeReviewViewState, FILE_HEADER_HEIGHT};
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CodeReviewVisibleAnchorForTest {
|
||||
pub file_path: PathBuf,
|
||||
pub line_number: usize,
|
||||
pub line_text: String,
|
||||
}
|
||||
|
||||
impl CodeReviewView {
|
||||
pub fn visible_anchor_for_test(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
) -> Option<CodeReviewVisibleAnchorForTest> {
|
||||
let CodeReviewViewState::Loaded(state) = self.state() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let file_index = self.viewported_list_state.get_scroll_index();
|
||||
let (_, file_state) = state.file_states.get_index(file_index)?;
|
||||
let editor_state = file_state.editor_state.as_ref()?;
|
||||
let scroll_offset = self.viewported_list_state.get_scroll_offset();
|
||||
let content_y = (scroll_offset - Pixels::new(FILE_HEADER_HEIGHT) + Pixels::new(2.0))
|
||||
.max(Pixels::zero());
|
||||
|
||||
let editor = editor_state.editor.as_ref(ctx).editor();
|
||||
let render_state_handle = editor.as_ref(ctx).model.as_ref(ctx).render_state().clone();
|
||||
let location = render_state_handle
|
||||
.as_ref(ctx)
|
||||
.render_coordinates_to_location(
|
||||
Pixels::new(10.0),
|
||||
content_y,
|
||||
&HitTestOptions {
|
||||
force_text_selection: true,
|
||||
},
|
||||
);
|
||||
let char_offset = match location {
|
||||
Location::Text { char_offset, .. } => char_offset,
|
||||
Location::Block { start_offset, .. } => start_offset,
|
||||
};
|
||||
let render_state = render_state_handle.as_ref(ctx);
|
||||
let line_number = render_state.offset_to_softwrap_point(char_offset).row() as usize + 1;
|
||||
let (start_offset, end_offset) =
|
||||
render_state.line_number_to_offset_range(LineCount::from(line_number));
|
||||
let line_text = editor
|
||||
.as_ref(ctx)
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.content()
|
||||
.as_ref(ctx)
|
||||
.text_in_range(start_offset..end_offset)
|
||||
.into_string();
|
||||
|
||||
Some(CodeReviewVisibleAnchorForTest {
|
||||
file_path: file_state.file_diff.file_path.clone(),
|
||||
line_number,
|
||||
line_text: line_text.trim_matches('\n').to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scroll_to_line_for_test(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
line_number: usize,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
let CodeReviewViewState::Loaded(state) = self.state() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(editor_index) = state
|
||||
.file_states
|
||||
.iter()
|
||||
.position(|(_, file_state)| file_state.file_diff.file_path == path)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(editor_state) = state
|
||||
.file_states
|
||||
.get_index(editor_index)
|
||||
.and_then(|(_, file_state)| file_state.editor_state.as_ref())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let editor = editor_state.editor().clone();
|
||||
let line_number = LineCount::from(line_number);
|
||||
let line = EditorLineLocation::Current {
|
||||
line_number,
|
||||
line_range: line_number..line_number + LineCount::from(1),
|
||||
};
|
||||
let (start_offset, end_offset) = editor
|
||||
.as_ref(ctx)
|
||||
.editor()
|
||||
.read(ctx, |code_editor_view, ctx| {
|
||||
code_editor_view.line_location_to_offsets(&line, ctx)
|
||||
});
|
||||
|
||||
if let Some((start_top_y, _end_bottom_y)) =
|
||||
self.get_match_character_bounds(editor_index, start_offset, end_offset, ctx)
|
||||
{
|
||||
self.viewported_list_state
|
||||
.scroll_to_with_offset(editor_index, Pixels::new(FILE_HEADER_HEIGHT) + start_top_y);
|
||||
self.horizontally_scroll_to_match(editor_index, start_offset, end_offset, ctx);
|
||||
|
||||
// Eagerly compute and store scroll context so it is available
|
||||
// before the next buffer edit (the debounce may not have fired yet).
|
||||
let context = self.compute_scroll_context_for_index(editor_index, &editor, ctx);
|
||||
if let Some(context) = context {
|
||||
self.viewported_list_state.set_scroll_context(Some(context));
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
} else {
|
||||
self.scroll_to_position(editor_index, start_offset, end_offset, 0.0, ctx);
|
||||
ctx.notify();
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls the code review to the header region of the given file.
|
||||
/// The header region is the area above the editor content (< FILE_HEADER_HEIGHT).
|
||||
pub fn scroll_to_header_for_test(&mut self, path: &Path, ctx: &mut ViewContext<Self>) -> bool {
|
||||
let CodeReviewViewState::Loaded(state) = self.state() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(editor_index) = state
|
||||
.file_states
|
||||
.iter()
|
||||
.position(|(_, file_state)| file_state.file_diff.file_path == path)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(editor_state) = state
|
||||
.file_states
|
||||
.get_index(editor_index)
|
||||
.and_then(|(_, file_state)| file_state.editor_state.as_ref())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let editor = editor_state.editor().clone();
|
||||
|
||||
// Scroll to 10px into the header (FILE_HEADER_HEIGHT is 41px)
|
||||
self.viewported_list_state
|
||||
.scroll_to_with_offset(editor_index, Pixels::new(10.0));
|
||||
|
||||
let context = self.compute_scroll_context_for_index(editor_index, &editor, ctx);
|
||||
if let Some(context) = context {
|
||||
self.viewported_list_state.set_scroll_context(Some(context));
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
/// Scrolls the code review past the end of editor content into the footer region.
|
||||
pub fn scroll_to_footer_for_test(&mut self, path: &Path, ctx: &mut ViewContext<Self>) -> bool {
|
||||
let CodeReviewViewState::Loaded(state) = self.state() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(editor_index) = state
|
||||
.file_states
|
||||
.iter()
|
||||
.position(|(_, file_state)| file_state.file_diff.file_path == path)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(editor_state) = state
|
||||
.file_states
|
||||
.get_index(editor_index)
|
||||
.and_then(|(_, file_state)| file_state.editor_state.as_ref())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let editor = editor_state.editor().clone();
|
||||
|
||||
let content_height = editor
|
||||
.as_ref(ctx)
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.render_state()
|
||||
.as_ref(ctx)
|
||||
.height();
|
||||
|
||||
// Scroll 5px past the editor content into the footer/margin area
|
||||
self.viewported_list_state.scroll_to_with_offset(
|
||||
editor_index,
|
||||
Pixels::new(FILE_HEADER_HEIGHT) + content_height + Pixels::new(5.0),
|
||||
);
|
||||
|
||||
let context = self.compute_scroll_context_for_index(editor_index, &editor, ctx);
|
||||
if let Some(context) = context {
|
||||
self.viewported_list_state.set_scroll_context(Some(context));
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
/// Scrolls the code review to a deleted (temporary) block near the given current buffer line.
|
||||
/// Scans forward from the y-offset of `near_line` to find the first TemporaryBlock.
|
||||
pub fn scroll_to_deleted_range_for_test(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
near_line: usize,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
let CodeReviewViewState::Loaded(state) = self.state() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(editor_index) = state
|
||||
.file_states
|
||||
.iter()
|
||||
.position(|(_, file_state)| file_state.file_diff.file_path == path)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(editor_state) = state
|
||||
.file_states
|
||||
.get_index(editor_index)
|
||||
.and_then(|(_, file_state)| file_state.editor_state.as_ref())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let editor = editor_state.editor().clone();
|
||||
let editor_model_handle = editor.as_ref(ctx).editor().as_ref(ctx).model.clone();
|
||||
|
||||
// Phase 1: Find the y-offset of a temporary block near the given line.
|
||||
let found_offset = {
|
||||
let editor_model = editor_model_handle.as_ref(ctx);
|
||||
let render_state = editor_model.render_state().as_ref(ctx);
|
||||
|
||||
// Get approximate content-relative y position of near_line.
|
||||
// vertical_offset_at_render_location internally borrows and releases
|
||||
// the content RefCell, so calling content() afterwards is safe.
|
||||
let line_offset = render_state
|
||||
.vertical_offset_at_render_location(RenderLineLocation::Current(LineCount::from(
|
||||
near_line,
|
||||
)))
|
||||
.unwrap_or(Pixels::zero());
|
||||
|
||||
let content = render_state.content();
|
||||
let mut y = line_offset.as_f32() as f64;
|
||||
let scan_limit = y + 2000.0;
|
||||
let mut found = None;
|
||||
|
||||
while y < scan_limit {
|
||||
let Some(block) = content.block_at_height(y) else {
|
||||
break;
|
||||
};
|
||||
if matches!(block.item, BlockItem::TemporaryBlock { .. }) {
|
||||
found = Some(block.start_y_offset + Pixels::new(5.0));
|
||||
break;
|
||||
}
|
||||
// Advance past this block
|
||||
let block_end = (block.start_y_offset + block.item.height()).as_f32() as f64;
|
||||
y = if block_end <= y {
|
||||
y + 1.0
|
||||
} else {
|
||||
block_end + 0.5
|
||||
};
|
||||
}
|
||||
|
||||
found
|
||||
};
|
||||
|
||||
let Some(offset_in_editor) = found_offset else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.viewported_list_state.scroll_to_with_offset(
|
||||
editor_index,
|
||||
Pixels::new(FILE_HEADER_HEIGHT) + offset_in_editor,
|
||||
);
|
||||
|
||||
let context = self.compute_scroll_context_for_index(editor_index, &editor, ctx);
|
||||
if let Some(context) = context {
|
||||
self.viewported_list_state.set_scroll_context(Some(context));
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns a string describing which scroll region the current scroll position
|
||||
/// is in: "header", "current_line", "removed_line", "footer", or "unknown".
|
||||
pub fn scroll_region_for_test(&self, ctx: &AppContext) -> String {
|
||||
let file_index = self.viewported_list_state.get_scroll_index();
|
||||
let scroll_offset = self.viewported_list_state.get_scroll_offset();
|
||||
let file_header_height = Pixels::new(FILE_HEADER_HEIGHT);
|
||||
|
||||
if scroll_offset < file_header_height {
|
||||
return "header".to_string();
|
||||
}
|
||||
|
||||
let CodeReviewViewState::Loaded(state) = self.state() else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
|
||||
let Some((_, file_state)) = state.file_states.get_index(file_index) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
|
||||
let Some(editor_state) = &file_state.editor_state else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
|
||||
let editor_model = editor_state
|
||||
.editor
|
||||
.as_ref(ctx)
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.model
|
||||
.as_ref(ctx);
|
||||
let render_state = editor_model.render_state().as_ref(ctx);
|
||||
let content_height = render_state.height();
|
||||
let scroll_in_editor = scroll_offset - file_header_height;
|
||||
|
||||
if scroll_in_editor >= content_height {
|
||||
return "footer".to_string();
|
||||
}
|
||||
|
||||
let content = render_state.content();
|
||||
if let Some(block) = content.block_at_height(scroll_in_editor.as_f32() as f64) {
|
||||
match block.item {
|
||||
BlockItem::TemporaryBlock { .. } => return "removed_line".to_string(),
|
||||
_ => return "current_line".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
pub fn all_editors_loaded_for_test(&self) -> bool {
|
||||
self.all_editors_loaded()
|
||||
}
|
||||
|
||||
pub fn line_text_for_test(
|
||||
&self,
|
||||
path: &Path,
|
||||
line_number: usize,
|
||||
ctx: &AppContext,
|
||||
) -> Option<String> {
|
||||
let editor = if let Some(editor) = self.editor_for_path(path, ctx) {
|
||||
editor
|
||||
} else {
|
||||
let absolute_path = self.repo_path()?.join(path);
|
||||
self.editor_for_path(&absolute_path, ctx)?
|
||||
};
|
||||
let text = editor
|
||||
.as_ref(ctx)
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.text(ctx)
|
||||
.into_string();
|
||||
let line_index = line_number.checked_sub(1)?;
|
||||
text.lines().nth(line_index).map(ToOwned::to_owned)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
use super::*;
|
||||
use crate::ai::persisted_workspace::PersistedWorkspace;
|
||||
use crate::ai::request_usage_model::AIRequestUsageModel;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use crate::code::local_code_editor::LocalCodeEditorView;
|
||||
use crate::code_review::comments::{
|
||||
attach_pending_imported_comments, AttachedReviewComment, AttachedReviewCommentTarget,
|
||||
CommentId, CommentOrigin, LineDiffContent, PendingImportedReviewComment,
|
||||
PendingImportedReviewCommentTarget,
|
||||
};
|
||||
use crate::code_review::diff_size_limits::DiffSize;
|
||||
use crate::code_review::diff_state::{DiffStateModel, FileDiff, GitFileStatus};
|
||||
use crate::code_review::editor_state::CodeReviewEditorState;
|
||||
use crate::code_review::GlobalCodeReviewModel;
|
||||
use crate::pane_group::WorkingDirectoriesModel;
|
||||
use crate::server::server_api::{
|
||||
team::MockTeamClient, workspace::MockWorkspaceClient, ServerApiProvider,
|
||||
};
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::local_shell::LocalShellState;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::vim_registers::VimRegisters;
|
||||
use crate::workspace::sync_inputs::SyncedInputState;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::NotebookKeybindings;
|
||||
use ai::agent::action::InsertReviewComment;
|
||||
use chrono::Local;
|
||||
use lsp::LspManagerModel;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::content::buffer::InitialBufferState;
|
||||
use warp_editor::render::element::VerticalExpansionBehavior;
|
||||
use warp_editor::render::model::LineCount;
|
||||
use warpui::elements::{Empty, MouseStateHandle};
|
||||
use warpui::platform::WindowStyle;
|
||||
use warpui::{App, ViewHandle};
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestView;
|
||||
|
||||
impl warpui::Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl warpui::View for TestView {
|
||||
fn render(&self, _: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
/// Initialize required singletons for testing
|
||||
fn initialize_test_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| SyncedInputState::mock());
|
||||
app.add_singleton_model(|_| VimRegisters::new());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(|_| LspManagerModel::new());
|
||||
app.add_singleton_model(|_| LocalShellState::NotLoaded);
|
||||
app.add_singleton_model(PersistedWorkspace::new_for_test);
|
||||
app.add_singleton_model(|_| GlobalCodeReviewModel);
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Add mocks required by rich text editor (used in the CommentEditor)
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|ctx| {
|
||||
AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx)
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates a LocalCodeEditorView with the given content
|
||||
fn create_editor_with_content(app: &mut App, content: &str) -> ViewHandle<LocalCodeEditorView> {
|
||||
let content = content.to_string();
|
||||
let (_, local_editor) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
let code_editor_view = ctx.add_typed_action_view(|ctx| {
|
||||
CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
code_editor_view.update(ctx, |editor, ctx| {
|
||||
editor.reset(InitialBufferState::plain_text(&content), ctx);
|
||||
});
|
||||
|
||||
LocalCodeEditorView::new(code_editor_view, None, false, None, ctx)
|
||||
});
|
||||
|
||||
local_editor
|
||||
}
|
||||
|
||||
/// Creates a LocalCodeEditorView with base and current content for diff testing
|
||||
#[allow(dead_code)]
|
||||
fn create_editor_with_diff(
|
||||
app: &mut App,
|
||||
base_content: &str,
|
||||
current_content: &str,
|
||||
) -> ViewHandle<LocalCodeEditorView> {
|
||||
let current = current_content.to_string();
|
||||
let base = base_content.to_string();
|
||||
let (_, local_editor) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
let code_editor_view = ctx.add_typed_action_view(|ctx| {
|
||||
CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
code_editor_view.update(ctx, |editor, ctx| {
|
||||
editor.reset(InitialBufferState::plain_text(¤t), ctx);
|
||||
editor.set_base(&base, true, ctx);
|
||||
});
|
||||
|
||||
LocalCodeEditorView::new(code_editor_view, None, false, None, ctx)
|
||||
});
|
||||
|
||||
local_editor
|
||||
}
|
||||
|
||||
/// Creates an attached review comment with a Line target
|
||||
fn create_line_comment(
|
||||
file_path: impl Into<PathBuf>,
|
||||
line_number: usize,
|
||||
line_text: &str,
|
||||
comment_content: &str,
|
||||
) -> AttachedReviewComment {
|
||||
let line_count = LineCount::from(line_number);
|
||||
AttachedReviewComment {
|
||||
id: CommentId::new(),
|
||||
content: comment_content.to_string(),
|
||||
target: AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path: file_path.into(),
|
||||
line: EditorLineLocation::Current {
|
||||
line_number: line_count,
|
||||
line_range: line_count..LineCount::from(line_number + 1),
|
||||
},
|
||||
content: LineDiffContent {
|
||||
content: format!("+{line_text}"),
|
||||
lines_added: LineCount::from(1),
|
||||
lines_removed: LineCount::from(0),
|
||||
},
|
||||
},
|
||||
last_update_time: Local::now(),
|
||||
base: None,
|
||||
head: None,
|
||||
outdated: false,
|
||||
origin: CommentOrigin::Native,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an attached review comment with a File target
|
||||
fn create_file_comment(
|
||||
file_path: impl Into<PathBuf>,
|
||||
comment_content: &str,
|
||||
) -> AttachedReviewComment {
|
||||
AttachedReviewComment {
|
||||
id: CommentId::new(),
|
||||
content: comment_content.to_string(),
|
||||
target: AttachedReviewCommentTarget::File {
|
||||
absolute_file_path: file_path.into(),
|
||||
},
|
||||
last_update_time: Local::now(),
|
||||
base: None,
|
||||
head: None,
|
||||
outdated: false,
|
||||
origin: CommentOrigin::Native,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an attached review comment with a General target
|
||||
fn create_general_comment(comment_content: &str) -> AttachedReviewComment {
|
||||
AttachedReviewComment {
|
||||
id: CommentId::new(),
|
||||
content: comment_content.to_string(),
|
||||
target: AttachedReviewCommentTarget::General,
|
||||
last_update_time: Local::now(),
|
||||
base: None,
|
||||
head: None,
|
||||
outdated: false,
|
||||
origin: CommentOrigin::Native,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_pending_comment(
|
||||
id: &str,
|
||||
author: &str,
|
||||
body: &str,
|
||||
parent_id: Option<&str>,
|
||||
timestamp: &str,
|
||||
target: PendingImportedReviewCommentTarget,
|
||||
) -> PendingImportedReviewComment {
|
||||
let mut pending = PendingImportedReviewComment::try_from(InsertReviewComment {
|
||||
comment_id: id.to_string(),
|
||||
author: author.to_string(),
|
||||
comment_body: body.to_string(),
|
||||
parent_comment_id: parent_id.map(|s| s.to_string()),
|
||||
last_modified_timestamp: timestamp.to_string(),
|
||||
comment_location: None,
|
||||
html_url: None,
|
||||
})
|
||||
.expect("valid pending import conversion");
|
||||
|
||||
// Override the location target since we intentionally use `comment_location: None` above.
|
||||
pending.target = target;
|
||||
|
||||
pending
|
||||
}
|
||||
|
||||
use crate::view_components::action_button::{ActionButton, NakedTheme};
|
||||
|
||||
/// Test context that holds all common test state
|
||||
struct TestContext {
|
||||
repo_path: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
window_id: warpui::WindowId,
|
||||
state: LoadedState,
|
||||
code_review_view: ViewHandle<CodeReviewView>,
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
/// Initialize common test state with a single file editor
|
||||
fn new(app: &mut App, file_path: PathBuf, editor_content: &str) -> Self {
|
||||
initialize_test_app(app);
|
||||
|
||||
let editor = create_editor_with_content(app, editor_content);
|
||||
let repo_path = PathBuf::from("/repo");
|
||||
|
||||
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
let state = create_loaded_state_with_editors(app, window_id, vec![(file_path, editor)]);
|
||||
|
||||
let diff_state_model = app.add_model(|ctx| DiffStateModel::new(None, ctx));
|
||||
|
||||
let working_directories_model = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
let code_review_comment_batch =
|
||||
working_directories_model.update(app, |working_directories, ctx| {
|
||||
working_directories.get_or_create_code_review_comments(repo_path.as_path(), ctx)
|
||||
});
|
||||
|
||||
let code_review_view = app.add_view(window_id, |ctx| {
|
||||
CodeReviewView::new(
|
||||
Some(repo_path.clone()),
|
||||
diff_state_model,
|
||||
code_review_comment_batch,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
repo_path,
|
||||
window_id,
|
||||
state,
|
||||
code_review_view,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a minimal LoadedState with file states containing editors.
|
||||
/// Must be called within an App context.
|
||||
fn create_loaded_state_with_editors(
|
||||
app: &mut App,
|
||||
window_id: warpui::WindowId,
|
||||
file_editors: Vec<(PathBuf, ViewHandle<LocalCodeEditorView>)>,
|
||||
) -> LoadedState {
|
||||
let file_states = file_editors
|
||||
.into_iter()
|
||||
.map(|(file_path, editor)| {
|
||||
let chevron_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme));
|
||||
let open_in_tab_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme));
|
||||
let discard_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme));
|
||||
let add_context_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme));
|
||||
let copy_path_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme));
|
||||
|
||||
let state = FileState {
|
||||
file_diff: FileDiff {
|
||||
file_path: file_path.clone(),
|
||||
status: GitFileStatus::Modified,
|
||||
hunks: Arc::new(vec![]),
|
||||
is_binary: false,
|
||||
is_autogenerated: false,
|
||||
max_line_number: 0,
|
||||
has_hidden_bidi_chars: false,
|
||||
size: DiffSize::Normal,
|
||||
},
|
||||
editor_state: Some(CodeReviewEditorState::new_loaded(editor)),
|
||||
is_expanded: true,
|
||||
sidebar_mouse_state: MouseStateHandle::default(),
|
||||
header_mouse_state: MouseStateHandle::default(),
|
||||
chevron_button,
|
||||
open_in_tab_button,
|
||||
discard_button,
|
||||
add_context_button,
|
||||
copy_path_button,
|
||||
};
|
||||
(file_path, state)
|
||||
})
|
||||
.collect();
|
||||
|
||||
LoadedState {
|
||||
file_states,
|
||||
total_additions: 0,
|
||||
total_deletions: 0,
|
||||
files_changed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_empty_input() {
|
||||
App::test((), |mut app| async move {
|
||||
let ctx = TestContext::new(
|
||||
&mut app,
|
||||
PathBuf::from("test.txt"),
|
||||
"line 1\nline 2\nline 3",
|
||||
);
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: fallbacks,
|
||||
} = CodeReviewView::relocate_comments(vec![], &ctx.state, &ctx.repo_path, view_ctx);
|
||||
|
||||
assert!(
|
||||
relocated.is_empty(),
|
||||
"Empty input should return empty output"
|
||||
);
|
||||
assert_eq!(fallbacks, 0, "Empty input should have no fallbacks");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_general_comment_passes_through() {
|
||||
App::test((), |mut app| async move {
|
||||
let ctx = TestContext::new(
|
||||
&mut app,
|
||||
PathBuf::from("test.txt"),
|
||||
"line 1\nline 2\nline 3",
|
||||
);
|
||||
|
||||
let general_comment = create_general_comment("This is a general comment");
|
||||
let original_id = general_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: fallbacks,
|
||||
} = CodeReviewView::relocate_comments(
|
||||
vec![general_comment],
|
||||
&ctx.state,
|
||||
&ctx.repo_path,
|
||||
view_ctx,
|
||||
);
|
||||
|
||||
assert_eq!(relocated.len(), 1, "Should return the comment");
|
||||
assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");
|
||||
assert!(
|
||||
matches!(relocated[0].target, AttachedReviewCommentTarget::General),
|
||||
"General comment should remain General"
|
||||
);
|
||||
assert_eq!(
|
||||
fallbacks, 0,
|
||||
"General comments should not count as fallbacks"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_file_comment_passes_through() {
|
||||
App::test((), |mut app| async move {
|
||||
let file_path = PathBuf::from("test.txt");
|
||||
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
|
||||
|
||||
let file_comment =
|
||||
create_file_comment(ctx.repo_path.join(&file_path), "This is a file comment");
|
||||
let original_id = file_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: fallbacks,
|
||||
} = CodeReviewView::relocate_comments(
|
||||
vec![file_comment],
|
||||
&ctx.state,
|
||||
&ctx.repo_path,
|
||||
view_ctx,
|
||||
);
|
||||
|
||||
assert_eq!(relocated.len(), 1, "Should return the comment");
|
||||
assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");
|
||||
assert!(
|
||||
matches!(
|
||||
relocated[0].target,
|
||||
AttachedReviewCommentTarget::File { .. }
|
||||
),
|
||||
"File comment should remain File"
|
||||
);
|
||||
assert_eq!(fallbacks, 0, "File comments should not count as fallbacks");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_line_comment_no_matching_editor_marked_outdated() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag_override = FeatureFlag::PRCommentsSlashCommand.override_enabled(true);
|
||||
|
||||
// Editor is for "test.txt" but comment is for "other.txt"
|
||||
let ctx = TestContext::new(
|
||||
&mut app,
|
||||
PathBuf::from("test.txt"),
|
||||
"line 1\nline 2\nline 3",
|
||||
);
|
||||
|
||||
let line_comment =
|
||||
create_line_comment("/repo/other.txt", 1, "line 1", "Comment on other file");
|
||||
let original_id = line_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: fallbacks,
|
||||
} = CodeReviewView::relocate_comments(
|
||||
vec![line_comment],
|
||||
&ctx.state,
|
||||
&ctx.repo_path,
|
||||
view_ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
relocated.len(),
|
||||
1,
|
||||
"Comment with no matching editor should be kept but marked outdated"
|
||||
);
|
||||
assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");
|
||||
assert!(
|
||||
relocated[0].outdated,
|
||||
"Comment should be marked as outdated"
|
||||
);
|
||||
assert_eq!(
|
||||
fallbacks, 0,
|
||||
"Outdated comments should not count as fallbacks"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_multiple_comment_types() {
|
||||
App::test((), |mut app| async move {
|
||||
let file_path = PathBuf::from("test.txt");
|
||||
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
|
||||
|
||||
let general_comment = create_general_comment("General comment");
|
||||
let file_comment = create_file_comment(ctx.repo_path.join(&file_path), "File comment");
|
||||
let line_comment = create_line_comment("/repo/test.txt", 1, "line 1", "Line comment");
|
||||
|
||||
let general_id = general_comment.id;
|
||||
let file_id = file_comment.id;
|
||||
let line_id = line_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let comments = vec![general_comment, file_comment, line_comment];
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: _,
|
||||
} = CodeReviewView::relocate_comments(comments, &ctx.state, &ctx.repo_path, view_ctx);
|
||||
|
||||
assert_eq!(
|
||||
relocated.len(),
|
||||
3,
|
||||
"Should return all comments (general, file, and line)"
|
||||
);
|
||||
|
||||
// Find each comment by ID
|
||||
let relocated_general = relocated.iter().find(|c| c.id == general_id).unwrap();
|
||||
let relocated_file = relocated.iter().find(|c| c.id == file_id).unwrap();
|
||||
let relocated_line = relocated.iter().find(|c| c.id == line_id).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
relocated_general.target,
|
||||
AttachedReviewCommentTarget::General
|
||||
));
|
||||
assert!(matches!(
|
||||
relocated_file.target,
|
||||
AttachedReviewCommentTarget::File { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
relocated_line.target,
|
||||
AttachedReviewCommentTarget::Line { .. }
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_line_comment_with_absolute_path() {
|
||||
App::test((), |mut app| async move {
|
||||
let file_path = PathBuf::from("test.txt");
|
||||
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
|
||||
|
||||
// Comment with absolute path matching the editor's file
|
||||
let line_comment = create_line_comment("/repo/test.txt", 1, "line 1", "Line comment");
|
||||
let original_id = line_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: _,
|
||||
} = CodeReviewView::relocate_comments(
|
||||
vec![line_comment],
|
||||
&ctx.state,
|
||||
&ctx.repo_path,
|
||||
view_ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
relocated.len(),
|
||||
1,
|
||||
"Comment with absolute path should be relocated"
|
||||
);
|
||||
assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");
|
||||
assert!(
|
||||
matches!(
|
||||
relocated[0].target,
|
||||
AttachedReviewCommentTarget::Line { .. }
|
||||
),
|
||||
"Line comment should remain Line"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attach_pending_imported_comment_formats_body_and_uses_absolute_path() {
|
||||
let repo_path = PathBuf::from("/repo");
|
||||
|
||||
let pending = make_pending_comment(
|
||||
"1",
|
||||
"alice",
|
||||
"Hello world",
|
||||
None,
|
||||
"2024-01-01T00:00:00Z",
|
||||
PendingImportedReviewCommentTarget::Line {
|
||||
relative_file_path: PathBuf::from("test.txt"),
|
||||
line: EditorLineLocation::Current {
|
||||
line_number: LineCount::from(1),
|
||||
line_range: LineCount::from(1)..LineCount::from(2),
|
||||
},
|
||||
diff_content: LineDiffContent {
|
||||
content: "+line 1".to_string(),
|
||||
lines_added: LineCount::from(1),
|
||||
lines_removed: LineCount::from(0),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let attached = attach_pending_imported_comments(vec![pending], repo_path.as_path());
|
||||
|
||||
assert_eq!(attached.len(), 1);
|
||||
assert_eq!(attached[0].content, "**@alice**:\nHello world");
|
||||
|
||||
match &attached[0].target {
|
||||
AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path, ..
|
||||
} => {
|
||||
assert_eq!(*absolute_file_path, repo_path.join("test.txt"));
|
||||
}
|
||||
_ => panic!("expected line comment target"),
|
||||
}
|
||||
|
||||
match &attached[0].origin {
|
||||
CommentOrigin::ImportedFromGitHub(details) => {
|
||||
assert_eq!(details.author, "alice");
|
||||
assert_eq!(details.github_comment_id, "1");
|
||||
assert!(details.github_parent_id.is_none());
|
||||
}
|
||||
_ => panic!("expected imported origin"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attach_pending_imported_thread_flattens_depth_first_sorted_by_timestamp() {
|
||||
let repo_path = PathBuf::from("/repo");
|
||||
|
||||
let root = make_pending_comment(
|
||||
"1",
|
||||
"alice",
|
||||
"Root",
|
||||
None,
|
||||
"2024-01-01T00:00:00Z",
|
||||
PendingImportedReviewCommentTarget::Line {
|
||||
relative_file_path: PathBuf::from("test.txt"),
|
||||
line: EditorLineLocation::Current {
|
||||
line_number: LineCount::from(1),
|
||||
line_range: LineCount::from(1)..LineCount::from(2),
|
||||
},
|
||||
diff_content: LineDiffContent {
|
||||
content: "+line 1".to_string(),
|
||||
lines_added: LineCount::from(1),
|
||||
lines_removed: LineCount::from(0),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Earlier reply to the root.
|
||||
let reply_early = make_pending_comment(
|
||||
"4",
|
||||
"dana",
|
||||
"Reply early",
|
||||
Some("1"),
|
||||
"2024-01-01T00:30:00Z",
|
||||
PendingImportedReviewCommentTarget::General,
|
||||
);
|
||||
|
||||
// Later reply to the root.
|
||||
let reply_late = make_pending_comment(
|
||||
"2",
|
||||
"bob",
|
||||
"Reply later",
|
||||
Some("1"),
|
||||
"2024-01-01T01:00:00Z",
|
||||
PendingImportedReviewCommentTarget::General,
|
||||
);
|
||||
|
||||
// Reply to the later reply.
|
||||
let reply_nested = make_pending_comment(
|
||||
"3",
|
||||
"charlie",
|
||||
"Nested reply",
|
||||
Some("2"),
|
||||
"2024-01-01T02:00:00Z",
|
||||
PendingImportedReviewCommentTarget::General,
|
||||
);
|
||||
|
||||
let latest_timestamp = reply_nested.last_update_time;
|
||||
|
||||
let attached = attach_pending_imported_comments(
|
||||
vec![reply_late, root, reply_nested, reply_early],
|
||||
repo_path.as_path(),
|
||||
);
|
||||
|
||||
assert_eq!(attached.len(), 1);
|
||||
assert_eq!(
|
||||
attached[0].content,
|
||||
"**@alice**:\nRoot\n---\n**@dana**:\nReply early\n---\n**@bob**:\nReply later\n---\n**@charlie**:\nNested reply"
|
||||
);
|
||||
assert_eq!(attached[0].last_update_time, latest_timestamp);
|
||||
|
||||
match &attached[0].target {
|
||||
AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path, ..
|
||||
} => {
|
||||
assert_eq!(*absolute_file_path, repo_path.join("test.txt"));
|
||||
}
|
||||
_ => panic!("expected root line target to be preserved"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_file_comment_no_matching_editor_marked_outdated() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag_override = FeatureFlag::PRCommentsSlashCommand.override_enabled(true);
|
||||
|
||||
// Editor is for "test.txt" but comment is for "other.txt"
|
||||
let ctx = TestContext::new(
|
||||
&mut app,
|
||||
PathBuf::from("test.txt"),
|
||||
"line 1\nline 2\nline 3",
|
||||
);
|
||||
|
||||
let file_comment = create_file_comment("/repo/other.txt", "Comment on other file");
|
||||
let original_id = file_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: fallbacks,
|
||||
} = CodeReviewView::relocate_comments(
|
||||
vec![file_comment],
|
||||
&ctx.state,
|
||||
&ctx.repo_path,
|
||||
view_ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
relocated.len(),
|
||||
1,
|
||||
"File comment with no matching editor should be kept but marked outdated"
|
||||
);
|
||||
assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");
|
||||
assert!(
|
||||
relocated[0].outdated,
|
||||
"Comment should be marked as outdated"
|
||||
);
|
||||
assert_eq!(
|
||||
fallbacks, 0,
|
||||
"Outdated file comments should not count as fallbacks"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relocate_comments_line_removed_marked_outdated() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag_override = FeatureFlag::PRCommentsSlashCommand.override_enabled(true);
|
||||
|
||||
// Editor has "line 1\nline 3" (line 2 was removed)
|
||||
// Comment was attached to "line 2" which no longer exists
|
||||
let file_path = PathBuf::from("test.txt");
|
||||
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 3");
|
||||
|
||||
// Create a comment that was attached to "line 2" at line index 1
|
||||
let line_comment =
|
||||
create_line_comment("/repo/test.txt", 1, "line 2", "Comment on removed line");
|
||||
let original_id = line_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: fallbacks,
|
||||
} = CodeReviewView::relocate_comments(
|
||||
vec![line_comment],
|
||||
&ctx.state,
|
||||
&ctx.repo_path,
|
||||
view_ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
relocated.len(),
|
||||
1,
|
||||
"Comment should be kept even when line content is removed"
|
||||
);
|
||||
assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");
|
||||
assert!(
|
||||
relocated[0].outdated,
|
||||
"Comment should be marked as outdated when line content cannot be found"
|
||||
);
|
||||
assert_eq!(
|
||||
fallbacks, 1,
|
||||
"Should count as a fallback when line content cannot be matched"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setup_dropdown_with_branches_includes_all_items() {
|
||||
App::test((), |mut app| async move {
|
||||
let ctx = TestContext::new(
|
||||
&mut app,
|
||||
PathBuf::from("test.txt"),
|
||||
"line 1\nline 2\nline 3",
|
||||
);
|
||||
|
||||
// Populate branches and compute targets via the selector's build method.
|
||||
let target_count = ctx.code_review_view.update(&mut app, |view, view_ctx| {
|
||||
if let Some(repo) = view.active_repo.as_mut() {
|
||||
repo.available_branches = vec![
|
||||
("main".to_string(), true),
|
||||
("feature-1".to_string(), false),
|
||||
("feature-2".to_string(), false),
|
||||
];
|
||||
}
|
||||
view.build_diff_targets(view_ctx).len()
|
||||
});
|
||||
|
||||
// Verify the selector surfaces all expected items:
|
||||
// 1. "Uncommitted changes" (always first)
|
||||
// 2. "main" (main branch)
|
||||
// 3. "feature-1"
|
||||
// 4. "feature-2"
|
||||
assert_eq!(
|
||||
target_count, 4,
|
||||
"Diff selector should have 4 targets: Uncommitted changes + main + 2 feature branches"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setup_dropdown_without_branches_only_has_uncommitted_changes() {
|
||||
App::test((), |mut app| async move {
|
||||
let ctx = TestContext::new(
|
||||
&mut app,
|
||||
PathBuf::from("test.txt"),
|
||||
"line 1\nline 2\nline 3",
|
||||
);
|
||||
|
||||
// Ensure branches are empty (simulates the bug state) and count targets.
|
||||
let target_count = ctx.code_review_view.update(&mut app, |view, view_ctx| {
|
||||
if let Some(repo) = view.active_repo.as_mut() {
|
||||
repo.available_branches = vec![];
|
||||
}
|
||||
view.build_diff_targets(view_ctx).len()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
target_count, 1,
|
||||
"Diff selector should only have 'Uncommitted changes' when no branches are available"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_on_close_then_on_open_reinitializes_repo_state() {
|
||||
App::test((), |mut app| async move {
|
||||
let ctx = TestContext::new(
|
||||
&mut app,
|
||||
PathBuf::from("test.txt"),
|
||||
"line 1\nline 2\nline 3",
|
||||
);
|
||||
let repo_path = ctx.repo_path.clone();
|
||||
|
||||
// Populate branches to simulate a working state
|
||||
let target_count_before = ctx.code_review_view.update(&mut app, |view, view_ctx| {
|
||||
if let Some(repo) = view.active_repo.as_mut() {
|
||||
repo.available_branches =
|
||||
vec![("main".to_string(), true), ("feature-1".to_string(), false)];
|
||||
}
|
||||
view.build_diff_targets(view_ctx).len()
|
||||
});
|
||||
assert_eq!(target_count_before, 3, "Should have 3 targets before close");
|
||||
|
||||
// Close the view
|
||||
ctx.code_review_view.update(&mut app, |view, view_ctx| {
|
||||
view.on_close(view_ctx);
|
||||
assert!(!view.is_open, "View should be closed after on_close");
|
||||
});
|
||||
|
||||
// Re-open the view
|
||||
ctx.code_review_view.update(&mut app, |view, view_ctx| {
|
||||
view.on_open(Some(repo_path.clone()), view_ctx);
|
||||
|
||||
assert!(view.is_open, "View should be open after on_open");
|
||||
assert_eq!(
|
||||
view.repo_path(),
|
||||
Some(&repo_path),
|
||||
"Repo path should be set after on_open"
|
||||
);
|
||||
|
||||
// available_branches should be empty after on_open resets the repo state,
|
||||
// because update_current_repo creates a fresh RepositoryState.
|
||||
// The async fetch_branches_and_rebuild_diff_selector has been initiated
|
||||
// but hasn't completed yet (git command will fail in test env).
|
||||
let branches_count = view
|
||||
.active_repo
|
||||
.as_ref()
|
||||
.map(|repo| repo.available_branches.len())
|
||||
.unwrap_or(0);
|
||||
assert_eq!(
|
||||
branches_count, 0,
|
||||
"Branches should be empty immediately after on_open (async fetch pending)"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_edit_comment_scrolls_with_buffer() {
|
||||
App::test((), |mut app| async move {
|
||||
let file_path = PathBuf::from("test.txt");
|
||||
let content = (0..100)
|
||||
.map(|i| format!("line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ctx = TestContext::new(&mut app, file_path.clone(), &content);
|
||||
|
||||
// Create a line comment targeting this file
|
||||
let line_comment = create_line_comment("/repo/test.txt", 5, "line 5", "Review comment");
|
||||
let comment_id = line_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |view, view_ctx| {
|
||||
// Inject the loaded state into the view's active repo
|
||||
if let Some(repo) = view.active_repo.as_mut() {
|
||||
repo.state = CodeReviewViewState::Loaded(ctx.state);
|
||||
}
|
||||
|
||||
// Add the comment to the active comment model so get_comment_by_id can find it
|
||||
if let Some(model) = view.active_comment_model.clone() {
|
||||
model.update(view_ctx, |batch, ctx| {
|
||||
batch.upsert_comment(line_comment, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Record scroll offset before the edit-comment scroll
|
||||
let offset_before = view.viewported_list_state.get_scroll_offset();
|
||||
|
||||
// Call handle_edit_comment — should call scroll_to_line with COMMENT_EDITOR_SCROLL_BUFFER
|
||||
view.handle_edit_comment(&comment_id, view_ctx);
|
||||
|
||||
// handle_edit_comment scrolls to the comment line. The scroll offset should
|
||||
// include COMMENT_EDITOR_SCROLL_BUFFER (200px) to account for the comment
|
||||
// editor that opens below the line.
|
||||
// Before the buffer fix, scroll_to_line passed buffer=0.0, so the offset
|
||||
// would be smaller. After the fix, it passes COMMENT_EDITOR_SCROLL_BUFFER.
|
||||
let offset_after = view.viewported_list_state.get_scroll_offset();
|
||||
let scroll_delta = offset_after - offset_before;
|
||||
|
||||
// The scroll delta should include the COMMENT_EDITOR_SCROLL_BUFFER.
|
||||
// Without the buffer fix, scroll_delta would be smaller by 200px.
|
||||
assert!(
|
||||
scroll_delta >= Pixels::new(COMMENT_EDITOR_SCROLL_BUFFER),
|
||||
"Scroll delta ({scroll_delta:?}) should be >= COMMENT_EDITOR_SCROLL_BUFFER ({COMMENT_EDITOR_SCROLL_BUFFER}px) to account for the comment editor"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_comments_not_marked_outdated() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag_override = FeatureFlag::PRCommentsSlashCommand.override_enabled(true);
|
||||
|
||||
let file_path = PathBuf::from("test.txt");
|
||||
let ctx = TestContext::new(&mut app, file_path.clone(), "line 1\nline 2\nline 3");
|
||||
|
||||
// Comment attached to "line 2" which exists in the editor
|
||||
let line_comment =
|
||||
create_line_comment("/repo/test.txt", 1, "line 2", "Comment on existing line");
|
||||
let original_id = line_comment.id;
|
||||
|
||||
ctx.code_review_view.update(&mut app, |_view, view_ctx| {
|
||||
let RelocateCommentsResult {
|
||||
comments: relocated,
|
||||
fallback_count: fallbacks,
|
||||
} = CodeReviewView::relocate_comments(
|
||||
vec![line_comment],
|
||||
&ctx.state,
|
||||
&ctx.repo_path,
|
||||
view_ctx,
|
||||
);
|
||||
|
||||
assert_eq!(relocated.len(), 1, "Comment should be relocated");
|
||||
assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");
|
||||
assert!(
|
||||
!relocated[0].outdated,
|
||||
"Comment should NOT be marked as outdated when line content is found"
|
||||
);
|
||||
assert_eq!(
|
||||
fallbacks, 0,
|
||||
"Should have no fallbacks when content matches"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
//! Shared rendering helpers for displaying code review comments.
|
||||
//!
|
||||
//! These functions are used by both the `CommentListView` (in the code review panel)
|
||||
//! and the blocklist's imported comments rendering.
|
||||
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use chrono::{Duration, Local};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::editor::comment_editor::create_readonly_comment_markdown_editor;
|
||||
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use crate::code_review::comments::{
|
||||
AttachedReviewComment, AttachedReviewCommentTarget, LineDiffContent,
|
||||
};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::view::RichTextEditorView;
|
||||
use crate::util::time_format::human_readable_approx_duration;
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::color::internal_colors::{neutral_1, neutral_2, text_sub};
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_editor::content::buffer::InitialBufferState;
|
||||
use warp_editor::render::element::VerticalExpansionBehavior;
|
||||
use warpui::elements::new_scrollable::ScrollableAppearance;
|
||||
use warpui::elements::ScrollbarWidth;
|
||||
use warpui::elements::{
|
||||
Border, ChildView, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::units::Pixels;
|
||||
use warpui::{AppContext, Element, EventContext, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
/// Configuration for making the comment header clickable.
|
||||
pub(crate) struct HeaderClickHandler {
|
||||
pub mouse_state: MouseStateHandle,
|
||||
pub on_click: Rc<dyn Fn(&mut EventContext) + 'static>,
|
||||
}
|
||||
|
||||
/// Wraps the given content element in the standard comment card chrome
|
||||
/// (rounded corners, neutral background, outline border).
|
||||
fn comment_card_container(
|
||||
content: Box<dyn Element>,
|
||||
theme: &warp_core::ui::theme::WarpTheme,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(content)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background(Fill::Solid(neutral_1(theme)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a collapsed comment card showing only the file-path header and an
|
||||
/// optional trailing element (e.g. action buttons).
|
||||
fn render_collapsed_comment_card(
|
||||
title: &str,
|
||||
is_outdated: bool,
|
||||
header_trailing_element: Option<Box<dyn Element>>,
|
||||
on_header_click: Option<&HeaderClickHandler>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let header = render_comment_file_path_header(
|
||||
title,
|
||||
is_outdated,
|
||||
header_trailing_element,
|
||||
CornerRadius::with_all(Radius::Pixels(8.)),
|
||||
on_header_click,
|
||||
appearance,
|
||||
);
|
||||
|
||||
comment_card_container(header, theme)
|
||||
}
|
||||
|
||||
fn render_comment_file_path_header(
|
||||
title: &str,
|
||||
is_outdated: bool,
|
||||
trailing_element: Option<Box<dyn Element>>,
|
||||
corner_radius: CornerRadius,
|
||||
on_header_click: Option<&HeaderClickHandler>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let file_path_text = Text::new(
|
||||
title.to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() + 2.,
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_clip(ClipConfig::start())
|
||||
.with_color(
|
||||
theme
|
||||
.main_text_color(Fill::Solid(neutral_2(theme)))
|
||||
.into_solid(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut header_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., file_path_text).finish());
|
||||
|
||||
if is_outdated {
|
||||
let yellow_border: ColorU = theme.terminal_colors().normal.yellow.into();
|
||||
let yellow_text: ColorU = theme.terminal_colors().bright.yellow.into();
|
||||
|
||||
let outdated_chip = Container::new(
|
||||
Text::new(
|
||||
"Outdated",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(yellow_text)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(4.)
|
||||
.with_border(Border::all(1.).with_border_fill(Fill::Solid(yellow_border)))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
|
||||
.with_background(Fill::Solid(neutral_2(theme)))
|
||||
.finish();
|
||||
|
||||
header_row.add_child(outdated_chip);
|
||||
}
|
||||
|
||||
if let Some(trailing) = trailing_element {
|
||||
header_row = header_row
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween);
|
||||
header_row.add_child(Container::new(trailing).with_margin_left(8.).finish());
|
||||
}
|
||||
|
||||
let container = Container::new(header_row.finish())
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_background(Fill::Solid(neutral_2(theme)))
|
||||
.with_corner_radius(corner_radius)
|
||||
.finish();
|
||||
|
||||
if let Some(click_handler) = on_header_click {
|
||||
let callback = Rc::clone(&click_handler.on_click);
|
||||
Hoverable::new(click_handler.mouse_state.clone(), |_| container)
|
||||
.on_click(move |ctx, _, _| {
|
||||
callback(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.with_defer_events_to_children()
|
||||
.finish()
|
||||
} else {
|
||||
container
|
||||
}
|
||||
}
|
||||
|
||||
fn render_comment_text_section(
|
||||
comment_editor: &ViewHandle<RichTextEditorView>,
|
||||
last_updated_duration: Duration,
|
||||
is_imported_from_github: bool,
|
||||
metadata_trailing_element: Option<Box<dyn Element>>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let background = Fill::Solid(neutral_1(theme));
|
||||
|
||||
let mut left_section = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.);
|
||||
|
||||
if is_imported_from_github {
|
||||
left_section.add_child(
|
||||
Text::new(
|
||||
"From GitHub".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(text_sub(theme, background))
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
left_section.add_child(
|
||||
Text::new(
|
||||
human_readable_approx_duration(last_updated_duration, true /* sentence_case */),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(background)
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut metadata_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(left_section.finish());
|
||||
|
||||
if let Some(trailing) = metadata_trailing_element {
|
||||
metadata_row = metadata_row
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(trailing);
|
||||
} else {
|
||||
metadata_row = metadata_row.with_main_axis_alignment(MainAxisAlignment::Start);
|
||||
}
|
||||
|
||||
let comment_content_child = ChildView::new(comment_editor).finish();
|
||||
|
||||
let column = Flex::column()
|
||||
.with_children([metadata_row.finish(), comment_content_child])
|
||||
.finish();
|
||||
|
||||
Container::new(column)
|
||||
.with_background(background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_uniform_padding(8.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Creates a read-only, syntax-highlighted code editor for displaying static diff content.
|
||||
///
|
||||
/// The editor is configured with infinite height, no diff UI, no line numbers, and selectable
|
||||
/// interaction state. The buffer is populated with the diff's original text and syntax
|
||||
/// highlighting is set based on the file path.
|
||||
fn create_static_diff_content_editor<V: View>(
|
||||
content: &LineDiffContent,
|
||||
file_path: &Path,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) -> ViewHandle<CodeEditorView> {
|
||||
let editor = ctx.add_typed_action_view(|ctx| {
|
||||
CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight),
|
||||
ctx,
|
||||
)
|
||||
.with_can_show_diff_ui(false)
|
||||
.with_show_line_numbers(false)
|
||||
.with_horizontal_scrollbar_appearance(ScrollableAppearance::new(
|
||||
ScrollbarWidth::Auto,
|
||||
false,
|
||||
))
|
||||
});
|
||||
editor.update(ctx, |view, ctx| {
|
||||
view.set_show_current_line_highlights(false, ctx);
|
||||
view.set_interaction_state(InteractionState::Selectable, ctx);
|
||||
let original_text = content.original_text();
|
||||
let state = InitialBufferState::plain_text(original_text.trim());
|
||||
view.reset(state, ctx);
|
||||
view.set_language_with_path(file_path, ctx);
|
||||
});
|
||||
editor
|
||||
}
|
||||
|
||||
fn render_static_diff_content_element(
|
||||
editor: &ViewHandle<CodeEditorView>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
Container::new(ChildView::new(editor).finish())
|
||||
.with_background(theme.background())
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// How to display the diff content section of a comment card.
|
||||
enum CommentDiffContent {
|
||||
/// Diff content rendered via a live editor lens. The element is provided at render time.
|
||||
EditorLens,
|
||||
/// Diff content rendered via a static, read-only `CodeEditorView`.
|
||||
StaticEditor(ViewHandle<CodeEditorView>),
|
||||
}
|
||||
|
||||
/// A shared UI component for a single code review comment card.
|
||||
///
|
||||
/// Used by both `CommentListView` (code review panel) and the blocklist's
|
||||
/// imported comments. Owns the view handles for the comment body editor and
|
||||
/// (optionally) a static diff editor, plus the underlying comment data.
|
||||
pub(crate) struct CommentViewCard {
|
||||
comment_editor: ViewHandle<RichTextEditorView>,
|
||||
diff_content: Option<CommentDiffContent>,
|
||||
source: AttachedReviewComment,
|
||||
title: String,
|
||||
last_updated_duration: Duration,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl CommentViewCard {
|
||||
pub(crate) fn new<V: View>(
|
||||
source: AttachedReviewComment,
|
||||
always_use_static_diff: bool,
|
||||
disable_scrolling: bool,
|
||||
max_width: Option<Pixels>,
|
||||
repo_path: Option<&Path>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) -> Self {
|
||||
let comment_editor = create_readonly_comment_markdown_editor(
|
||||
&source.content,
|
||||
disable_scrolling,
|
||||
max_width,
|
||||
ctx,
|
||||
);
|
||||
let diff_content = Self::diff_content_for_comment(&source, always_use_static_diff, ctx);
|
||||
let title = Self::compute_title(&source, repo_path);
|
||||
let last_updated_duration = Local::now() - source.last_update_time;
|
||||
Self {
|
||||
comment_editor,
|
||||
diff_content,
|
||||
source,
|
||||
title,
|
||||
last_updated_duration,
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_content_for_comment<V: View>(
|
||||
comment: &AttachedReviewComment,
|
||||
always_use_static_diff: bool,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) -> Option<CommentDiffContent> {
|
||||
if let AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path,
|
||||
content,
|
||||
..
|
||||
} = &comment.target
|
||||
{
|
||||
if always_use_static_diff || comment.outdated {
|
||||
Some(CommentDiffContent::StaticEditor(
|
||||
create_static_diff_content_editor(content, absolute_file_path, ctx),
|
||||
))
|
||||
} else {
|
||||
Some(CommentDiffContent::EditorLens)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_collapsed(&mut self) {
|
||||
self.is_collapsed = !self.is_collapsed;
|
||||
}
|
||||
|
||||
pub(crate) fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
|
||||
/// Updates the comment data and resets the body editor with the new content.
|
||||
pub(crate) fn update_source<V: View>(
|
||||
&mut self,
|
||||
new_source: AttachedReviewComment,
|
||||
repo_path: Option<&Path>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
self.comment_editor.update(ctx, |editor, ctx| {
|
||||
editor.model().update(ctx, |model, ctx| {
|
||||
model.reset_with_markdown(&new_source.content, ctx);
|
||||
});
|
||||
});
|
||||
self.source = new_source;
|
||||
self.title = Self::compute_title(&self.source, repo_path);
|
||||
}
|
||||
|
||||
/// Renders the comment card. When collapsed, only the header and trailing
|
||||
/// element are shown. When expanded, the full card with diff content and
|
||||
/// comment text is rendered.
|
||||
///
|
||||
/// When `diff_content` is `EditorLens`, the caller must supply the live element via
|
||||
/// `editor_lens_element`. For `StaticEditor` or `None` it is ignored.
|
||||
///
|
||||
/// When `on_header_click` is provided, the entire header area becomes clickable.
|
||||
pub(crate) fn render(
|
||||
&self,
|
||||
editor_lens_element: Option<Box<dyn Element>>,
|
||||
header_trailing_element: Option<Box<dyn Element>>,
|
||||
metadata_trailing_element: Option<Box<dyn Element>>,
|
||||
on_header_click: Option<&HeaderClickHandler>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
if self.is_collapsed {
|
||||
return render_collapsed_comment_card(
|
||||
&self.title,
|
||||
self.source.outdated,
|
||||
header_trailing_element,
|
||||
on_header_click,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut card = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
card.add_child(render_comment_file_path_header(
|
||||
&self.title,
|
||||
self.source.outdated,
|
||||
header_trailing_element,
|
||||
CornerRadius::with_top(Radius::Pixels(8.)),
|
||||
on_header_click,
|
||||
appearance,
|
||||
));
|
||||
|
||||
match &self.diff_content {
|
||||
Some(CommentDiffContent::EditorLens) => {
|
||||
if let Some(lens) = editor_lens_element {
|
||||
card.add_child(lens);
|
||||
}
|
||||
}
|
||||
Some(CommentDiffContent::StaticEditor(editor)) => {
|
||||
card.add_child(render_static_diff_content_element(editor, app));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
card.add_child(render_comment_text_section(
|
||||
&self.comment_editor,
|
||||
self.last_updated_duration,
|
||||
self.source.origin.is_imported_from_github(),
|
||||
metadata_trailing_element,
|
||||
appearance,
|
||||
));
|
||||
comment_card_container(card.finish(), theme)
|
||||
}
|
||||
|
||||
pub(crate) fn source(&self) -> &AttachedReviewComment {
|
||||
&self.source
|
||||
}
|
||||
|
||||
pub(crate) fn comment_editor(&self) -> &ViewHandle<RichTextEditorView> {
|
||||
&self.comment_editor
|
||||
}
|
||||
|
||||
pub(crate) fn static_diff_editor(&self) -> Option<&ViewHandle<CodeEditorView>> {
|
||||
match &self.diff_content {
|
||||
Some(CommentDiffContent::StaticEditor(editor)) => Some(editor),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn uses_editor_lens(&self) -> bool {
|
||||
matches!(self.diff_content, Some(CommentDiffContent::EditorLens))
|
||||
}
|
||||
|
||||
/// Recomputes the cached display title.
|
||||
pub(crate) fn update_title(&mut self, repo_path: Option<&Path>) {
|
||||
self.title = Self::compute_title(&self.source, repo_path);
|
||||
}
|
||||
|
||||
/// Refreshes the cached `last_updated_duration` to the current time.
|
||||
pub(crate) fn refresh_last_updated_duration(&mut self) {
|
||||
self.last_updated_duration = Local::now() - self.source.last_update_time;
|
||||
}
|
||||
|
||||
fn compute_title(source: &AttachedReviewComment, repo_path: Option<&Path>) -> String {
|
||||
let file_path = source.target.absolute_file_path().map(|p| {
|
||||
repo_path
|
||||
.and_then(|rp| p.strip_prefix(rp).ok())
|
||||
.unwrap_or(p)
|
||||
.display()
|
||||
.to_string()
|
||||
});
|
||||
let line_number = source.target.line_number().map(|lc| lc.as_u32() + 1);
|
||||
|
||||
match (file_path, line_number) {
|
||||
(Some(path), Some(line)) => format!("{path}:{line}"),
|
||||
(Some(path), None) => path,
|
||||
_ => source
|
||||
.head()
|
||||
.map(|head| head.title())
|
||||
.unwrap_or_else(|| "Review Comment".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use super::{
|
||||
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, PendingImportedReviewComment,
|
||||
};
|
||||
use crate::{code::editor::EditorReviewComment, code_review::diff_state::DiffMode};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_editor::render::model::LineCount;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ReviewCommentBatchEvent {
|
||||
Changed { should_reposition_comments: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ReviewCommentBatch {
|
||||
/// Comments that are attached to local editors and visible to the user.
|
||||
pub comments: Vec<AttachedReviewComment>,
|
||||
/// Imported comments waiting for editors and diffs to load before they can be displayed to the user.
|
||||
/// Comments are grouped by base branch.
|
||||
pending_imported_comments: HashMap<DiffMode, Vec<PendingImportedReviewComment>>,
|
||||
}
|
||||
|
||||
impl Entity for ReviewCommentBatch {
|
||||
type Event = ReviewCommentBatchEvent;
|
||||
}
|
||||
|
||||
impl ReviewCommentBatch {
|
||||
pub fn from_comments(comments: Vec<AttachedReviewComment>) -> Self {
|
||||
Self {
|
||||
comments,
|
||||
pending_imported_comments: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_review_comment_by_id(&self, id: CommentId) -> Option<&AttachedReviewComment> {
|
||||
self.comments.iter().find(|comment| comment.id == id)
|
||||
}
|
||||
|
||||
pub(super) fn get_mut_review_comment_by_id(
|
||||
&mut self,
|
||||
id: CommentId,
|
||||
) -> Option<&mut AttachedReviewComment> {
|
||||
self.comments.iter_mut().find(|comment| comment.id == id)
|
||||
}
|
||||
|
||||
pub(crate) fn diffset_comment(&self) -> Option<&AttachedReviewComment> {
|
||||
self.comments
|
||||
.iter()
|
||||
.find(|comment| matches!(comment.target, AttachedReviewCommentTarget::General))
|
||||
}
|
||||
|
||||
pub(crate) fn has_only_outdated_comments(&self) -> bool {
|
||||
self.comments.iter().all(|comment| comment.outdated)
|
||||
}
|
||||
|
||||
/// `file` param should always be the filepath from the root of the repository.
|
||||
/// This ensures we'll never confuse files in different subdirectories with
|
||||
/// the same suffix, for example `/my_repo/src/a/file.txt` and `/my_repo/src/b/file.txt`.
|
||||
pub fn file_comments<'a>(
|
||||
&'a self,
|
||||
file: &'a Path,
|
||||
) -> impl Iterator<Item = &'a AttachedReviewComment> + 'a {
|
||||
self.comments.iter().filter(move |comment| {
|
||||
comment
|
||||
.target
|
||||
.absolute_file_path()
|
||||
.is_some_and(|comment_file| comment_file.ends_with(file))
|
||||
})
|
||||
}
|
||||
|
||||
/// `file` param should always be the filepath from the root of the repository.
|
||||
/// This ensures we'll never confuse files in different subdirectories with
|
||||
/// the same suffix, for example `/my_repo/src/a/file.txt` and `/my_repo/src/b/file.txt`.
|
||||
pub fn comment_line_numbers_for_file<'a>(
|
||||
&'a self,
|
||||
file: &'a Path,
|
||||
) -> impl Iterator<Item = LineCount> + 'a {
|
||||
self.file_comments(file).filter_map(move |comment| {
|
||||
if let AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path: comment_file_path,
|
||||
line,
|
||||
..
|
||||
} = &comment.target
|
||||
{
|
||||
if comment_file_path.ends_with(file) {
|
||||
line.line_number()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn editor_comments_for_file(&self, file: &Path) -> Vec<EditorReviewComment> {
|
||||
self.file_comments(file)
|
||||
.filter(|comment| {
|
||||
if FeatureFlag::PRCommentsSlashCommand.is_enabled() {
|
||||
!comment.outdated
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter_map(|comment| EditorReviewComment::try_from(comment.clone()).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn upsert_comment(
|
||||
&mut self,
|
||||
comment: AttachedReviewComment,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.upsert_comments_inner(vec![comment]);
|
||||
ctx.emit(ReviewCommentBatchEvent::Changed {
|
||||
should_reposition_comments: false,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn upsert_imported_comments(
|
||||
&mut self,
|
||||
comments: Vec<AttachedReviewComment>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if comments.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.upsert_comments_inner(comments);
|
||||
ctx.emit(ReviewCommentBatchEvent::Changed {
|
||||
should_reposition_comments: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Comments with existing IDs are updated.
|
||||
/// New comments are inserted into the batch.
|
||||
pub fn upsert_comments(
|
||||
&mut self,
|
||||
comments: Vec<AttachedReviewComment>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.upsert_comments_inner(comments);
|
||||
ctx.emit(ReviewCommentBatchEvent::Changed {
|
||||
should_reposition_comments: false,
|
||||
});
|
||||
}
|
||||
|
||||
fn upsert_comments_inner(&mut self, comments: Vec<AttachedReviewComment>) {
|
||||
let (existing_comments, new_comments): (
|
||||
Vec<AttachedReviewComment>,
|
||||
Vec<AttachedReviewComment>,
|
||||
) = comments
|
||||
.into_iter()
|
||||
.partition(|c| self.get_review_comment_by_id(c.id).is_some());
|
||||
|
||||
self.comments.extend(new_comments);
|
||||
for c in existing_comments {
|
||||
if let Some(existing_entry) = self.get_mut_review_comment_by_id(c.id) {
|
||||
*existing_entry = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_comments(&mut self) -> Vec<AttachedReviewComment> {
|
||||
std::mem::take(&mut self.comments)
|
||||
}
|
||||
|
||||
/// Deleting a comment does NOT remove the associated diff hunk from the batch's
|
||||
/// diff set because that hunk may be referenced by another comment.
|
||||
/// In the future, we may investigate a cleaner way to do this.
|
||||
pub(crate) fn delete_comment(&mut self, id: CommentId, ctx: &mut ModelContext<Self>) {
|
||||
self.comments.retain(|comment| comment.id != id);
|
||||
ctx.emit(ReviewCommentBatchEvent::Changed {
|
||||
should_reposition_comments: false,
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn clear_all(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.comments.clear();
|
||||
ctx.emit(ReviewCommentBatchEvent::Changed {
|
||||
should_reposition_comments: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Stores imported comments that are waiting for diffs and editors to load before they can be flattened,
|
||||
/// relocated, and inserted into `comments`.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn add_pending_imported_comments(
|
||||
&mut self,
|
||||
comments: Vec<PendingImportedReviewComment>,
|
||||
base_branch: DiffMode,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.pending_imported_comments
|
||||
.entry(base_branch)
|
||||
.or_default()
|
||||
.extend(comments);
|
||||
ctx.emit(ReviewCommentBatchEvent::Changed {
|
||||
should_reposition_comments: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Takes all pending imported comments for the given diff mode, leaving the pending list empty.
|
||||
/// Used when diffs have loaded and comments can be relocated.
|
||||
pub(crate) fn take_pending_imported_comments_for_branch(
|
||||
&mut self,
|
||||
branch: &DiffMode,
|
||||
) -> Vec<PendingImportedReviewComment> {
|
||||
if let Some(pending_comments) = self.pending_imported_comments.get_mut(branch) {
|
||||
std::mem::take(pending_comments)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "batch_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,157 @@
|
||||
use chrono::Local;
|
||||
use warp_editor::render::model::LineCount;
|
||||
use warpui::App;
|
||||
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code_review::comments::{
|
||||
AttachedReviewComment, AttachedReviewCommentTarget, CommentOrigin, LineDiffContent,
|
||||
ReviewCommentBatch,
|
||||
};
|
||||
|
||||
fn line_comment(file_path: &str, line_number: usize, content: &str) -> AttachedReviewComment {
|
||||
AttachedReviewComment {
|
||||
id: Default::default(),
|
||||
content: content.to_string(),
|
||||
target: AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path: file_path.into(),
|
||||
line: EditorLineLocation::Current {
|
||||
line_number: LineCount::from(line_number),
|
||||
line_range: LineCount::from(line_number)..LineCount::from(line_number + 1),
|
||||
},
|
||||
content: LineDiffContent {
|
||||
content: "+line\n".to_string(),
|
||||
lines_added: LineCount::from(1),
|
||||
lines_removed: LineCount::from(0),
|
||||
},
|
||||
},
|
||||
last_update_time: Local::now(),
|
||||
base: None,
|
||||
head: None,
|
||||
outdated: false,
|
||||
origin: CommentOrigin::Native,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_existing_comment_in_place() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_model(|_| ReviewCommentBatch::default());
|
||||
|
||||
let mut comment = line_comment("/repo/src/lib.rs", 10, "first");
|
||||
let id = comment.id;
|
||||
|
||||
model.update(&mut app, |batch, ctx| {
|
||||
batch.upsert_comment(comment.clone(), ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |batch, _| {
|
||||
assert_eq!(batch.comments.len(), 1);
|
||||
assert_eq!(batch.comments[0].id, id);
|
||||
assert_eq!(batch.comments[0].content, "first");
|
||||
});
|
||||
|
||||
comment.content = "updated".to_string();
|
||||
model.update(&mut app, |batch, ctx| {
|
||||
batch.upsert_comment(comment.clone(), ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |batch, _| {
|
||||
assert_eq!(batch.comments.len(), 1);
|
||||
assert_eq!(batch.comments[0].id, id);
|
||||
assert_eq!(batch.comments[0].content, "updated");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_clear_mutations_work() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_model(|_| ReviewCommentBatch::default());
|
||||
|
||||
let comment_a = line_comment("/repo/src/lib.rs", 1, "a");
|
||||
let comment_b = line_comment("/repo/src/lib.rs", 2, "b");
|
||||
let id_a = comment_a.id;
|
||||
|
||||
model.update(&mut app, |batch, ctx| {
|
||||
batch.upsert_comment(comment_a.clone(), ctx);
|
||||
batch.upsert_comment(comment_b.clone(), ctx);
|
||||
});
|
||||
|
||||
model.update(&mut app, |batch, ctx| {
|
||||
batch.delete_comment(id_a, ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |batch, _| {
|
||||
assert_eq!(batch.comments.len(), 1);
|
||||
assert_eq!(batch.comments[0].content, "b");
|
||||
});
|
||||
|
||||
model.update(&mut app, |batch, ctx| {
|
||||
batch.clear_all(ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |batch, _| {
|
||||
assert!(batch.comments.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_and_line_queries_filter_by_suffix() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_model(|_| ReviewCommentBatch::default());
|
||||
|
||||
let comment_a = line_comment("/repo/src/lib.rs", 3, "a");
|
||||
let comment_b = line_comment("/repo/src/main.rs", 5, "b");
|
||||
|
||||
model.update(&mut app, |batch, ctx| {
|
||||
batch.upsert_comment(comment_a.clone(), ctx);
|
||||
batch.upsert_comment(comment_b.clone(), ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |batch, _| {
|
||||
let file_comments: Vec<_> = batch
|
||||
.file_comments(std::path::Path::new("src/lib.rs"))
|
||||
.collect();
|
||||
assert_eq!(file_comments.len(), 1);
|
||||
assert_eq!(file_comments[0].content, "a");
|
||||
|
||||
let line_numbers: Vec<_> = batch
|
||||
.comment_line_numbers_for_file(std::path::Path::new("src/lib.rs"))
|
||||
.collect();
|
||||
assert_eq!(line_numbers, vec![LineCount::from(3)]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_comments_for_file_includes_only_line_comments() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_model(|_| ReviewCommentBatch::default());
|
||||
|
||||
let comment_a = line_comment("/repo/src/lib.rs", 7, "a");
|
||||
let general = AttachedReviewComment {
|
||||
id: Default::default(),
|
||||
content: "general".to_string(),
|
||||
target: AttachedReviewCommentTarget::General,
|
||||
last_update_time: Local::now(),
|
||||
base: None,
|
||||
head: None,
|
||||
outdated: false,
|
||||
origin: CommentOrigin::Native,
|
||||
};
|
||||
|
||||
model.update(&mut app, |batch, ctx| {
|
||||
batch.upsert_comment(comment_a.clone(), ctx);
|
||||
batch.upsert_comment(general.clone(), ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |batch, _| {
|
||||
let editor_comments =
|
||||
batch.editor_comments_for_file(std::path::Path::new("src/lib.rs"));
|
||||
assert_eq!(editor_comments.len(), 1);
|
||||
assert_eq!(editor_comments[0].id, comment_a.id);
|
||||
assert_eq!(editor_comments[0].comment_content, "a");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use crate::{
|
||||
ai::agent::{CurrentHead, DiffBase},
|
||||
code::editor::{line::EditorLineLocation, EditorReviewComment},
|
||||
};
|
||||
use chrono::{DateTime, Local};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::PathBuf;
|
||||
use warp_editor::render::model::LineCount;
|
||||
use warp_multi_agent_api::{self as api};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CommentOrigin {
|
||||
/// Comments originally created in the Warp UI.
|
||||
#[default]
|
||||
Native,
|
||||
/// Comments imported from a GitHub pull request.
|
||||
ImportedFromGitHub(ImportedCommentDetails),
|
||||
}
|
||||
|
||||
impl CommentOrigin {
|
||||
pub(crate) fn is_imported_from_github(&self) -> bool {
|
||||
matches!(self, Self::ImportedFromGitHub(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Imported comment metadata for GitHub-specific fields.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImportedCommentDetails {
|
||||
pub author: String,
|
||||
/// The GitHub comment ID from the API.
|
||||
pub github_comment_id: String,
|
||||
/// The GitHub parent comment ID if this was a reply.
|
||||
/// Should be None for threaded comments after flattening.
|
||||
pub github_parent_id: Option<String>,
|
||||
pub html_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct LineDiffContent {
|
||||
pub content: String,
|
||||
pub lines_added: LineCount,
|
||||
pub lines_removed: LineCount,
|
||||
}
|
||||
|
||||
impl LineDiffContent {
|
||||
/// The text in the diff line, without the `+` or `-` diff prefix or trailing newlines.
|
||||
///
|
||||
/// Uses `strip_prefix` (removes exactly one occurrence) rather than `trim_start_matches`
|
||||
/// (which removes all leading occurrences) so that content characters are preserved.
|
||||
pub(crate) fn original_text(&self) -> String {
|
||||
let s = self.content.trim_end_matches('\n');
|
||||
s.strip_prefix('+')
|
||||
.or_else(|| s.strip_prefix('-'))
|
||||
.unwrap_or(s)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn from_content(diff_line: &str) -> Self {
|
||||
let lines_added = LineCount::from(if diff_line.starts_with('+') { 1 } else { 0 });
|
||||
let lines_removed = LineCount::from(if diff_line.starts_with('-') { 1 } else { 0 });
|
||||
Self {
|
||||
content: diff_line.to_owned(),
|
||||
lines_added,
|
||||
lines_removed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct CommentId(uuid::Uuid);
|
||||
|
||||
impl CommentId {
|
||||
pub(crate) fn new() -> Self {
|
||||
CommentId(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub(crate) fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CommentId {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommentId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Code review comment attached to a local file editor.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AttachedReviewComment {
|
||||
/// Locally-generated ID.
|
||||
pub id: CommentId,
|
||||
pub content: String,
|
||||
pub target: AttachedReviewCommentTarget,
|
||||
pub last_update_time: DateTime<Local>,
|
||||
pub base: Option<DiffBase>,
|
||||
pub head: Option<CurrentHead>,
|
||||
pub outdated: bool,
|
||||
pub origin: CommentOrigin,
|
||||
}
|
||||
|
||||
impl From<AttachedReviewComment> for api::ReviewComment {
|
||||
fn from(val: AttachedReviewComment) -> Self {
|
||||
let comment_target = match val.target {
|
||||
AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path,
|
||||
content,
|
||||
line,
|
||||
} => {
|
||||
// For now, comments are only attached to a single line.
|
||||
let line_range = line.line_number().map(|lc| {
|
||||
let line_number = lc.as_usize() as u32;
|
||||
api::FileContentLineRange {
|
||||
start: line_number,
|
||||
end: line_number + 1,
|
||||
}
|
||||
});
|
||||
|
||||
api::review_comment::CommentTarget::CommentedLine(api::DiffHunk {
|
||||
file_path: absolute_file_path.to_string_lossy().to_string(),
|
||||
line_range,
|
||||
diff_content: content.content,
|
||||
lines_added: content.lines_added.as_u32(),
|
||||
lines_removed: content.lines_removed.as_u32(),
|
||||
current: val.head.to_owned().map(Into::into),
|
||||
base: val.base.map(Into::into),
|
||||
})
|
||||
}
|
||||
AttachedReviewCommentTarget::File { absolute_file_path } => {
|
||||
api::review_comment::CommentTarget::CommentedFile(
|
||||
api::review_comment::CommentedFile {
|
||||
file_path: absolute_file_path.to_string_lossy().to_string(),
|
||||
current: val.head.to_owned().map(Into::into),
|
||||
base: val.base.map(Into::into),
|
||||
},
|
||||
)
|
||||
}
|
||||
AttachedReviewCommentTarget::General => {
|
||||
api::review_comment::CommentTarget::CommentedDiffset(
|
||||
api::review_comment::CommentedDiffset {
|
||||
current: val.head.to_owned().map(Into::into),
|
||||
base: val.base.map(Into::into),
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
api::ReviewComment {
|
||||
id: val.id.to_string(),
|
||||
comment: val.content,
|
||||
comment_target: Some(comment_target),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Target for an attached review comment. File paths are always absolute when present.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AttachedReviewCommentTarget {
|
||||
Line {
|
||||
absolute_file_path: PathBuf,
|
||||
line: EditorLineLocation,
|
||||
content: LineDiffContent,
|
||||
},
|
||||
File {
|
||||
absolute_file_path: PathBuf,
|
||||
},
|
||||
General,
|
||||
}
|
||||
|
||||
impl AttachedReviewCommentTarget {
|
||||
pub(crate) fn absolute_file_path(&self) -> Option<&PathBuf> {
|
||||
match self {
|
||||
AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path, ..
|
||||
} => Some(absolute_file_path),
|
||||
AttachedReviewCommentTarget::File { absolute_file_path } => Some(absolute_file_path),
|
||||
AttachedReviewCommentTarget::General => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn line_number(&self) -> Option<LineCount> {
|
||||
match self {
|
||||
AttachedReviewCommentTarget::Line { line, .. } => line.line_number(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AttachedReviewComment {
|
||||
pub(crate) fn from_editor_review_comment(
|
||||
comment: EditorReviewComment,
|
||||
absolute_file_path: PathBuf,
|
||||
base: Option<DiffBase>,
|
||||
head: Option<CurrentHead>,
|
||||
) -> AttachedReviewComment {
|
||||
AttachedReviewComment {
|
||||
id: comment.id,
|
||||
content: comment.comment_content,
|
||||
base,
|
||||
head,
|
||||
target: AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path,
|
||||
line: comment.line,
|
||||
content: comment.diff_content,
|
||||
},
|
||||
last_update_time: comment.last_update_time,
|
||||
outdated: false,
|
||||
origin: CommentOrigin::Native,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn head(&self) -> Option<&CurrentHead> {
|
||||
self.head.as_ref()
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> &CommentOrigin {
|
||||
&self.origin
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "comment_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::LineDiffContent;
|
||||
|
||||
#[test]
|
||||
fn original_text_strips_addition_prefix() {
|
||||
let content = LineDiffContent::from_content("+added line");
|
||||
assert_eq!(content.original_text(), "added line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_strips_deletion_prefix() {
|
||||
let content = LineDiffContent::from_content("-deleted line");
|
||||
assert_eq!(content.original_text(), "deleted line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_preserves_markdown_list_dash_in_addition() {
|
||||
let content = LineDiffContent::from_content("+- list item");
|
||||
assert_eq!(content.original_text(), "- list item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_preserves_dash_only_content_in_addition() {
|
||||
let content = LineDiffContent::from_content("+-");
|
||||
assert_eq!(content.original_text(), "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_strips_only_one_leading_plus() {
|
||||
let content = LineDiffContent::from_content("++text");
|
||||
assert_eq!(content.original_text(), "+text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_strips_only_one_leading_minus() {
|
||||
let content = LineDiffContent::from_content("--text");
|
||||
assert_eq!(content.original_text(), "-text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_preserves_space_prefixed_content() {
|
||||
let content = LineDiffContent {
|
||||
content: " - context list item".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(content.original_text(), " - context list item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_strips_trailing_newline() {
|
||||
let content = LineDiffContent::from_content("+added line\n");
|
||||
assert_eq!(content.original_text(), "added line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_handles_empty_content() {
|
||||
let content = LineDiffContent::from_content("");
|
||||
assert_eq!(content.original_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_text_handles_plain_text_without_prefix() {
|
||||
let content = LineDiffContent {
|
||||
content: "no prefix".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(content.original_text(), "no prefix");
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use ai::agent::action::InsertReviewComment;
|
||||
use chrono::{DateTime, Local};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{
|
||||
comment::ImportedCommentDetails, PendingImportedReviewComment,
|
||||
PendingImportedReviewCommentTarget,
|
||||
};
|
||||
use crate::code_review::comments::diff_hunk_parser::parse_diff_hunk;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConversionError {
|
||||
InvalidTimestamp(String),
|
||||
InvalidFilePath(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConversionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConversionError::InvalidTimestamp(ts) => {
|
||||
write!(f, "Invalid timestamp: {}", ts)
|
||||
}
|
||||
ConversionError::InvalidFilePath(path) => {
|
||||
write!(f, "Pending imported review comment targets must use repo-relative paths, invalid path: {}", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConversionError {}
|
||||
|
||||
pub(crate) fn convert_insert_review_comments(
|
||||
comments: &[InsertReviewComment],
|
||||
) -> Vec<PendingImportedReviewComment> {
|
||||
comments
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter_map(
|
||||
|comment| match PendingImportedReviewComment::try_from(comment) {
|
||||
Ok(comment) => Some(comment),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to convert InsertReviewComment: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl TryFrom<InsertReviewComment> for PendingImportedReviewComment {
|
||||
type Error = ConversionError;
|
||||
|
||||
fn try_from(comment: InsertReviewComment) -> Result<Self, Self::Error> {
|
||||
// Parse timestamp - try RFC3339 format
|
||||
let last_update_time: DateTime<Local> =
|
||||
DateTime::parse_from_rfc3339(&comment.last_modified_timestamp)
|
||||
.map(|dt| dt.with_timezone(&Local))
|
||||
.map_err(|_| {
|
||||
ConversionError::InvalidTimestamp(comment.last_modified_timestamp.clone())
|
||||
})?;
|
||||
|
||||
let target = match comment.comment_location {
|
||||
None => PendingImportedReviewCommentTarget::General,
|
||||
Some(location) => match location.line {
|
||||
None => PendingImportedReviewCommentTarget::File {
|
||||
relative_file_path: PathBuf::from(location.relative_file_path),
|
||||
},
|
||||
Some(line) => {
|
||||
// Use the start of the range as the target line for attaching the comment.
|
||||
match parse_diff_hunk(
|
||||
&line.diff_hunk_text,
|
||||
line.comment_line_range.start,
|
||||
line.side,
|
||||
) {
|
||||
Ok((line_location, diff_content)) => {
|
||||
PendingImportedReviewCommentTarget::Line {
|
||||
relative_file_path: PathBuf::from(location.relative_file_path),
|
||||
line: line_location,
|
||||
diff_content,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Error parsing comment at line {} from unified diff hunk: {}",
|
||||
line.comment_line_range.start,
|
||||
err
|
||||
);
|
||||
PendingImportedReviewCommentTarget::File {
|
||||
relative_file_path: PathBuf::from(location.relative_file_path),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(file_path) = target.file_path() {
|
||||
if file_path.is_absolute() {
|
||||
return Err(ConversionError::InvalidFilePath(file_path.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
let github_details = ImportedCommentDetails {
|
||||
author: comment.author,
|
||||
github_comment_id: comment.comment_id,
|
||||
github_parent_id: comment.parent_comment_id,
|
||||
html_url: comment.html_url.clone(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
github_details,
|
||||
body: comment.comment_body,
|
||||
last_update_time,
|
||||
target,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Utilities for parsing unified diff hunks and extracting specific line content.
|
||||
|
||||
use ai::agent::action::CommentSide;
|
||||
use num_traits::SaturatingSub;
|
||||
use warp_editor::render::model::LineCount;
|
||||
|
||||
use crate::{
|
||||
code::editor::line::EditorLineLocation,
|
||||
code_review::{
|
||||
comments::LineDiffContent,
|
||||
diff_state::{DiffLineType, DiffStateModel},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DiffHunkParseError {
|
||||
EmptyHunk,
|
||||
InvalidHeader(anyhow::Error),
|
||||
UnexpectedHunkHeader { line_index: usize },
|
||||
LineNotFound { target_line: usize },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DiffHunkParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DiffHunkParseError::EmptyHunk => write!(f, "Empty diff hunk"),
|
||||
DiffHunkParseError::InvalidHeader(err) => write!(f, "Invalid header: {err}"),
|
||||
DiffHunkParseError::UnexpectedHunkHeader { line_index } => {
|
||||
write!(f, "Unexpected hunk header at line index {line_index}")
|
||||
}
|
||||
DiffHunkParseError::LineNotFound { target_line } => {
|
||||
write!(f, "Target line {target_line} not found in hunk")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for DiffHunkParseError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
DiffHunkParseError::InvalidHeader(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the result tuple from a diff line's type and content.
|
||||
fn build_line_result(
|
||||
line: &str,
|
||||
line_type: &DiffLineType,
|
||||
line_index_in_hunk: usize,
|
||||
new_file_line: LineCount,
|
||||
) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> {
|
||||
// EditorLineLocation expects 0-based line numbers, but diff hunks are 1-based.
|
||||
let line_num = new_file_line.saturating_sub(&LineCount::from(1));
|
||||
|
||||
let editor_line_location = match line_type {
|
||||
DiffLineType::Context | DiffLineType::Add => EditorLineLocation::Current {
|
||||
line_number: line_num,
|
||||
line_range: line_num..line_num,
|
||||
},
|
||||
DiffLineType::Delete => EditorLineLocation::Removed {
|
||||
line_number: line_num,
|
||||
line_range: line_num..line_num,
|
||||
index: 0,
|
||||
},
|
||||
DiffLineType::HunkHeader => {
|
||||
return Err(DiffHunkParseError::UnexpectedHunkHeader {
|
||||
line_index: line_index_in_hunk,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let lines_added = usize::from(matches!(line_type, DiffLineType::Add));
|
||||
let lines_removed = usize::from(matches!(line_type, DiffLineType::Delete));
|
||||
|
||||
let line_diff_content = LineDiffContent {
|
||||
content: line.to_string(),
|
||||
lines_added: LineCount::from(lines_added),
|
||||
lines_removed: LineCount::from(lines_removed),
|
||||
};
|
||||
|
||||
Ok((editor_line_location, line_diff_content))
|
||||
}
|
||||
|
||||
fn get_diff_line_from_diff_hunk(
|
||||
diff_hunk: &str,
|
||||
target_line_number: usize,
|
||||
side: CommentSide,
|
||||
) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> {
|
||||
let parsed_lines: Vec<&str> = diff_hunk.lines().collect();
|
||||
let diff_hunk_header = parsed_lines
|
||||
.first()
|
||||
.ok_or(DiffHunkParseError::EmptyHunk)
|
||||
.and_then(|line| DiffStateModel::parse_unified_diff_header(line).map_err(Into::into))?;
|
||||
|
||||
let mut index_in_file = match side {
|
||||
CommentSide::Left => diff_hunk_header.old_start_line,
|
||||
CommentSide::Right => diff_hunk_header.new_start_line,
|
||||
};
|
||||
|
||||
for (index_in_hunk, line) in parsed_lines.iter().enumerate().skip(1) {
|
||||
let line_type = match line.chars().next().unwrap_or_default() {
|
||||
'@' => {
|
||||
return Err(DiffHunkParseError::UnexpectedHunkHeader {
|
||||
line_index: index_in_hunk,
|
||||
});
|
||||
}
|
||||
'+' => DiffLineType::Add,
|
||||
'-' => DiffLineType::Delete,
|
||||
_ => DiffLineType::Context,
|
||||
};
|
||||
|
||||
match side {
|
||||
CommentSide::Left => {
|
||||
if matches!(line_type, DiffLineType::Delete | DiffLineType::Context) {
|
||||
if index_in_file == target_line_number {
|
||||
return build_line_result(
|
||||
line,
|
||||
&line_type,
|
||||
index_in_hunk,
|
||||
LineCount::from(index_in_file),
|
||||
);
|
||||
}
|
||||
index_in_file += 1;
|
||||
}
|
||||
}
|
||||
CommentSide::Right => {
|
||||
if matches!(line_type, DiffLineType::Add | DiffLineType::Context) {
|
||||
if index_in_file == target_line_number {
|
||||
return build_line_result(
|
||||
line,
|
||||
&line_type,
|
||||
index_in_hunk,
|
||||
LineCount::from(index_in_file),
|
||||
);
|
||||
}
|
||||
index_in_file += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(DiffHunkParseError::LineNotFound {
|
||||
target_line: target_line_number,
|
||||
})
|
||||
}
|
||||
|
||||
/// Given a diff hunk and a target start and end line within it, return the EditorLineLocation and LineDiffContent for the target.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `diff_hunk` - A unified diff hunk string starting with `@@ -old_file_hunk_start,old_hunk_line_count +new_file_hunk_start,new_hunk_line_count @@`
|
||||
/// * `target_new_file_line` - The 1-indexed line number in the new file that the comment was attached to.
|
||||
/// * `side` - Optionally specify which side of the diff the target line is in. If None, tries Right first, then Left.
|
||||
///
|
||||
/// # Returns
|
||||
/// * the `EditorLineLocation` representing the comment, where the line_range represents the location in the new file
|
||||
/// * the `LineDiffContent` from the parsed diff hunk.
|
||||
pub(crate) fn parse_diff_hunk(
|
||||
diff_hunk: &str,
|
||||
target_line_number: usize,
|
||||
side: Option<CommentSide>,
|
||||
) -> Result<(EditorLineLocation, LineDiffContent), DiffHunkParseError> {
|
||||
match side {
|
||||
Some(side) => get_diff_line_from_diff_hunk(diff_hunk, target_line_number, side),
|
||||
None => {
|
||||
// Try Right first (new file), then fall back to Left (old file)
|
||||
let mut diff_line_result =
|
||||
get_diff_line_from_diff_hunk(diff_hunk, target_line_number, CommentSide::Right);
|
||||
if matches!(
|
||||
diff_line_result,
|
||||
Err(DiffHunkParseError::LineNotFound { .. })
|
||||
) {
|
||||
diff_line_result =
|
||||
get_diff_line_from_diff_hunk(diff_hunk, target_line_number, CommentSide::Left);
|
||||
}
|
||||
diff_line_result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "diff_hunk_parser_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,145 @@
|
||||
use ai::agent::action::CommentSide;
|
||||
|
||||
use super::{parse_diff_hunk, DiffHunkParseError};
|
||||
|
||||
#[test]
|
||||
fn test_parse_preserves_whitespace() {
|
||||
let diff_hunk = "@@ -1,2 +1,3 @@
|
||||
first line
|
||||
+ indented line
|
||||
last line";
|
||||
|
||||
// Diff line index 2 is the added line with indentation
|
||||
let result = parse_diff_hunk(diff_hunk, 2, None);
|
||||
assert!(result.is_ok());
|
||||
let (_, content) = result.unwrap();
|
||||
assert_eq!(content.content, "+ indented line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_hunk() {
|
||||
let result = parse_diff_hunk("", 1, None);
|
||||
assert!(matches!(result, Err(DiffHunkParseError::EmptyHunk)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_malformed_header() {
|
||||
// Header doesn't have valid format
|
||||
let diff_hunk = "@@ invalid @@";
|
||||
let result = parse_diff_hunk(diff_hunk, 1, None);
|
||||
assert!(matches!(result, Err(DiffHunkParseError::InvalidHeader(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_header() {
|
||||
// Content without a valid header line
|
||||
let diff_hunk = " line1\n+line2\n line3";
|
||||
let result = parse_diff_hunk(diff_hunk, 1, None);
|
||||
assert!(matches!(result, Err(DiffHunkParseError::InvalidHeader(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hunk_with_two_headers() {
|
||||
// Diff with an unexpected second header line
|
||||
let diff_hunk = "@@ -1,3 +1,4 @@\n line1\n@@ -5,2 +5,2 @@\n line2";
|
||||
let result = parse_diff_hunk(diff_hunk, 2, None);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(DiffHunkParseError::UnexpectedHunkHeader { line_index: 2 })
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression: markdown list items in diff hunks (PR #23626)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Regression test for diff hunks from PR #23626 where the commented line is a
|
||||
/// markdown list item (`+- ...`). The leading `-` is part of the content, not a
|
||||
/// diff deletion marker.
|
||||
#[test]
|
||||
fn test_parse_markdown_list_item_in_pure_addition_hunk() {
|
||||
// Trimmed hunk shape from PR #23626 comment 2997753460
|
||||
let diff_hunk = "@@ -0,0 +16,1 @@\n+- `specs/<issue-number>/TECH.md`";
|
||||
|
||||
let result = parse_diff_hunk(diff_hunk, 16, Some(CommentSide::Right));
|
||||
assert!(result.is_ok());
|
||||
let (_, content) = result.unwrap();
|
||||
assert_eq!(content.content, "+- `specs/<issue-number>/TECH.md`");
|
||||
assert_eq!(content.original_text(), "- `specs/<issue-number>/TECH.md`");
|
||||
}
|
||||
|
||||
/// Same scenario but with more surrounding context, matching the full diff hunk
|
||||
/// shape from the GitHub API for PR #23626.
|
||||
#[test]
|
||||
fn test_parse_markdown_list_item_in_full_addition_hunk() {
|
||||
let diff_hunk = "@@ -0,0 +1,116 @@\n\
|
||||
+---\n\
|
||||
+name: write-tech-spec\n\
|
||||
+description: desc\n\
|
||||
+---\n\
|
||||
+\n\
|
||||
+# write-tech-spec\n\
|
||||
+\n\
|
||||
+Write a spec.\n\
|
||||
+\n\
|
||||
+## Overview\n\
|
||||
+\n\
|
||||
+The tech spec overview.\n\
|
||||
+\n\
|
||||
+Write specs into source control under:\n\
|
||||
+\n\
|
||||
+- `specs/<issue-number>/TECH.md`";
|
||||
|
||||
let result = parse_diff_hunk(diff_hunk, 16, Some(CommentSide::Right));
|
||||
assert!(result.is_ok());
|
||||
let (_, content) = result.unwrap();
|
||||
assert_eq!(content.content, "+- `specs/<issue-number>/TECH.md`");
|
||||
assert_eq!(content.original_text(), "- `specs/<issue-number>/TECH.md`");
|
||||
}
|
||||
|
||||
/// Diff line whose content starts with `+` after the diff `+` prefix.
|
||||
#[test]
|
||||
fn test_parse_addition_with_leading_plus_in_content() {
|
||||
let diff_hunk = "@@ -0,0 +1,1 @@\n++positive value";
|
||||
|
||||
let result = parse_diff_hunk(diff_hunk, 1, Some(CommentSide::Right));
|
||||
assert!(result.is_ok());
|
||||
let (_, content) = result.unwrap();
|
||||
assert_eq!(content.content, "++positive value");
|
||||
assert_eq!(content.original_text(), "+positive value");
|
||||
}
|
||||
|
||||
/// Diff line whose content starts with `-` after the diff `-` prefix (deletion
|
||||
/// of a markdown list item).
|
||||
#[test]
|
||||
fn test_parse_deletion_with_leading_dash_in_content() {
|
||||
let diff_hunk = "@@ -1,1 +1,0 @@\n-- list item";
|
||||
|
||||
let result = parse_diff_hunk(diff_hunk, 1, Some(CommentSide::Left));
|
||||
assert!(result.is_ok());
|
||||
let (_, content) = result.unwrap();
|
||||
assert_eq!(content.content, "-- list item");
|
||||
assert_eq!(content.original_text(), "- list item");
|
||||
}
|
||||
|
||||
/// Context line with content that starts with `-`.
|
||||
#[test]
|
||||
fn test_parse_context_line_starting_with_dash() {
|
||||
let diff_hunk = "@@ -1,3 +1,4 @@\n - list item\n+added\n second\n third";
|
||||
|
||||
let result = parse_diff_hunk(diff_hunk, 1, Some(CommentSide::Right));
|
||||
assert!(result.is_ok());
|
||||
let (_, content) = result.unwrap();
|
||||
assert_eq!(content.content, " - list item");
|
||||
}
|
||||
|
||||
/// Context line with content that starts with `@`.
|
||||
#[test]
|
||||
fn test_parse_context_line_starting_with_at_sign() {
|
||||
let diff_hunk = "@@ -1,2 +1,3 @@\n @decorator\n+added\n next";
|
||||
|
||||
let result = parse_diff_hunk(diff_hunk, 1, Some(CommentSide::Right));
|
||||
assert!(result.is_ok());
|
||||
let (_, content) = result.unwrap();
|
||||
assert_eq!(content.content, " @decorator");
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use super::comment::{
|
||||
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin,
|
||||
};
|
||||
use super::pending_imported::{PendingImportedReviewComment, PendingImportedReviewCommentTarget};
|
||||
|
||||
/// Converts pending imported provider comments into attached review comments by:
|
||||
/// * flattening threaded replies
|
||||
/// * formatting markdown bodies
|
||||
/// * converting repo-relative file paths to absolute file paths
|
||||
pub(crate) fn attach_pending_imported_comments(
|
||||
pending_comments: Vec<PendingImportedReviewComment>,
|
||||
repo_path: &Path,
|
||||
) -> Vec<AttachedReviewComment> {
|
||||
if pending_comments.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Build a set of all GitHub comment IDs for orphan detection.
|
||||
let existing_ids: HashSet<&str> = pending_comments
|
||||
.iter()
|
||||
.map(|c| c.github_comment_id())
|
||||
.collect();
|
||||
|
||||
let mut roots: HashMap<&str, &PendingImportedReviewComment> = HashMap::new();
|
||||
let mut parent_to_children: HashMap<&str, Vec<&PendingImportedReviewComment>> = HashMap::new();
|
||||
|
||||
for comment in pending_comments.iter() {
|
||||
match comment.github_parent_comment_id() {
|
||||
Some(parent_id) if existing_ids.contains(parent_id) => {
|
||||
parent_to_children
|
||||
.entry(parent_id)
|
||||
.or_default()
|
||||
.push(comment);
|
||||
}
|
||||
Some(missing_parent_id) => {
|
||||
// Orphaned comment - parent doesn't exist, treat as root.
|
||||
log::warn!(
|
||||
"Importing orphaned comment (ID {:?}) with parent ID {:?}",
|
||||
comment.github_comment_id(),
|
||||
missing_parent_id
|
||||
);
|
||||
roots.insert(comment.github_comment_id(), comment);
|
||||
}
|
||||
None => {
|
||||
// Already a root comment.
|
||||
roots.insert(comment.github_comment_id(), comment);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let mut root_comments: Vec<_> = roots.values().copied().collect();
|
||||
root_comments.sort_by_key(|c| c.github_comment_id());
|
||||
|
||||
root_comments
|
||||
.into_iter()
|
||||
.map(|root| flatten_pending_imported_thread(root, &parent_to_children, repo_path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn flatten_pending_imported_thread(
|
||||
root: &PendingImportedReviewComment,
|
||||
children_map: &HashMap<&str, Vec<&PendingImportedReviewComment>>,
|
||||
repo_path: &Path,
|
||||
) -> AttachedReviewComment {
|
||||
const THREAD_REPLY_DIVIDER: &str = "\n---\n";
|
||||
|
||||
let mut thread_comments = Vec::new();
|
||||
collect_pending_imported_thread_dfs(root, children_map, &mut thread_comments);
|
||||
|
||||
let last_update_time = thread_comments
|
||||
.iter()
|
||||
.map(|c| c.last_update_time)
|
||||
.max()
|
||||
.unwrap_or(root.last_update_time);
|
||||
|
||||
let target = match &root.target {
|
||||
PendingImportedReviewCommentTarget::Line {
|
||||
relative_file_path,
|
||||
line,
|
||||
diff_content,
|
||||
} => AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path: repo_path.join(relative_file_path),
|
||||
line: line.clone(),
|
||||
content: diff_content.clone(),
|
||||
},
|
||||
PendingImportedReviewCommentTarget::File { relative_file_path } => {
|
||||
AttachedReviewCommentTarget::File {
|
||||
absolute_file_path: repo_path.join(relative_file_path),
|
||||
}
|
||||
}
|
||||
PendingImportedReviewCommentTarget::General => AttachedReviewCommentTarget::General,
|
||||
};
|
||||
|
||||
let mut combined_body = String::new();
|
||||
for (i, comment) in thread_comments.iter().enumerate() {
|
||||
if i > 0 {
|
||||
combined_body.push_str(THREAD_REPLY_DIVIDER);
|
||||
}
|
||||
|
||||
combined_body.push_str(&format!(
|
||||
"**@{}**:\n{}",
|
||||
comment.author(),
|
||||
comment.body.as_str()
|
||||
));
|
||||
}
|
||||
|
||||
let origin = CommentOrigin::ImportedFromGitHub(root.github_details_without_parent());
|
||||
|
||||
AttachedReviewComment {
|
||||
id: CommentId::new(),
|
||||
content: combined_body,
|
||||
target,
|
||||
last_update_time,
|
||||
base: None,
|
||||
head: None,
|
||||
outdated: false,
|
||||
origin,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_pending_imported_thread_dfs<'a>(
|
||||
comment: &'a PendingImportedReviewComment,
|
||||
children_map: &HashMap<&str, Vec<&'a PendingImportedReviewComment>>,
|
||||
result: &mut Vec<&'a PendingImportedReviewComment>,
|
||||
) {
|
||||
result.push(comment);
|
||||
|
||||
// Get children of this comment.
|
||||
if let Some(children) = children_map.get(comment.github_comment_id()) {
|
||||
let mut sorted_children = children.to_vec();
|
||||
sorted_children.sort_by(|a, b| a.last_update_time.cmp(&b.last_update_time));
|
||||
for child in sorted_children {
|
||||
collect_pending_imported_thread_dfs(child, children_map, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
mod batch;
|
||||
mod comment;
|
||||
pub(crate) mod convert;
|
||||
mod diff_hunk_parser;
|
||||
mod flatten;
|
||||
mod pending_imported;
|
||||
|
||||
pub(crate) use batch::{ReviewCommentBatch, ReviewCommentBatchEvent};
|
||||
pub(crate) use comment::{
|
||||
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin, LineDiffContent,
|
||||
};
|
||||
pub(crate) use convert::convert_insert_review_comments;
|
||||
pub(crate) use flatten::attach_pending_imported_comments;
|
||||
pub(crate) use pending_imported::{
|
||||
PendingImportedReviewComment, PendingImportedReviewCommentTarget,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use chrono::{DateTime, Local};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::comment::{ImportedCommentDetails, LineDiffContent};
|
||||
|
||||
/// Pending imported GitHub review comment.
|
||||
///
|
||||
/// This represents imported GitHub PR data after parsing timestamps and diff hunks, but before it
|
||||
/// is:
|
||||
/// * flattened (threads/replies combined),
|
||||
/// * converted from repo-relative paths to absolute paths,
|
||||
/// * attached/relocated against a local editor.
|
||||
///
|
||||
/// Invariants:
|
||||
/// * `target` file paths are repo-relative.
|
||||
/// * `body` is the raw GitHub comment body.
|
||||
/// * no notion of `outdated`, since there is no local diff/editor state to compare to yet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PendingImportedReviewComment {
|
||||
/// GitHub metadata such as remote comment IDs.
|
||||
pub(crate) github_details: ImportedCommentDetails,
|
||||
/// The raw comment body as provided by GitHub (no additional formatting).
|
||||
pub(crate) body: String,
|
||||
/// The most recent update time for this individual comment.
|
||||
pub(crate) last_update_time: DateTime<Local>,
|
||||
/// Where this comment was originally attached in the GitHub UI.
|
||||
pub(crate) target: PendingImportedReviewCommentTarget,
|
||||
}
|
||||
|
||||
impl PendingImportedReviewComment {
|
||||
pub(crate) fn author(&self) -> &str {
|
||||
&self.github_details.author
|
||||
}
|
||||
|
||||
pub(crate) fn github_comment_id(&self) -> &str {
|
||||
&self.github_details.github_comment_id
|
||||
}
|
||||
|
||||
pub(crate) fn github_parent_comment_id(&self) -> Option<&str> {
|
||||
self.github_details.github_parent_id.as_deref()
|
||||
}
|
||||
|
||||
/// Returns a copy of the GitHub metadata with any parent reference cleared.
|
||||
///
|
||||
/// Used when collapsing a threaded comment into a single flattened comment.
|
||||
pub(crate) fn github_details_without_parent(&self) -> ImportedCommentDetails {
|
||||
let mut details = self.github_details.clone();
|
||||
details.github_parent_id = None;
|
||||
details
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a pending imported review comment applies.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PendingImportedReviewCommentTarget {
|
||||
/// A comment attached to a specific diff line in a file.
|
||||
Line {
|
||||
/// Repo-relative path to the file.
|
||||
relative_file_path: PathBuf,
|
||||
/// A line location derived from the provider's diff hunk.
|
||||
line: EditorLineLocation,
|
||||
/// The diff line content at the target location.
|
||||
diff_content: LineDiffContent,
|
||||
},
|
||||
/// A comment attached to a file, but not a specific line.
|
||||
File {
|
||||
/// Repo-relative path to the file.
|
||||
relative_file_path: PathBuf,
|
||||
},
|
||||
/// A comment that applies to the entire diffset / PR.
|
||||
General,
|
||||
}
|
||||
|
||||
impl PendingImportedReviewCommentTarget {
|
||||
pub(crate) fn file_path(&self) -> Option<&PathBuf> {
|
||||
match self {
|
||||
PendingImportedReviewCommentTarget::Line {
|
||||
relative_file_path, ..
|
||||
} => Some(relative_file_path),
|
||||
PendingImportedReviewCommentTarget::File { relative_file_path } => {
|
||||
Some(relative_file_path)
|
||||
}
|
||||
PendingImportedReviewCommentTarget::General => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::ai::agent::DiffSetHunk;
|
||||
use crate::code_review::diff_state::{DiffLineType, FileDiff};
|
||||
use std::collections::HashMap;
|
||||
use warp_editor::render::model::LineCount;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
use std::path::Path;
|
||||
use crate::ai::agent::{AIAgentAttachment, CurrentHead, DiffBase};
|
||||
use crate::ai::blocklist::BlocklistAIContextModel;
|
||||
use crate::code_review::{diff_state::DiffMode, DiffSetScope};
|
||||
use warpui::{AppContext, ModelHandle};
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts file diffs into a HashMap of file paths to DiffSetHunks
|
||||
/// If repo_path is provided, file paths will be relative to the repo root
|
||||
pub fn convert_file_diffs_to_diffset_hunks<'a, I>(files: I) -> HashMap<String, Vec<DiffSetHunk>>
|
||||
where
|
||||
I: Iterator<Item = &'a FileDiff>,
|
||||
{
|
||||
let mut file_diffs: HashMap<String, Vec<DiffSetHunk>> = HashMap::new();
|
||||
|
||||
for file_diff in files {
|
||||
let file_path = file_diff.file_path.display().to_string();
|
||||
|
||||
let mut file_hunks = Vec::new();
|
||||
for hunk in file_diff.hunks.iter() {
|
||||
// Format the diff content for this hunk
|
||||
let mut diff_lines = Vec::new();
|
||||
let mut lines_added = 0;
|
||||
let mut lines_removed = 0;
|
||||
for line in &hunk.lines {
|
||||
let prefix = match line.line_type {
|
||||
DiffLineType::Add => {
|
||||
lines_added += 1;
|
||||
"+"
|
||||
}
|
||||
DiffLineType::Delete => {
|
||||
lines_removed += 1;
|
||||
"-"
|
||||
}
|
||||
DiffLineType::Context => "",
|
||||
DiffLineType::HunkHeader => continue,
|
||||
};
|
||||
diff_lines.push(format!("{}{}", prefix, line.text));
|
||||
}
|
||||
let diff_content = diff_lines.join("\n");
|
||||
|
||||
// Create line range using LineCount: Note that git lines are 1-based and LineCount is 0-based
|
||||
let line_range = LineCount::from(hunk.new_start_line.saturating_sub(1))
|
||||
..LineCount::from(hunk.new_start_line.saturating_sub(1) + hunk.new_line_count);
|
||||
|
||||
file_hunks.push(DiffSetHunk {
|
||||
line_range,
|
||||
diff_content,
|
||||
lines_added,
|
||||
lines_removed,
|
||||
});
|
||||
}
|
||||
|
||||
if !file_hunks.is_empty() {
|
||||
file_diffs.insert(file_path, file_hunks);
|
||||
}
|
||||
}
|
||||
|
||||
file_diffs
|
||||
}
|
||||
|
||||
/// Creates attachment reference and key for a set of changes based on scope and diff mode
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn create_attachment_reference_and_key(
|
||||
scope: &DiffSetScope,
|
||||
diff_mode: &DiffMode,
|
||||
main_branch_name: Option<&str>,
|
||||
repo_path: &Path,
|
||||
) -> (String, String) {
|
||||
match scope {
|
||||
DiffSetScope::All => {
|
||||
let diff_set_description = match diff_mode {
|
||||
DiffMode::Head => "uncommitted changes".to_string(),
|
||||
DiffMode::MainBranch => {
|
||||
let main_branch = main_branch_name.unwrap_or("main");
|
||||
format!("diffset against {main_branch}")
|
||||
}
|
||||
DiffMode::OtherBranch(branch_name) => {
|
||||
format!("diffset against {branch_name}")
|
||||
}
|
||||
};
|
||||
let key = diff_set_description.clone();
|
||||
(format!("<change:{key}>"), key)
|
||||
}
|
||||
DiffSetScope::File(file_path) => {
|
||||
let relative_path = if file_path.is_absolute() {
|
||||
file_path
|
||||
.strip_prefix(repo_path)
|
||||
.unwrap_or(file_path)
|
||||
.to_path_buf()
|
||||
} else {
|
||||
file_path.clone()
|
||||
};
|
||||
let key = relative_path.display().to_string();
|
||||
(format!("<change:{key}>"), key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a DiffSet attachment with the AI controller
|
||||
/// This encapsulates the common logic for creating and registering diff attachments
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn register_diffset_attachment(
|
||||
ai_context_model: &ModelHandle<BlocklistAIContextModel>,
|
||||
attachment_key: String,
|
||||
file_diffs: HashMap<String, Vec<DiffSetHunk>>,
|
||||
current: Option<CurrentHead>,
|
||||
base: DiffBase,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
// Create the DiffSet attachment
|
||||
let attachment = AIAgentAttachment::DiffSet {
|
||||
file_diffs,
|
||||
current,
|
||||
base,
|
||||
};
|
||||
|
||||
// Register the attachment with the AI controller
|
||||
ai_context_model.update(ctx, |context_model, _| {
|
||||
context_model.register_diff_hunk_attachment(attachment_key, attachment);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Overlay menu for the code review diff selector: pinned search input and
|
||||
//! a filtered list of label-only rows with a left check slot.
|
||||
use std::cmp;
|
||||
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_editor::editor::NavigationKey;
|
||||
use warpui::{
|
||||
color::ColorU,
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
|
||||
DispatchEventResult, DropShadow, Element, Empty, EventHandler, Flex, Highlight,
|
||||
MainAxisSize, MouseInBehavior, ParentElement, Radius, ScrollStateHandle, Scrollable,
|
||||
ScrollableElement, ScrollbarWidth, Text, UniformList, UniformListState,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
id,
|
||||
keymap::FixedBinding,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
code_review::{diff_selector::DiffTarget, diff_state::DiffMode},
|
||||
editor::{
|
||||
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
TextOptions,
|
||||
},
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
|
||||
const MENU_WIDTH: f32 = 280.;
|
||||
const MENU_MAX_LIST_HEIGHT: f32 = 200.;
|
||||
const MENU_CORNER_RADIUS: f32 = 6.;
|
||||
const ROW_HORIZONTAL_PADDING: f32 = 14.;
|
||||
const ROW_VERTICAL_PADDING: f32 = 5.;
|
||||
const LIST_BOTTOM_PADDING: f32 = 9.;
|
||||
const SEARCH_INPUT_HORIZONTAL_PADDING: f32 = 8.;
|
||||
const CHECK_GAP: f32 = 8.;
|
||||
const CHECK_SLOT_SIZE: f32 = 12.;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CodeReviewDiffMenuEvent {
|
||||
Select(DiffMode),
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CodeReviewDiffMenuAction {
|
||||
ClickRow { index: usize },
|
||||
HoverRow { index: usize },
|
||||
SelectUp,
|
||||
SelectDown,
|
||||
SelectEnter,
|
||||
Close,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"up",
|
||||
CodeReviewDiffMenuAction::SelectUp,
|
||||
id!(CodeReviewDiffMenu::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"down",
|
||||
CodeReviewDiffMenuAction::SelectDown,
|
||||
id!(CodeReviewDiffMenu::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
CodeReviewDiffMenuAction::SelectEnter,
|
||||
id!(CodeReviewDiffMenu::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
CodeReviewDiffMenuAction::Close,
|
||||
id!(CodeReviewDiffMenu::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
pub struct CodeReviewDiffMenu {
|
||||
targets: Vec<DiffTarget>,
|
||||
/// (target index, optional match result) pairs for rows that pass the
|
||||
/// current filter. Original target order is preserved; the match result
|
||||
/// carries indices for bolding matched characters in the label.
|
||||
filtered: Vec<(usize, Option<FuzzyMatchResult>)>,
|
||||
/// Index into `filtered` of the keyboard-focused row.
|
||||
selected_index: Option<usize>,
|
||||
search_input: ViewHandle<EditorView>,
|
||||
search_query: String,
|
||||
list_state: UniformListState,
|
||||
scroll_state: ScrollStateHandle,
|
||||
}
|
||||
|
||||
impl CodeReviewDiffMenu {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let search_input = ctx.add_typed_action_view(|ctx| {
|
||||
let appearance = Appearance::handle(ctx).as_ref(ctx);
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
|
||||
let mut text_options = TextOptions::ui_font_size(appearance);
|
||||
text_options.font_family_override = Some(ui_font_family);
|
||||
|
||||
let options = EditorOptions {
|
||||
autogrow: false,
|
||||
soft_wrap: false,
|
||||
single_line: true,
|
||||
text: text_options,
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::new(options, ctx);
|
||||
editor.set_placeholder_text("Search diff sets or branches to compare…", ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&search_input, |menu, _, event, ctx| match event {
|
||||
EditorEvent::Edited(_) => {
|
||||
let new_query = menu
|
||||
.search_input
|
||||
.read(ctx, |editor, ctx| editor.buffer_text(ctx).to_string());
|
||||
if new_query != menu.search_query {
|
||||
menu.set_search_query(new_query, ctx);
|
||||
}
|
||||
}
|
||||
EditorEvent::Escape => menu.emit_close(ctx),
|
||||
EditorEvent::Navigate(NavigationKey::Up) => menu.select_prev(ctx),
|
||||
EditorEvent::Navigate(NavigationKey::Down) => menu.select_next(ctx),
|
||||
EditorEvent::Enter => menu.select_enter(ctx),
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Self {
|
||||
targets: Vec::new(),
|
||||
filtered: Vec::new(),
|
||||
selected_index: None,
|
||||
search_input,
|
||||
search_query: String::new(),
|
||||
list_state: Default::default(),
|
||||
scroll_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the row set and reset filter/scroll to the top.
|
||||
pub fn set_targets(&mut self, targets: Vec<DiffTarget>, ctx: &mut ViewContext<Self>) {
|
||||
self.targets = targets;
|
||||
self.refresh_filtered();
|
||||
self.scroll_list_to_top();
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Reset the menu to a fresh state: empty query, selection/scroll back
|
||||
/// to the top, and rows re-computed against the current targets.
|
||||
pub fn reset(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.search_query.clear();
|
||||
self.search_input.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer(ctx);
|
||||
});
|
||||
self.refresh_filtered();
|
||||
self.scroll_list_to_top();
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn set_search_query(&mut self, query: String, ctx: &mut ViewContext<Self>) {
|
||||
self.search_query = query;
|
||||
self.refresh_filtered();
|
||||
self.scroll_list_to_top();
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn scroll_list_to_top(&mut self) {
|
||||
if !self.filtered.is_empty() {
|
||||
self.list_state.scroll_to(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_filtered(&mut self) {
|
||||
if self.search_query.is_empty() {
|
||||
self.filtered = (0..self.targets.len()).map(|i| (i, None)).collect();
|
||||
} else {
|
||||
// Use fuzzy matching for membership only; preserve original order
|
||||
// so the list never reorders under the user as they type.
|
||||
self.filtered = self
|
||||
.targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, target)| {
|
||||
match_indices_case_insensitive(&target.label, &self.search_query)
|
||||
.map(|m| (i, Some(m)))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.selected_index = if self.filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
|
||||
fn select_prev(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(current) = self.selected_index else {
|
||||
return;
|
||||
};
|
||||
if current == 0 {
|
||||
return;
|
||||
}
|
||||
self.selected_index = Some(current - 1);
|
||||
self.list_state.scroll_to(current - 1);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn select_next(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.filtered.is_empty() {
|
||||
return;
|
||||
}
|
||||
let next = match self.selected_index {
|
||||
Some(i) if i + 1 < self.filtered.len() => i + 1,
|
||||
Some(i) => i,
|
||||
None => 0,
|
||||
};
|
||||
self.selected_index = Some(next);
|
||||
self.list_state.scroll_to(next);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn select_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(selected) = self.selected_index else {
|
||||
return;
|
||||
};
|
||||
self.select_filtered_index(selected, ctx);
|
||||
}
|
||||
|
||||
fn select_filtered_index(&mut self, filtered_index: usize, ctx: &mut ViewContext<Self>) {
|
||||
let Some((target_index, _)) = self.filtered.get(filtered_index).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(target) = self.targets.get(target_index) else {
|
||||
return;
|
||||
};
|
||||
let mode = target.mode.clone();
|
||||
ctx.emit(CodeReviewDiffMenuEvent::Select(mode));
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn emit_close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(CodeReviewDiffMenuEvent::Close);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_search_input(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let search_input = appearance
|
||||
.ui_builder()
|
||||
.text_input(self.search_input.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
background: Some(Fill::Solid(ColorU::new(0, 0, 0, 0)).into()),
|
||||
border_color: None,
|
||||
border_width: Some(0.),
|
||||
border_radius: None,
|
||||
width: Some(MENU_WIDTH - (SEARCH_INPUT_HORIZONTAL_PADDING * 2.)),
|
||||
padding: Some(Coords::uniform(4.)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
// Search bar inherits the menu card surface; a hairline bottom
|
||||
// border separates it from the row list below.
|
||||
Container::new(search_input)
|
||||
.with_horizontal_padding(SEARCH_INPUT_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(2.)
|
||||
.with_border(Border::bottom(1.0).with_border_color(theme.outline().into()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_empty_state(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
Container::new(
|
||||
Text::new(
|
||||
"No matches",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.surface_2()).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(ROW_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(ROW_VERTICAL_PADDING * 2.0)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_rows(&self, ctx: &AppContext) -> Box<dyn Element> {
|
||||
if self.filtered.is_empty() {
|
||||
if !self.search_query.is_empty() {
|
||||
return self.render_empty_state(Appearance::as_ref(ctx));
|
||||
}
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let theme = appearance.theme();
|
||||
let selected = self.selected_index;
|
||||
let filtered_snapshot: Vec<(DiffTarget, Option<FuzzyMatchResult>)> = self
|
||||
.filtered
|
||||
.iter()
|
||||
.filter_map(|(i, m)| self.targets.get(*i).cloned().map(|t| (t, m.clone())))
|
||||
.collect();
|
||||
let filtered_len = filtered_snapshot.len();
|
||||
|
||||
let list = UniformList::new(
|
||||
self.list_state.clone(),
|
||||
filtered_len,
|
||||
move |mut range, app| {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
range.end = cmp::min(range.end, filtered_len);
|
||||
range
|
||||
.map(|row_index| {
|
||||
let (target, match_result) = &filtered_snapshot[row_index];
|
||||
let is_focused = selected == Some(row_index);
|
||||
let font_size = appearance.ui_font_size();
|
||||
|
||||
let (text_color, bg) = if is_focused {
|
||||
let bg = theme.accent();
|
||||
(theme.main_text_color(bg).into_solid(), Some(bg))
|
||||
} else {
|
||||
(theme.main_text_color(theme.surface_2()).into_solid(), None)
|
||||
};
|
||||
|
||||
let check_slot: Box<dyn Element> = if target.is_selected {
|
||||
ConstrainedBox::new(
|
||||
Icon::Check.to_warpui_icon(Fill::Solid(text_color)).finish(),
|
||||
)
|
||||
.with_width(CHECK_SLOT_SIZE)
|
||||
.with_height(CHECK_SLOT_SIZE)
|
||||
.finish()
|
||||
} else {
|
||||
ConstrainedBox::new(Empty::new().finish())
|
||||
.with_width(CHECK_SLOT_SIZE)
|
||||
.with_height(CHECK_SLOT_SIZE)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let label_text = Text::new_inline(
|
||||
target.label.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(text_color);
|
||||
let label = match match_result {
|
||||
Some(m) => label_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(text_color),
|
||||
m.matched_indices.clone(),
|
||||
),
|
||||
None => label_text,
|
||||
}
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(check_slot)
|
||||
.with_margin_right(CHECK_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(label)
|
||||
.finish();
|
||||
|
||||
let mut container = Container::new(row)
|
||||
.with_horizontal_padding(ROW_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(ROW_VERTICAL_PADDING);
|
||||
|
||||
if let Some(bg) = bg {
|
||||
container = container.with_background(bg);
|
||||
}
|
||||
|
||||
EventHandler::new(container.finish())
|
||||
.on_left_mouse_down(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CodeReviewDiffMenuAction::ClickRow {
|
||||
index: row_index,
|
||||
});
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_mouse_in(
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CodeReviewDiffMenuAction::HoverRow {
|
||||
index: row_index,
|
||||
});
|
||||
ctx.notify();
|
||||
DispatchEventResult::StopPropagation
|
||||
},
|
||||
Some(MouseInBehavior {
|
||||
fire_on_synthetic_events: false,
|
||||
fire_when_covered: false,
|
||||
}),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
},
|
||||
);
|
||||
|
||||
let scrollable = Scrollable::vertical(
|
||||
self.scroll_state.clone(),
|
||||
list.finish_scrollable(),
|
||||
ScrollbarWidth::None,
|
||||
theme.nonactive_ui_detail().into(),
|
||||
theme.active_ui_detail().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.with_padding_end(0.)
|
||||
.with_padding_start(0.);
|
||||
|
||||
ConstrainedBox::new(scrollable.finish())
|
||||
.with_width(MENU_WIDTH)
|
||||
.with_max_height(MENU_MAX_LIST_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CodeReviewDiffMenu {
|
||||
type Event = CodeReviewDiffMenuEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for CodeReviewDiffMenu {
|
||||
type Action = CodeReviewDiffMenuAction;
|
||||
|
||||
fn handle_action(&mut self, action: &CodeReviewDiffMenuAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
CodeReviewDiffMenuAction::ClickRow { index } => {
|
||||
// Sync keyboard selection with the clicked row.
|
||||
if *index < self.filtered.len() {
|
||||
self.selected_index = Some(*index);
|
||||
self.select_filtered_index(*index, ctx);
|
||||
}
|
||||
}
|
||||
CodeReviewDiffMenuAction::HoverRow { index } => {
|
||||
if *index < self.filtered.len() && self.selected_index != Some(*index) {
|
||||
self.selected_index = Some(*index);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
CodeReviewDiffMenuAction::SelectUp => self.select_prev(ctx),
|
||||
CodeReviewDiffMenuAction::SelectDown => self.select_next(ctx),
|
||||
CodeReviewDiffMenuAction::SelectEnter => self.select_enter(ctx),
|
||||
CodeReviewDiffMenuAction::Close => self.emit_close(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for CodeReviewDiffMenu {
|
||||
fn ui_name() -> &'static str {
|
||||
"CodeReviewDiffMenu"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.focus(&self.search_input);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut column = Flex::column().with_child(self.render_search_input(appearance));
|
||||
if !self.targets.is_empty() || !self.search_query.is_empty() {
|
||||
column.add_child(
|
||||
Container::new(self.render_rows(app))
|
||||
.with_padding_bottom(LIST_BOTTOM_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let menu_card = ConstrainedBox::new(
|
||||
Container::new(column.finish())
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS)))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MENU_WIDTH)
|
||||
.finish();
|
||||
|
||||
Dismiss::new(menu_card)
|
||||
.on_dismiss(|ctx, _app| {
|
||||
ctx.dispatch_typed_action(CodeReviewDiffMenuAction::Close);
|
||||
})
|
||||
.prevent_interaction_with_other_elements()
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Trigger button + [`CodeReviewDiffMenu`] overlay for picking the diff
|
||||
//! target in the code review header.
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Element, Flex, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Radius, Stack, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
id,
|
||||
keymap::FixedBinding,
|
||||
platform::Cursor,
|
||||
text_layout::ClipConfig,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Entity, FocusContext, SingletonEntity as _, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
code_review::{
|
||||
diff_menu::{CodeReviewDiffMenu, CodeReviewDiffMenuEvent},
|
||||
diff_state::DiffMode,
|
||||
},
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
|
||||
/// A single selectable target in the diff selector menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffTarget {
|
||||
pub label: String,
|
||||
pub mode: DiffMode,
|
||||
pub is_selected: bool,
|
||||
}
|
||||
|
||||
impl DiffTarget {
|
||||
pub fn new(label: impl Into<String>, mode: DiffMode, is_selected: bool) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
mode,
|
||||
is_selected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BUTTON_LABEL_MAX_WIDTH: f32 = 240.;
|
||||
const MENU_OFFSET_Y: f32 = 4.;
|
||||
const BUTTON_CORNER_RADIUS: f32 = 4.;
|
||||
const BUTTON_VERTICAL_PADDING: f32 = 5.;
|
||||
const BUTTON_HORIZONTAL_PADDING: f32 = 8.;
|
||||
|
||||
pub struct DiffSelector {
|
||||
menu: ViewHandle<CodeReviewDiffMenu>,
|
||||
menu_open: bool,
|
||||
trigger_mouse_state: MouseStateHandle,
|
||||
/// Cached label for the trigger button; mirrors the selected `DiffTarget`.
|
||||
trigger_label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DiffSelectorAction {
|
||||
Toggle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DiffSelectorEvent {
|
||||
SelectMode(DiffMode),
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"enter",
|
||||
DiffSelectorAction::Toggle,
|
||||
id!(DiffSelector::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
impl DiffSelector {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let menu = ctx.add_typed_action_view(CodeReviewDiffMenu::new);
|
||||
|
||||
ctx.subscribe_to_view(&menu, |me, _, event, ctx| match event {
|
||||
CodeReviewDiffMenuEvent::Select(mode) => {
|
||||
me.menu_open = false;
|
||||
ctx.emit(DiffSelectorEvent::SelectMode(mode.clone()));
|
||||
ctx.notify();
|
||||
}
|
||||
CodeReviewDiffMenuEvent::Close => {
|
||||
me.menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu,
|
||||
menu_open: false,
|
||||
trigger_mouse_state: MouseStateHandle::default(),
|
||||
trigger_label: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.menu_open {
|
||||
self.close(ctx);
|
||||
} else {
|
||||
self.menu_open = true;
|
||||
self.menu.update(ctx, |menu, ctx| menu.reset(ctx));
|
||||
ctx.focus(&self.menu);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.menu_open {
|
||||
self.menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the menu rows. Trigger label follows the selected target.
|
||||
pub fn set_targets(&mut self, targets: Vec<DiffTarget>, ctx: &mut ViewContext<Self>) {
|
||||
self.trigger_label = targets
|
||||
.iter()
|
||||
.find(|target| target.is_selected)
|
||||
.map(|target| target.label.clone())
|
||||
.unwrap_or_default();
|
||||
self.menu.update(ctx, |menu, ctx| {
|
||||
menu.set_targets(targets, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DiffSelector {
|
||||
type Event = DiffSelectorEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for DiffSelector {
|
||||
type Action = DiffSelectorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &DiffSelectorAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
DiffSelectorAction::Toggle => self.toggle(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for DiffSelector {
|
||||
fn ui_name() -> &'static str {
|
||||
"CodeReviewDiffSelector"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() && self.menu_open {
|
||||
ctx.focus(&self.menu);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let text_color = theme.main_text_color(theme.background()).into_solid();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
|
||||
let label = if self.trigger_label.is_empty() {
|
||||
"Uncommitted changes".to_string()
|
||||
} else {
|
||||
self.trigger_label.clone()
|
||||
};
|
||||
|
||||
// Build the icon+text row by hand: `with_text_and_icon_label` wraps
|
||||
// the text in `Shrinkable<flex=1>`, which makes the button grow to
|
||||
// fill available width and paints an oversized hover rectangle even
|
||||
// for short labels.
|
||||
let icon = ConstrainedBox::new(
|
||||
Icon::SwitchHorizontal01
|
||||
.to_warpui_icon(Fill::Solid(text_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(15.)
|
||||
.with_height(15.)
|
||||
.finish();
|
||||
|
||||
// Truncate long branch names. Capping the text directly avoids
|
||||
// double-constraining padding from an outer wrapper.
|
||||
let label_text = ConstrainedBox::new(
|
||||
Text::new_inline(label, font_family, font_size)
|
||||
.with_color(text_color)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.with_clip(ClipConfig::ellipsis())
|
||||
.finish(),
|
||||
)
|
||||
.with_max_width(BUTTON_LABEL_MAX_WIDTH)
|
||||
.finish();
|
||||
|
||||
let custom_label = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(icon)
|
||||
.with_child(Container::new(label_text).with_margin_left(6.).finish())
|
||||
.finish();
|
||||
|
||||
// Shared hover + active styles so the fill persists when the pointer
|
||||
// moves into the menu's search input.
|
||||
let hover_or_active_styles = UiComponentStyles {
|
||||
background: Some(theme.surface_2().into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(BUTTON_CORNER_RADIUS))),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, self.trigger_mouse_state.clone())
|
||||
.with_custom_label(custom_label)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(font_family),
|
||||
font_size: Some(font_size),
|
||||
font_color: Some(text_color),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
padding: Some(Coords {
|
||||
top: BUTTON_VERTICAL_PADDING,
|
||||
bottom: BUTTON_VERTICAL_PADDING,
|
||||
left: BUTTON_HORIZONTAL_PADDING,
|
||||
right: BUTTON_HORIZONTAL_PADDING,
|
||||
}),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(BUTTON_CORNER_RADIUS))),
|
||||
..Default::default()
|
||||
})
|
||||
.with_hovered_styles(hover_or_active_styles)
|
||||
.with_active_styles(hover_or_active_styles);
|
||||
|
||||
if self.menu_open {
|
||||
button = button.active();
|
||||
}
|
||||
|
||||
let trigger = button
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(DiffSelectorAction::Toggle);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(trigger);
|
||||
|
||||
if self.menu_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&self.menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., MENU_OFFSET_Y),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(stack.finish()).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::diff_state::{DiffHunk, DiffLineType};
|
||||
|
||||
/**
|
||||
* Maximum diff size that we will attempt to render. Diffs larger than this
|
||||
* should not be rendered to avoid performance issues.
|
||||
*/
|
||||
const MAX_DIFF_SIZE: usize = 4_375_000; // 4.375MB in decimal
|
||||
|
||||
/**
|
||||
* Reasonable limit for diff size. Diffs bigger than this _could_ be displayed
|
||||
* but it might cause some slowness.
|
||||
*/
|
||||
const MAX_REASONABLE_DIFF_SIZE: usize = 2_187_500; // ~2.1875MB in decimal
|
||||
|
||||
/**
|
||||
* The longest line length we should try to display. If a diff has a line longer
|
||||
* than this, we don't attempt to render it.
|
||||
*/
|
||||
const MAX_CHARACTERS_PER_LINE: usize = 5000;
|
||||
|
||||
/**
|
||||
* Current line-based limit for auto-expansion in code review.
|
||||
* This exists separately from the new size-based limits.
|
||||
*/
|
||||
const DIFF_LINE_RENDER_LIMIT: usize = 10_000;
|
||||
|
||||
/**
|
||||
* We have a lower deletion line limit since rendering deleted chunks are more
|
||||
* performance intensive.
|
||||
*/
|
||||
const DELETION_LINE_RENDER_LIMIT: usize = 8000;
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DiffSize {
|
||||
/// Small diff that can be rendered normally
|
||||
Normal,
|
||||
/// Large diff that should be collapsed by default but can be expanded
|
||||
Large,
|
||||
/// Diff that's too large to render safely
|
||||
Unrenderable,
|
||||
}
|
||||
|
||||
/// Determines if a diff size exceeds the maximum renderable limit
|
||||
fn is_diff_unrenderable(buffer_length: usize) -> bool {
|
||||
buffer_length > MAX_DIFF_SIZE
|
||||
}
|
||||
|
||||
/// Determines if a diff buffer is too large for reasonable rendering
|
||||
fn is_buffer_too_large(buffer_length: usize) -> bool {
|
||||
buffer_length >= MAX_REASONABLE_DIFF_SIZE
|
||||
}
|
||||
|
||||
/// Determines if a diff has any line that's too long
|
||||
fn is_diff_too_large(diff: &[DiffHunk]) -> bool {
|
||||
diff.iter()
|
||||
.flat_map(|hunk| &hunk.lines)
|
||||
.any(|line| line.text.len() > MAX_CHARACTERS_PER_LINE)
|
||||
}
|
||||
|
||||
/// Categorizes a diff based on multiple size heuristics
|
||||
pub fn compute_diff_size(diffs: &[DiffHunk], diff_size: usize) -> DiffSize {
|
||||
if is_diff_unrenderable(diff_size) {
|
||||
return DiffSize::Unrenderable;
|
||||
}
|
||||
|
||||
let additions = diffs
|
||||
.iter()
|
||||
.flat_map(|hunk| &hunk.lines)
|
||||
.filter(|line| line.line_type == DiffLineType::Add)
|
||||
.count();
|
||||
|
||||
let deletions = diffs
|
||||
.iter()
|
||||
.flat_map(|hunk| &hunk.lines)
|
||||
.filter(|line| line.line_type == DiffLineType::Delete)
|
||||
.count();
|
||||
|
||||
// To avoid performance issues, set a lower render limit for deletion lines.
|
||||
if deletions > DELETION_LINE_RENDER_LIMIT {
|
||||
return DiffSize::Unrenderable;
|
||||
}
|
||||
|
||||
if is_buffer_too_large(diff_size)
|
||||
|| is_diff_too_large(diffs)
|
||||
|| additions > DIFF_LINE_RENDER_LIMIT
|
||||
|| deletions > DIFF_LINE_RENDER_LIMIT
|
||||
{
|
||||
return DiffSize::Large;
|
||||
}
|
||||
|
||||
DiffSize::Normal
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_with_comma() {
|
||||
let (start, count) = DiffStateModel::parse_range("10,5")
|
||||
.expect("parse_range should succeed for range with count");
|
||||
assert_eq!(start, 10);
|
||||
assert_eq!(count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range_without_comma() {
|
||||
let (start, count) = DiffStateModel::parse_range("10")
|
||||
.expect("parse_range should succeed for range without count");
|
||||
assert_eq!(start, 10);
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unified_diff_header_basic() {
|
||||
let header = "@@ -10,5 +12,7 @@";
|
||||
let parsed = DiffStateModel::parse_unified_diff_header(header)
|
||||
.expect("parse_unified_diff_header should succeed for basic header");
|
||||
assert_eq!(parsed.old_start_line, 10);
|
||||
assert_eq!(parsed.old_line_count, 5);
|
||||
assert_eq!(parsed.new_start_line, 12);
|
||||
assert_eq!(parsed.new_line_count, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unified_diff_header_with_context() {
|
||||
let header = "@@ -4978,33 +4978,43 @@ impl TerminalView {";
|
||||
let parsed = DiffStateModel::parse_unified_diff_header(header)
|
||||
.expect("parse_unified_diff_header should succeed for header with context");
|
||||
assert_eq!(parsed.old_start_line, 4978);
|
||||
assert_eq!(parsed.old_line_count, 33);
|
||||
assert_eq!(parsed.new_start_line, 4978);
|
||||
assert_eq!(parsed.new_line_count, 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unified_diff_header_single_line() {
|
||||
let header = "@@ -10 +12,3 @@";
|
||||
let parsed = DiffStateModel::parse_unified_diff_header(header)
|
||||
.expect("parse_unified_diff_header should succeed for single line header");
|
||||
assert_eq!(parsed.old_start_line, 10);
|
||||
assert_eq!(parsed.old_line_count, 1);
|
||||
assert_eq!(parsed.new_start_line, 12);
|
||||
assert_eq!(parsed.new_line_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_branches_main_first_empty() {
|
||||
let branches: Vec<(String, bool)> = vec![];
|
||||
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches).collect();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_branches_main_first_no_main() {
|
||||
let branches = vec![
|
||||
("feature-a".to_string(), false),
|
||||
("feature-b".to_string(), false),
|
||||
("feature-c".to_string(), false),
|
||||
];
|
||||
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches).collect();
|
||||
// No main branches — order should be unchanged.
|
||||
assert_eq!(result, branches.iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_branches_main_first_promotes_main() {
|
||||
let branches = vec![
|
||||
("feature-a".to_string(), false),
|
||||
("main".to_string(), true),
|
||||
("feature-b".to_string(), false),
|
||||
];
|
||||
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect();
|
||||
assert_eq!(result, vec!["main", "feature-a", "feature-b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_branches_main_first_main_already_first() {
|
||||
let branches = vec![
|
||||
("main".to_string(), true),
|
||||
("feature-a".to_string(), false),
|
||||
("feature-b".to_string(), false),
|
||||
];
|
||||
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect();
|
||||
assert_eq!(result, vec!["main", "feature-a", "feature-b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_branches_main_first_preserves_recency_order_for_non_main() {
|
||||
// Non-main branches should remain in their original (recency) order.
|
||||
let branches = vec![
|
||||
("recent-feature".to_string(), false),
|
||||
("main".to_string(), true),
|
||||
("older-feature".to_string(), false),
|
||||
("oldest-feature".to_string(), false),
|
||||
];
|
||||
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec!["main", "recent-feature", "older-feature", "oldest-feature"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_branches_main_first_multiple_main_flags() {
|
||||
// Defensive: both flagged as main (shouldn't happen in practice, but
|
||||
// sort_branches_main_first should handle it gracefully).
|
||||
let branches = vec![
|
||||
("feature".to_string(), false),
|
||||
("main".to_string(), true),
|
||||
("master".to_string(), true),
|
||||
];
|
||||
let result: Vec<_> = DiffStateModel::sort_branches_main_first(&branches)
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect();
|
||||
// Both main-flagged entries appear first, non-main last.
|
||||
assert_eq!(result, vec!["main", "master", "feature"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unified_diff_header_malformed() {
|
||||
let header = "not a diff header";
|
||||
let result = DiffStateModel::parse_unified_diff_header(header);
|
||||
assert!(result.is_err());
|
||||
|
||||
let header2 = "@@ incomplete";
|
||||
let result2 = DiffStateModel::parse_unified_diff_header(header2);
|
||||
assert!(result2.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_modified_file_with_spaces() {
|
||||
// Porcelain v2 output for a modified file with spaces in the name.
|
||||
// Format: 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
|
||||
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 test file.txt";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, std::path::PathBuf::from("test file.txt"));
|
||||
assert_eq!(result[0].1, GitFileStatus::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_modified_file_with_multiple_spaces() {
|
||||
// Filename with multiple spaces.
|
||||
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 path to/my test file.txt";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(
|
||||
result[0].0,
|
||||
std::path::PathBuf::from("path to/my test file.txt")
|
||||
);
|
||||
assert_eq!(result[0].1, GitFileStatus::Modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_new_file_with_spaces() {
|
||||
let status_output = "1 A. N... 000000 100644 100644 0000000 abc1234 new file name.rs";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, std::path::PathBuf::from("new file name.rs"));
|
||||
assert_eq!(result[0].1, GitFileStatus::New);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_renamed_file_with_spaces() {
|
||||
// Porcelain v2 renamed entry (type 2) with spaces in the new path.
|
||||
// Format: 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
|
||||
let status_output =
|
||||
"2 R. N... 100644 100644 100644 abc1234 def5678 R100 new name.txt\0old name.txt";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, std::path::PathBuf::from("new name.txt"));
|
||||
assert!(matches!(
|
||||
&result[0].1,
|
||||
GitFileStatus::Renamed { old_path } if old_path == "old name.txt"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_untracked_file_with_spaces() {
|
||||
let status_output = "? my untracked file.txt";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(
|
||||
result[0].0,
|
||||
std::path::PathBuf::from("my untracked file.txt")
|
||||
);
|
||||
assert_eq!(result[0].1, GitFileStatus::Untracked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_unmerged_file_with_spaces() {
|
||||
// Porcelain v2 unmerged entry (type u) with spaces in the path.
|
||||
// Format: u <xy> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
|
||||
let status_output =
|
||||
"u UU N... 100644 100644 100644 100644 abc1234 def5678 ghi9012 conflict file.txt";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, std::path::PathBuf::from("conflict file.txt"));
|
||||
assert_eq!(result[0].1, GitFileStatus::Conflicted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_mixed_entries_with_spaces() {
|
||||
// Multiple entries separated by NUL, mixing files with and without spaces.
|
||||
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 test file.txt\0\
|
||||
1 .M N... 100644 100644 100644 abc1234 def5678 normal.txt\0\
|
||||
? another file with spaces.rs";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].0, std::path::PathBuf::from("test file.txt"));
|
||||
assert_eq!(result[1].0, std::path::PathBuf::from("normal.txt"));
|
||||
assert_eq!(
|
||||
result[2].0,
|
||||
std::path::PathBuf::from("another file with spaces.rs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status_file_without_spaces_still_works() {
|
||||
// Ensure the splitn change doesn't break files without spaces.
|
||||
let status_output = "1 .M N... 100644 100644 100644 abc1234 def5678 simple.txt";
|
||||
let result = DiffStateModel::parse_git_status(status_output).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, std::path::PathBuf::from("simple.txt"));
|
||||
assert_eq!(result[0].1, GitFileStatus::Modified);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::{AppContext, ViewHandle};
|
||||
|
||||
use crate::code::local_code_editor::LocalCodeEditorView;
|
||||
|
||||
pub struct CodeReviewEditorState {
|
||||
pub editor: ViewHandle<LocalCodeEditorView>,
|
||||
unsaved_changes_mouse_state: MouseStateHandle,
|
||||
pub(super) editor_mouse_state: MouseStateHandle,
|
||||
/// Whether the buffer content has been loaded from disk (for global buffer mode).
|
||||
/// This is set to true when LocalCodeEditorEvent::DelayedRenderingFlushed or FailedToLoad fires.
|
||||
is_loaded: bool,
|
||||
}
|
||||
|
||||
impl CodeReviewEditorState {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(editor: ViewHandle<LocalCodeEditorView>) -> Self {
|
||||
Self {
|
||||
editor,
|
||||
unsaved_changes_mouse_state: MouseStateHandle::default(),
|
||||
editor_mouse_state: MouseStateHandle::default(),
|
||||
is_loaded: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new editor state that is already marked as loaded.
|
||||
/// Used for non-global buffer mode where content is loaded synchronously.
|
||||
pub fn new_loaded(editor: ViewHandle<LocalCodeEditorView>) -> Self {
|
||||
Self {
|
||||
editor,
|
||||
unsaved_changes_mouse_state: MouseStateHandle::default(),
|
||||
editor_mouse_state: MouseStateHandle::default(),
|
||||
is_loaded: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the buffer content has been loaded.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.is_loaded
|
||||
}
|
||||
|
||||
/// Marks the editor as loaded.
|
||||
pub fn set_loaded(&mut self) {
|
||||
self.is_loaded = true;
|
||||
}
|
||||
|
||||
pub fn editor(&self) -> &ViewHandle<LocalCodeEditorView> {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
pub fn unsaved_changes_mouse_state(&self) -> MouseStateHandle {
|
||||
self.unsaved_changes_mouse_state.clone()
|
||||
}
|
||||
|
||||
pub fn has_unsaved_changes(&self, ctx: &AppContext) -> bool {
|
||||
self.editor.as_ref(ctx).has_unsaved_changes(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
|
||||
use warp_core::sync_queue::{IsTransientError, SyncQueueTaskTrait};
|
||||
|
||||
use super::diff_state::{DiffMode, DiffStateModel, FileDiffAndContent};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error(transparent)]
|
||||
pub struct FileInvalidationError(#[from] anyhow::Error);
|
||||
|
||||
impl IsTransientError for FileInvalidationError {
|
||||
fn is_transient(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileInvalidationTask {
|
||||
pub file: PathBuf,
|
||||
pub repo_path: PathBuf,
|
||||
pub mode: DiffMode,
|
||||
pub merge_base: Option<String>,
|
||||
}
|
||||
|
||||
impl SyncQueueTaskTrait for FileInvalidationTask {
|
||||
type Error = FileInvalidationError;
|
||||
type Result = (PathBuf, Option<FileDiffAndContent>);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type Fut = Pin<Box<dyn Future<Output = Result<Self::Result, Self::Error>> + Send>>;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
type Fut = Pin<Box<dyn Future<Output = Result<Self::Result, Self::Error>>>>;
|
||||
|
||||
fn run(&mut self) -> Self::Fut {
|
||||
let repo_path = self.repo_path.clone();
|
||||
let file = self.file.clone();
|
||||
let mode = self.mode.clone();
|
||||
let merge_base = self.merge_base.clone();
|
||||
Box::pin(async move {
|
||||
DiffStateModel::retrieve_diff_state(&repo_path, &file, &mode, merge_base.as_deref())
|
||||
.await
|
||||
.map_err(FileInvalidationError::from)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
use crate::code::local_code_editor::LocalCodeEditorView;
|
||||
use crate::code_review::code_review_view::CodeReviewView;
|
||||
use crate::code_review::telemetry_event::CodeReviewTelemetryEvent;
|
||||
use crate::view_components::find::{FindDirection, FindEvent, FindModel};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use string_offset::CharOffset;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warp_editor::content::find::SearchConfig;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warp_editor::search::Searcher;
|
||||
use warp_editor::search::{RestorableSearchResults, SelectedResult};
|
||||
use warpui::WeakViewHandle;
|
||||
use warpui::{
|
||||
r#async::SpawnedFutureHandle, AppContext, Entity, EntityId, ModelContext, ViewHandle,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchMatch {
|
||||
pub editor_id: EntityId,
|
||||
pub start_offset: CharOffset,
|
||||
pub end_offset: CharOffset,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MultiEditorSelectedResult {
|
||||
pub editor_id: EntityId,
|
||||
pub selected_result: SelectedResult,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
pub struct MultiEditorSearchMatches {
|
||||
editor_id: EntityId,
|
||||
matches: Vec<SearchMatch>,
|
||||
}
|
||||
|
||||
impl RestorableSearchResults for MultiEditorSearchMatches {
|
||||
fn valid_matches(&self) -> impl Iterator<Item = (usize, CharOffset)> {
|
||||
self.matches
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(move |(_, m)| m.editor_id == self.editor_id)
|
||||
.map(|(index, m)| (index, m.start_offset))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SelectedMatchInfo {
|
||||
pub editor_id: EntityId,
|
||||
pub index_within_editor: usize,
|
||||
pub start_offset: CharOffset,
|
||||
pub end_offset: CharOffset,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub struct CodeReviewFindModel {
|
||||
query_text: String,
|
||||
case_sensitive: bool,
|
||||
regex: bool,
|
||||
results: Option<Vec<SearchMatch>>,
|
||||
selected_match: Option<MultiEditorSelectedResult>,
|
||||
search_handle: Option<SpawnedFutureHandle>,
|
||||
is_find_bar_open: bool,
|
||||
weak_view_handle: WeakViewHandle<CodeReviewView>,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
impl CodeReviewFindModel {
|
||||
pub fn new(
|
||||
weak_view_handle: WeakViewHandle<CodeReviewView>,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
query_text: String::new(),
|
||||
case_sensitive: false,
|
||||
regex: false,
|
||||
results: None,
|
||||
search_handle: None,
|
||||
is_find_bar_open: false,
|
||||
selected_match: None,
|
||||
weak_view_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_find_bar_open(&self) -> bool {
|
||||
self.is_find_bar_open
|
||||
}
|
||||
|
||||
pub fn set_is_find_bar_open(&mut self, is_open: bool) {
|
||||
self.is_find_bar_open = is_open;
|
||||
}
|
||||
|
||||
pub fn clear_results(&mut self) {
|
||||
self.results = None;
|
||||
}
|
||||
|
||||
pub fn update_query(
|
||||
&mut self,
|
||||
query: Option<String>,
|
||||
editor_handles: impl Iterator<Item = ViewHandle<LocalCodeEditorView>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.query_text = query.unwrap_or_default();
|
||||
self.run_search(editor_handles, ctx);
|
||||
}
|
||||
|
||||
pub fn set_case_sensitive(
|
||||
&mut self,
|
||||
case_sensitive: bool,
|
||||
editor_handles: impl Iterator<Item = ViewHandle<LocalCodeEditorView>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.case_sensitive = case_sensitive;
|
||||
send_telemetry_from_ctx!(
|
||||
CodeReviewTelemetryEvent::FindBarModeChanged {
|
||||
case_sensitive: self.case_sensitive,
|
||||
regex: self.regex,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.run_search(editor_handles, ctx);
|
||||
}
|
||||
|
||||
pub fn set_regex(
|
||||
&mut self,
|
||||
regex: bool,
|
||||
editor_handles: impl Iterator<Item = ViewHandle<LocalCodeEditorView>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.regex = regex;
|
||||
send_telemetry_from_ctx!(
|
||||
CodeReviewTelemetryEvent::FindBarModeChanged {
|
||||
case_sensitive: self.case_sensitive,
|
||||
regex: self.regex,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.run_search(editor_handles, ctx);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn focus_next_find_match(
|
||||
&mut self,
|
||||
direction: FindDirection,
|
||||
mut editor_handles: impl Iterator<Item = ViewHandle<LocalCodeEditorView>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(results) = &self.results else {
|
||||
return;
|
||||
};
|
||||
|
||||
if results.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(CodeReviewTelemetryEvent::FindNavigated { direction }, ctx);
|
||||
|
||||
let next_index = if let Some(selected) = &self.selected_match {
|
||||
match direction {
|
||||
FindDirection::Down => {
|
||||
(selected.selected_result.current_index() + 1) % results.len()
|
||||
}
|
||||
FindDirection::Up => {
|
||||
if selected.selected_result.current_index() == 0 {
|
||||
results.len() - 1
|
||||
} else {
|
||||
selected.selected_result.current_index() - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let search_match = &results[next_index];
|
||||
if let Some(editor_handle) =
|
||||
editor_handles.find(|editor| editor.id() == search_match.editor_id)
|
||||
{
|
||||
let searcher = editor_handle
|
||||
.as_ref(ctx)
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.searcher
|
||||
.clone();
|
||||
let selected_result = searcher.update(ctx, |searcher, ctx| {
|
||||
searcher.select_match_at_offset(search_match.start_offset, next_index, ctx)
|
||||
});
|
||||
self.selected_match = Some(MultiEditorSelectedResult {
|
||||
editor_id: search_match.editor_id,
|
||||
selected_result,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.emit(FindEvent::UpdatedFocusedMatch);
|
||||
}
|
||||
|
||||
pub fn selected_match_info(&self) -> Option<SelectedMatchInfo> {
|
||||
let results = self.results.as_ref()?;
|
||||
let selected = self.selected_match.as_ref()?;
|
||||
let selected_match_index = selected.selected_result.current_index();
|
||||
let selected_match = results.get(selected_match_index)?;
|
||||
|
||||
let index_within_editor = results
|
||||
.iter()
|
||||
.take(selected_match_index)
|
||||
.filter(|m| m.editor_id == selected.editor_id)
|
||||
.count();
|
||||
|
||||
Some(SelectedMatchInfo {
|
||||
editor_id: selected.editor_id,
|
||||
index_within_editor,
|
||||
start_offset: selected_match.start_offset,
|
||||
end_offset: selected_match.end_offset,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn get_editor_searcher(
|
||||
&self,
|
||||
editor_id: EntityId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<warpui::ModelHandle<Searcher>> {
|
||||
let view = self.weak_view_handle.upgrade(ctx);
|
||||
if view.is_none() {
|
||||
if ChannelState::enable_debug_features() {
|
||||
log::error!(
|
||||
"Failed to upgrade WeakViewHandle<CodeReviewView> in get_editor_searcher"
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let view = view.unwrap();
|
||||
let editor_handle = view
|
||||
.as_ref(ctx)
|
||||
.editor_handles()
|
||||
.find(|h| h.id() == editor_id);
|
||||
|
||||
if editor_handle.is_none() {
|
||||
if ChannelState::enable_debug_features() {
|
||||
log::error!(
|
||||
"Failed to find editor with id {editor_id:?} in CodeReviewView editor handles"
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
editor_handle
|
||||
.unwrap()
|
||||
.as_ref(ctx)
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.searcher
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn handle_run_search_result(
|
||||
&mut self,
|
||||
all_matches: Vec<SearchMatch>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Try to restore the previous selection if there was one
|
||||
if let Some(selected) = self.selected_match.take() {
|
||||
if let Some(searcher) = self.get_editor_searcher(selected.editor_id, ctx) {
|
||||
let candidates = MultiEditorSearchMatches {
|
||||
editor_id: selected.editor_id,
|
||||
matches: all_matches.clone(),
|
||||
};
|
||||
|
||||
if let Some(restored_result) = searcher.update(ctx, |searcher, ctx| {
|
||||
searcher.restore_selected_result(selected.selected_result, candidates, ctx)
|
||||
}) {
|
||||
self.selected_match = Some(MultiEditorSelectedResult {
|
||||
editor_id: selected.editor_id,
|
||||
selected_result: restored_result,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have a selection and we have matches, select the first one
|
||||
if self.selected_match.is_none() && !all_matches.is_empty() {
|
||||
let first_match = &all_matches[0];
|
||||
if let Some(searcher) = self.get_editor_searcher(first_match.editor_id, ctx) {
|
||||
let selected_result = searcher.update(ctx, |searcher, ctx| {
|
||||
searcher.select_match_at_offset(first_match.start_offset, 0, ctx)
|
||||
});
|
||||
self.selected_match = Some(MultiEditorSelectedResult {
|
||||
editor_id: first_match.editor_id,
|
||||
selected_result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.results = Some(all_matches);
|
||||
ctx.emit(FindEvent::RanFind);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn run_search(
|
||||
&mut self,
|
||||
editor_handles: impl Iterator<Item = ViewHandle<LocalCodeEditorView>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Abort any ongoing search since we're starting a new one
|
||||
if let Some(handle) = self.search_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
if self.query_text.is_empty() {
|
||||
self.results = None;
|
||||
self.selected_match = None;
|
||||
ctx.emit(FindEvent::RanFind);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut search_futures = Vec::new();
|
||||
|
||||
for local_editor_handle in editor_handles {
|
||||
let editor_model = local_editor_handle
|
||||
.as_ref(ctx)
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.model
|
||||
.as_ref(ctx);
|
||||
|
||||
let hidden_lines = editor_model.hidden_ranges(ctx);
|
||||
let config = SearchConfig::new(&self.query_text)
|
||||
.with_regex(self.regex)
|
||||
.with_case_sensitive(self.case_sensitive)
|
||||
.with_skip_hidden(true)
|
||||
.with_hidden_ranges(&hidden_lines);
|
||||
|
||||
match editor_model.run_search(&config, ctx) {
|
||||
Ok(search_future) => {
|
||||
search_futures.push((local_editor_handle.id(), search_future));
|
||||
}
|
||||
Err(err) => {
|
||||
// This should be a user regex error (BuildError from invalid syntax, etc)
|
||||
log::info!(
|
||||
"Invalid regex in search query: {} - {}",
|
||||
self.query_text,
|
||||
err
|
||||
);
|
||||
self.results = None;
|
||||
self.selected_match = None;
|
||||
ctx.emit(FindEvent::RanFind);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.search_handle = Some(ctx.spawn(
|
||||
async move {
|
||||
let mut all_matches = Vec::new();
|
||||
|
||||
for (editor_id, search_future) in search_futures {
|
||||
let results = search_future.await;
|
||||
for match_result in results.matches {
|
||||
all_matches.push(SearchMatch {
|
||||
editor_id,
|
||||
start_offset: match_result.start,
|
||||
end_offset: match_result.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
all_matches
|
||||
},
|
||||
|me, all_matches, ctx| me.handle_run_search_result(all_matches, ctx),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn run_search(
|
||||
&mut self,
|
||||
_editor_handles: impl Iterator<Item = ViewHandle<LocalCodeEditorView>>,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
unreachable!("Code review is not available on wasm")
|
||||
}
|
||||
|
||||
pub fn matches_by_editor(&self) -> HashMap<EntityId, Vec<Range<CharOffset>>> {
|
||||
let mut matches_map: HashMap<EntityId, Vec<Range<CharOffset>>> = HashMap::new();
|
||||
|
||||
if let Some(results) = &self.results {
|
||||
for search_match in results {
|
||||
matches_map
|
||||
.entry(search_match.editor_id)
|
||||
.or_default()
|
||||
.push(search_match.start_offset..search_match.end_offset);
|
||||
}
|
||||
}
|
||||
|
||||
matches_map
|
||||
}
|
||||
}
|
||||
|
||||
impl FindModel for CodeReviewFindModel {
|
||||
fn focused_match_index(&self) -> Option<usize> {
|
||||
self.selected_match
|
||||
.as_ref()
|
||||
.map(|s| s.selected_result.current_index())
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.results.as_ref().map_or(0, |r| r.len())
|
||||
}
|
||||
|
||||
fn default_find_direction(&self, _app: &AppContext) -> FindDirection {
|
||||
FindDirection::Down
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CodeReviewFindModel {
|
||||
type Event = FindEvent;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "find_model_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::*;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use crate::code::local_code_editor::LocalCodeEditorView;
|
||||
use crate::code_review::code_review_view::CodeReviewView;
|
||||
use crate::code_review::diff_state::DiffStateModel;
|
||||
use crate::code_review::GlobalCodeReviewModel;
|
||||
use crate::pane_group::WorkingDirectoriesModel;
|
||||
use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient};
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::vim_registers::VimRegisters;
|
||||
use crate::workspace::sync_inputs::SyncedInputState;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::NotebookKeybindings;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use string_offset::CharOffset;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::content::buffer::InitialBufferState;
|
||||
use warp_editor::render::element::VerticalExpansionBehavior;
|
||||
use warpui::elements::Empty;
|
||||
use warpui::platform::WindowStyle;
|
||||
use warpui::{App, Element as _, ModelHandle, ViewHandle};
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestView;
|
||||
|
||||
impl warpui::Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl warpui::View for TestView {
|
||||
fn render(&self, _: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_across_multiple_editors() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_test_app(&mut app);
|
||||
|
||||
let editor1 =
|
||||
create_editor_with_content(&mut app, "hello world\ntest content\nhello again");
|
||||
let editor2 = create_editor_with_content(&mut app, "no matches here\njust some text");
|
||||
let editor3 = create_editor_with_content(&mut app, "hello from editor 3\nmore hello");
|
||||
|
||||
let editor1_id = editor1.id();
|
||||
let editor2_id = editor2.id();
|
||||
let editor3_id = editor3.id();
|
||||
|
||||
let editor_handles = vec![editor1, editor2, editor3];
|
||||
|
||||
let model = create_find_model_with_query(&mut app, "hello", false, false);
|
||||
|
||||
run_search_and_wait(&mut app, &model, editor_handles.into_iter()).await;
|
||||
|
||||
app.read(|ctx| {
|
||||
let m = model.as_ref(ctx);
|
||||
let results = m.results.as_ref().expect("Should have results");
|
||||
|
||||
assert_eq!(results.len(), 4, "Should find 4 matches for 'hello'");
|
||||
|
||||
let editor1_matches: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|m| m.editor_id == editor1_id)
|
||||
.collect();
|
||||
assert_eq!(editor1_matches.len(), 2);
|
||||
|
||||
let editor2_matches: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|m| m.editor_id == editor2_id)
|
||||
.collect();
|
||||
assert_eq!(editor2_matches.len(), 0);
|
||||
|
||||
let editor3_matches: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|m| m.editor_id == editor3_id)
|
||||
.collect();
|
||||
assert_eq!(editor3_matches.len(), 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_sensitive_search() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_test_app(&mut app);
|
||||
|
||||
let editor1 = create_editor_with_content(&mut app, "Hello HELLO hello");
|
||||
let editor_handles = vec![editor1.clone()];
|
||||
|
||||
let model = create_find_model_with_query(&mut app, "hello", false, false);
|
||||
|
||||
run_search_and_wait(&mut app, &model, editor_handles.iter().cloned()).await;
|
||||
|
||||
app.read(|ctx| {
|
||||
let results = model.as_ref(ctx).results.as_ref().unwrap();
|
||||
assert_eq!(results.len(), 3, "Case insensitive should find all 3");
|
||||
});
|
||||
|
||||
let model2 = create_find_model_with_query(&mut app, "hello", false, true);
|
||||
|
||||
run_search_and_wait(&mut app, &model2, editor_handles.into_iter()).await;
|
||||
|
||||
app.read(|ctx| {
|
||||
let results = model2.as_ref(ctx).results.as_ref().unwrap();
|
||||
assert_eq!(results.len(), 1, "Case sensitive should find only 1");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex_search() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_test_app(&mut app);
|
||||
|
||||
let editor1 = create_editor_with_content(&mut app, "test123 test456 test789");
|
||||
let editor_handles = vec![editor1];
|
||||
|
||||
let model = create_find_model_with_query(&mut app, r"test\d+", true, false);
|
||||
|
||||
run_search_and_wait(&mut app, &model, editor_handles.into_iter()).await;
|
||||
|
||||
app.read(|ctx| {
|
||||
let results = model.as_ref(ctx).results.as_ref().unwrap();
|
||||
assert_eq!(results.len(), 3, "Regex should find 3 matches");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Initialize required singletons for testing LocalCodeEditorView
|
||||
fn initialize_test_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| SyncedInputState::mock());
|
||||
app.add_singleton_model(|_| VimRegisters::new());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(|_| GlobalCodeReviewModel);
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Add mocks required by rich text editor (used in the CommentEditor)
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
}
|
||||
|
||||
fn create_find_model_with_query(
|
||||
app: &mut App,
|
||||
query_text: &str,
|
||||
regex: bool,
|
||||
case_sensitive: bool,
|
||||
) -> ModelHandle<CodeReviewFindModel> {
|
||||
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
|
||||
let diff_state_model =
|
||||
app.add_model(|ctx| DiffStateModel::new(Some("/tmp/test".to_string()), ctx));
|
||||
let repo_path = PathBuf::from("/tmp/test");
|
||||
let working_directories_model = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
let code_review_comment_batch =
|
||||
working_directories_model.update(app, |working_directories, ctx| {
|
||||
working_directories.get_or_create_code_review_comments(repo_path.as_path(), ctx)
|
||||
});
|
||||
|
||||
let code_review_view = app.add_view(window_id, |ctx| {
|
||||
CodeReviewView::new(
|
||||
Some(repo_path),
|
||||
diff_state_model,
|
||||
code_review_comment_batch,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let weak_handle = code_review_view.downgrade();
|
||||
|
||||
app.add_model(|ctx| {
|
||||
let mut m = CodeReviewFindModel::new(weak_handle, ctx);
|
||||
m.query_text = query_text.to_string();
|
||||
m.regex = regex;
|
||||
m.case_sensitive = case_sensitive;
|
||||
m
|
||||
})
|
||||
}
|
||||
|
||||
fn create_editor_with_content(app: &mut App, content: &str) -> ViewHandle<LocalCodeEditorView> {
|
||||
let content = content.to_string();
|
||||
let (_, local_editor) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
let code_editor_view = ctx.add_typed_action_view(|ctx| {
|
||||
CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
code_editor_view.update(ctx, |editor, ctx| {
|
||||
editor.reset(InitialBufferState::plain_text(&content), ctx);
|
||||
});
|
||||
|
||||
LocalCodeEditorView::new(code_editor_view, None, false, None, ctx)
|
||||
});
|
||||
|
||||
local_editor
|
||||
}
|
||||
|
||||
async fn run_search_and_wait(
|
||||
app: &mut App,
|
||||
model: &ModelHandle<CodeReviewFindModel>,
|
||||
editors: impl Iterator<Item = ViewHandle<LocalCodeEditorView>>,
|
||||
) {
|
||||
let search_future = model.update(app, |m, ctx| {
|
||||
m.run_search(editors, ctx);
|
||||
let future_id = m.search_handle.as_ref().unwrap().future_id();
|
||||
ctx.await_spawned_future(future_id)
|
||||
});
|
||||
search_future.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_selection_when_editor_removed() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_test_app(&mut app);
|
||||
|
||||
let editor1 = create_editor_with_content(&mut app, "hello world");
|
||||
let editor2 = create_editor_with_content(&mut app, "another file");
|
||||
|
||||
let model = create_find_model_with_query(&mut app, "hello", false, false);
|
||||
|
||||
// Run initial search with both editors
|
||||
run_search_and_wait(
|
||||
&mut app,
|
||||
&model,
|
||||
vec![editor1.clone(), editor2.clone()].into_iter(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Select the match in editor1
|
||||
let selected_result = editor1.update(&mut app, |local_editor, ctx| {
|
||||
local_editor.editor().update(ctx, |editor, ctx| {
|
||||
editor.searcher.update(ctx, |searcher, ctx| {
|
||||
searcher.select_match_at_offset(CharOffset::from(0), 0, ctx)
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
model.update(&mut app, |m, _ctx| {
|
||||
m.selected_match = Some(MultiEditorSelectedResult {
|
||||
editor_id: editor1.id(),
|
||||
selected_result,
|
||||
});
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
let m = model.as_ref(ctx);
|
||||
assert_eq!(m.focused_match_index(), Some(0));
|
||||
});
|
||||
|
||||
// Run search again but without editor1 (simulating collapsed file)
|
||||
run_search_and_wait(&mut app, &model, vec![editor2].into_iter()).await;
|
||||
|
||||
// Selection should be cleared since the editor is no longer in the view
|
||||
app.read(|ctx| {
|
||||
let m = model.as_ref(ctx);
|
||||
assert_eq!(m.match_count(), 0, "Should find no matches in editor2");
|
||||
assert_eq!(
|
||||
m.focused_match_index(),
|
||||
None,
|
||||
"Selection should be cleared when editor is removed"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Commit mode for [`GitDialog`]. Drafts a commit message via AI on open,
|
||||
//! then on confirm runs `run_commit` and optionally chains `run_push` /
|
||||
//! `create_pr` per the selected intent.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildView, ClippedScrollStateHandle, Container, CornerRadius, CrossAxisAlignment, Element,
|
||||
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
ui_components::{
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
switch::SwitchStateHandle,
|
||||
},
|
||||
AppContext, SingletonEntity, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::generate_code_review_content::api::{GenerateCodeReviewContentRequest, OutputType},
|
||||
code_review::git_dialog::{
|
||||
interactive_path_future,
|
||||
pr::{create_pr_with_ai_content, show_pr_created_toast},
|
||||
render_branch_section, render_file_changes_box, should_send_git_ops_ai_request, show_toast,
|
||||
user_facing_git_error, GitDialog, GitDialogAction, GitDialogEvent, GitDialogMode,
|
||||
},
|
||||
editor::{
|
||||
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
TextOptions,
|
||||
},
|
||||
server::server_api::ServerApiProvider,
|
||||
ui_components::icons::Icon,
|
||||
util::git::{FileChangeEntry, PrInfo},
|
||||
view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme},
|
||||
};
|
||||
|
||||
/// What should happen after a successful commit.
|
||||
#[allow(clippy::enum_variant_names)] // `Commit` prefix is intentional: describes the always-present first stage.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CommitIntent {
|
||||
CommitOnly,
|
||||
CommitAndPush,
|
||||
CommitAndCreatePr,
|
||||
}
|
||||
|
||||
/// What actually happened when a commit confirm ran to completion. Keeps
|
||||
/// the "which stages ran" information separate from the user's selected
|
||||
/// intent so the callback can't drift out of sync with the async body.
|
||||
enum CommitOutcome {
|
||||
Committed,
|
||||
Pushed,
|
||||
PrCreated(PrInfo),
|
||||
}
|
||||
|
||||
/// Commit-specific sub-actions, dispatched wrapped in `GitDialogAction::Commit`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CommitSubAction {
|
||||
SetIntent(CommitIntent),
|
||||
ToggleIncludeUnstaged,
|
||||
ToggleChangesExpanded,
|
||||
}
|
||||
|
||||
const EDITOR_FONT_SIZE: f32 = 12.;
|
||||
const EDITOR_MIN_HEIGHT: f32 = 72.;
|
||||
/// Placeholder shown while the open-time AI commit-message autogen is in
|
||||
/// flight.
|
||||
const GENERATING_PLACEHOLDER_TEXT: &str = "Generating commit message\u{2026}";
|
||||
/// Placeholder shown once the open-time autogen resolves — either as a
|
||||
/// nudge if the user later clears the generated draft, or as guidance when
|
||||
/// autogen failed and the editor is blank. Also used when autogen is off.
|
||||
const FALLBACK_PLACEHOLDER_TEXT: &str = "Type a commit message";
|
||||
/// Loading-state label while the commit / chain runs. Static regardless of
|
||||
/// which chain is in flight — the success toast communicates what actually
|
||||
/// ran.
|
||||
const LOADING_LABEL: &str = "Committing\u{2026}";
|
||||
|
||||
pub struct CommitState {
|
||||
intent: CommitIntent,
|
||||
include_unstaged: bool,
|
||||
file_changes: Vec<FileChangeEntry>,
|
||||
changes_expanded: bool,
|
||||
switch_state: SwitchStateHandle,
|
||||
summary_mouse_state: MouseStateHandle,
|
||||
changes_scroll_state: ClippedScrollStateHandle,
|
||||
pub(super) message_editor: ViewHandle<EditorView>,
|
||||
commit_button: ViewHandle<ActionButton>,
|
||||
commit_and_push_button: ViewHandle<ActionButton>,
|
||||
/// `None` when creating a PR doesn't make sense for this branch —
|
||||
/// either a PR already exists or we're on the repo's main branch.
|
||||
/// The intent is hidden entirely in either case; an existing PR is
|
||||
/// still reachable via the git operations menu in the header.
|
||||
commit_and_create_pr_button: Option<ViewHandle<ActionButton>>,
|
||||
}
|
||||
|
||||
pub(super) fn new_state(
|
||||
repo_path: &Path,
|
||||
allow_create_pr: bool,
|
||||
has_upstream: bool,
|
||||
ctx: &mut ViewContext<GitDialog>,
|
||||
) -> CommitState {
|
||||
// Dialog always opens with the plain commit intent; the user picks
|
||||
// something else via the segmented intent selector inside the dialog.
|
||||
let intent = CommitIntent::CommitOnly;
|
||||
// `CommitAndPush` always runs `git push --set-upstream`, so it works
|
||||
// whether or not the branch already has an upstream — but the label
|
||||
// and icon flip to communicate the user-visible difference.
|
||||
let (push_label, push_icon) = if has_upstream {
|
||||
("Commit and push", Icon::ArrowUp)
|
||||
} else {
|
||||
("Commit and publish", Icon::UploadCloud)
|
||||
};
|
||||
// If AI autogen is on, the dialog opens with "Generating\u{2026}" and a
|
||||
// background request fills the editor when it resolves. Otherwise, we
|
||||
// land on the manual-type prompt immediately.
|
||||
let ai_autogen_enabled = should_send_git_ops_ai_request(ctx);
|
||||
let initial_placeholder = if ai_autogen_enabled {
|
||||
GENERATING_PLACEHOLDER_TEXT
|
||||
} else {
|
||||
FALLBACK_PLACEHOLDER_TEXT
|
||||
};
|
||||
let message_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let options = EditorOptions {
|
||||
text: TextOptions {
|
||||
font_size_override: Some(EDITOR_FONT_SIZE),
|
||||
font_family_override: Some(appearance.ui_font_family()),
|
||||
..Default::default()
|
||||
},
|
||||
soft_wrap: true,
|
||||
autogrow: true,
|
||||
propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always,
|
||||
supports_vim_mode: false,
|
||||
single_line: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut editor = EditorView::new(options, ctx);
|
||||
editor.set_placeholder_text(initial_placeholder, ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&message_editor, |me, _, event, ctx| {
|
||||
handle_editor_event(me, event, ctx);
|
||||
});
|
||||
|
||||
let commit_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Commit", SecondaryTheme)
|
||||
.with_size(ButtonSize::XSmall)
|
||||
.with_height(32.)
|
||||
.with_icon(Icon::GitCommit)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(GitDialogAction::Commit(CommitSubAction::SetIntent(
|
||||
CommitIntent::CommitOnly,
|
||||
)))
|
||||
})
|
||||
});
|
||||
let commit_and_push_button = ctx.add_typed_action_view(move |_ctx| {
|
||||
ActionButton::new(push_label, SecondaryTheme)
|
||||
.with_size(ButtonSize::XSmall)
|
||||
.with_height(32.)
|
||||
.with_icon(push_icon)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(GitDialogAction::Commit(CommitSubAction::SetIntent(
|
||||
CommitIntent::CommitAndPush,
|
||||
)))
|
||||
})
|
||||
});
|
||||
|
||||
let commit_and_create_pr_button = if allow_create_pr {
|
||||
Some(ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Commit and create PR", SecondaryTheme)
|
||||
.with_size(ButtonSize::XSmall)
|
||||
.with_height(32.)
|
||||
.with_icon(Icon::Github)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(GitDialogAction::Commit(CommitSubAction::SetIntent(
|
||||
CommitIntent::CommitAndCreatePr,
|
||||
)))
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let include_unstaged = true;
|
||||
let repo_path_for_load = repo_path.to_path_buf();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
crate::util::git::get_file_change_entries(&repo_path_for_load, include_unstaged).await
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
let GitDialogMode::Commit(state) = &mut me.mode else {
|
||||
return;
|
||||
};
|
||||
let has_changes = match result {
|
||||
Ok(entries) => {
|
||||
let has_changes = !entries.is_empty();
|
||||
state.file_changes = entries;
|
||||
has_changes
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to load file changes: {err}");
|
||||
false
|
||||
}
|
||||
};
|
||||
me.refresh_confirm_enabled(ctx);
|
||||
ctx.notify();
|
||||
if ai_autogen_enabled && has_changes {
|
||||
generate_commit_message(me.repo_path(), me.branch_name(), include_unstaged, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let state = CommitState {
|
||||
intent,
|
||||
include_unstaged,
|
||||
file_changes: Vec::new(),
|
||||
changes_expanded: true,
|
||||
switch_state: SwitchStateHandle::default(),
|
||||
summary_mouse_state: MouseStateHandle::default(),
|
||||
changes_scroll_state: ClippedScrollStateHandle::default(),
|
||||
message_editor,
|
||||
commit_button,
|
||||
commit_and_push_button,
|
||||
commit_and_create_pr_button,
|
||||
};
|
||||
apply_intent_selector(&state, ctx);
|
||||
state
|
||||
}
|
||||
|
||||
pub(super) fn on_focus(state: &CommitState, ctx: &mut ViewContext<GitDialog>) {
|
||||
ctx.focus(&state.message_editor);
|
||||
}
|
||||
|
||||
pub(super) fn is_ready_to_confirm(state: &CommitState, app: &AppContext) -> bool {
|
||||
// Confirm requires at least one file change and a non-empty commit
|
||||
// message. While open-time autogen is in flight the editor is still
|
||||
// empty, so this keeps the button disabled until the draft lands (or the
|
||||
// user types something).
|
||||
!state.file_changes.is_empty() && commit_message(state, app).is_some()
|
||||
}
|
||||
|
||||
/// Kicks off an open-time AI commit-message generation. On success, writes
|
||||
/// the result into the message editor (unless the user has already typed
|
||||
/// something). On failure, silently swaps the placeholder to the manual
|
||||
/// prompt so the user can type their own — no toast because the failure
|
||||
/// isn't retryable and the empty editor already tells the story.
|
||||
fn generate_commit_message(
|
||||
repo_path: &Path,
|
||||
branch_name: &str,
|
||||
include_unstaged: bool,
|
||||
ctx: &mut ViewContext<GitDialog>,
|
||||
) {
|
||||
let repo_path = repo_path.to_path_buf();
|
||||
let branch_name = branch_name.to_string();
|
||||
let code_review_ai = ServerApiProvider::handle(ctx).read(ctx, |p, _| p.get_ai_client());
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let diff =
|
||||
crate::util::git::get_diff_for_commit_message(&repo_path, include_unstaged).await?;
|
||||
let generated = code_review_ai
|
||||
.generate_code_review_content(GenerateCodeReviewContentRequest {
|
||||
output_type: OutputType::CommitMessage,
|
||||
diff,
|
||||
branch_name,
|
||||
commit_messages: Vec::new(),
|
||||
})
|
||||
.await?
|
||||
.content;
|
||||
if generated.trim().is_empty() {
|
||||
anyhow::bail!("AI returned an empty commit message");
|
||||
}
|
||||
anyhow::Ok(generated)
|
||||
},
|
||||
|me, result, ctx| {
|
||||
let editor_handle = match &me.mode {
|
||||
GitDialogMode::Commit(state) => state.message_editor.clone(),
|
||||
_ => return,
|
||||
};
|
||||
match result {
|
||||
Ok(generated) => {
|
||||
let user_typed = !editor_handle.as_ref(ctx).buffer_text(ctx).trim().is_empty();
|
||||
editor_handle.update(ctx, |editor, ctx| {
|
||||
// Swap "Generating\u{2026}" for the manual-type
|
||||
// prompt so it shows if the user later clears the
|
||||
// generated draft.
|
||||
editor.set_placeholder_text(FALLBACK_PLACEHOLDER_TEXT, ctx);
|
||||
// User input wins — don't clobber their text.
|
||||
if !user_typed {
|
||||
editor.system_reset_buffer_text(generated.trim(), ctx);
|
||||
}
|
||||
});
|
||||
me.refresh_confirm_enabled(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to autogenerate commit message: {err}");
|
||||
editor_handle.update(ctx, |editor, ctx| {
|
||||
editor.set_placeholder_text(FALLBACK_PLACEHOLDER_TEXT, ctx);
|
||||
});
|
||||
me.refresh_confirm_enabled(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn handle_sub_action(
|
||||
me: &mut GitDialog,
|
||||
action: &CommitSubAction,
|
||||
ctx: &mut ViewContext<GitDialog>,
|
||||
) {
|
||||
if me.loading() {
|
||||
return;
|
||||
}
|
||||
match action {
|
||||
CommitSubAction::SetIntent(new_intent) => {
|
||||
if let GitDialogMode::Commit(state) = me.mode_mut() {
|
||||
state.intent = *new_intent;
|
||||
}
|
||||
// Re-highlight the selected segment. The confirm button's
|
||||
// label is static ("Confirm"), so it doesn't need to update.
|
||||
if let GitDialogMode::Commit(state) = me.mode() {
|
||||
apply_intent_selector(state, ctx);
|
||||
}
|
||||
}
|
||||
CommitSubAction::ToggleIncludeUnstaged => {
|
||||
if let GitDialogMode::Commit(state) = me.mode_mut() {
|
||||
state.include_unstaged = !state.include_unstaged;
|
||||
}
|
||||
reload_file_changes(me, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
CommitSubAction::ToggleChangesExpanded => {
|
||||
if let GitDialogMode::Commit(state) = me.mode_mut() {
|
||||
state.changes_expanded = !state.changes_expanded;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>) {
|
||||
let GitDialogMode::Commit(state) = me.mode() else {
|
||||
return;
|
||||
};
|
||||
// `is_ready_to_confirm` already guarantees a non-empty message, but
|
||||
// guard against dispatch paths that could bypass the disabled state
|
||||
// (e.g. keyboard shortcut).
|
||||
let Some(message) = commit_message(state, ctx) else {
|
||||
return;
|
||||
};
|
||||
let intent = state.intent;
|
||||
let include_unstaged = state.include_unstaged;
|
||||
let ai_autogen_enabled = should_send_git_ops_ai_request(ctx);
|
||||
let repo_path = me.repo_path().clone();
|
||||
let branch_name = me.branch_name().to_string();
|
||||
let parent_branch = me.parent_branch_name.clone();
|
||||
|
||||
me.set_loading(LOADING_LABEL, ctx);
|
||||
|
||||
let code_review_ai = if ai_autogen_enabled {
|
||||
Some(ServerApiProvider::handle(ctx).read(ctx, |p, _| p.get_ai_client()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let path_future = interactive_path_future(ctx);
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let path_env = path_future.await;
|
||||
let path_env_ref = path_env.as_deref();
|
||||
crate::util::git::run_commit(&repo_path, &message, include_unstaged, path_env_ref)
|
||||
.await?;
|
||||
let outcome = match intent {
|
||||
CommitIntent::CommitOnly => CommitOutcome::Committed,
|
||||
CommitIntent::CommitAndPush => {
|
||||
crate::util::git::run_push(&repo_path, &branch_name, path_env_ref).await?;
|
||||
CommitOutcome::Pushed
|
||||
}
|
||||
CommitIntent::CommitAndCreatePr => {
|
||||
crate::util::git::run_push(&repo_path, &branch_name, path_env_ref).await?;
|
||||
let pr = match code_review_ai {
|
||||
Some(ai) => {
|
||||
// Reuse pr.rs's AI-title/body-with-fallback helper so
|
||||
// the standalone PR flow and this chain always produce
|
||||
// PRs the same way.
|
||||
create_pr_with_ai_content(
|
||||
&repo_path,
|
||||
&branch_name,
|
||||
parent_branch.as_deref(),
|
||||
ai.as_ref(),
|
||||
path_env_ref,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
// AI autogen disabled (global toggle, per-feature
|
||||
// toggle, or enterprise) — skip AI entirely and use
|
||||
// `gh pr create --fill`
|
||||
crate::util::git::create_pr(
|
||||
&repo_path,
|
||||
None,
|
||||
None,
|
||||
parent_branch.as_deref(),
|
||||
path_env_ref,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
CommitOutcome::PrCreated(pr)
|
||||
}
|
||||
};
|
||||
anyhow::Ok(outcome)
|
||||
},
|
||||
move |_me, result, ctx| {
|
||||
match result {
|
||||
Ok(CommitOutcome::Committed) => {
|
||||
show_toast("Changes successfully committed.", ctx);
|
||||
}
|
||||
Ok(CommitOutcome::Pushed) => {
|
||||
show_toast("Changes committed and pushed.", ctx);
|
||||
}
|
||||
Ok(CommitOutcome::PrCreated(pr)) => {
|
||||
show_pr_created_toast(&pr, ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Commit failed: {err}");
|
||||
show_toast(user_facing_git_error(&err.to_string()), ctx);
|
||||
}
|
||||
}
|
||||
// Success or failure, the dialog is done and the parent should
|
||||
// close it and refresh.
|
||||
ctx.emit(GitDialogEvent::Completed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_editor_event(me: &mut GitDialog, event: &EditorEvent, ctx: &mut ViewContext<GitDialog>) {
|
||||
match event {
|
||||
EditorEvent::Escape => {
|
||||
if !me.loading() {
|
||||
ctx.emit(GitDialogEvent::Cancelled);
|
||||
}
|
||||
}
|
||||
EditorEvent::Edited(_) => {
|
||||
me.refresh_confirm_enabled(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_intent_selector(state: &CommitState, ctx: &mut ViewContext<GitDialog>) {
|
||||
state.commit_button.update(ctx, |b, ctx| {
|
||||
b.set_active(state.intent == CommitIntent::CommitOnly, ctx);
|
||||
});
|
||||
state.commit_and_push_button.update(ctx, |b, ctx| {
|
||||
b.set_active(state.intent == CommitIntent::CommitAndPush, ctx);
|
||||
});
|
||||
if let Some(button) = &state.commit_and_create_pr_button {
|
||||
button.update(ctx, |b, ctx| {
|
||||
b.set_active(state.intent == CommitIntent::CommitAndCreatePr, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_file_changes(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>) {
|
||||
let repo_path = me.repo_path().clone();
|
||||
let include_unstaged = match me.mode() {
|
||||
GitDialogMode::Commit(state) => state.include_unstaged,
|
||||
_ => return,
|
||||
};
|
||||
ctx.spawn(
|
||||
async move { crate::util::git::get_file_change_entries(&repo_path, include_unstaged).await },
|
||||
|me, result, ctx| {
|
||||
if let GitDialogMode::Commit(state) = &mut me.mode {
|
||||
match result {
|
||||
Ok(entries) => {
|
||||
state.file_changes = entries;
|
||||
me.refresh_confirm_enabled(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
Err(err) => log::warn!("Failed to reload file changes: {err}"),
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn commit_message(state: &CommitState, app: &AppContext) -> Option<String> {
|
||||
let text = state.message_editor.as_ref(app).buffer_text(app);
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_body(
|
||||
state: &CommitState,
|
||||
branch_name: &str,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let branch_section = render_branch_section(branch_name, appearance);
|
||||
let changes_section = render_changes_section(state, appearance);
|
||||
let message_section = render_message_editor(state, appearance, app);
|
||||
let intent_section = render_intent_buttons(state);
|
||||
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(branch_section)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(changes_section)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(message_section)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(intent_section)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_changes_section(state: &CommitState, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let main_color = theme.main_text_color(theme.surface_1()).into_solid();
|
||||
let sub_color = theme.sub_text_color(theme.surface_1()).into_solid();
|
||||
|
||||
let changes_label = Text::new(
|
||||
"Changes",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main_color)
|
||||
.finish();
|
||||
|
||||
let include_label = Text::new(
|
||||
"Include unstaged",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(sub_color)
|
||||
.finish();
|
||||
|
||||
let switch = appearance
|
||||
.ui_builder()
|
||||
.switch(state.switch_state.clone())
|
||||
.check(state.include_unstaged)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(GitDialogAction::Commit(
|
||||
CommitSubAction::ToggleIncludeUnstaged,
|
||||
));
|
||||
})
|
||||
.finish();
|
||||
|
||||
let toggle_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(include_label)
|
||||
.with_child(Container::new(switch).with_margin_left(4.).finish())
|
||||
.finish();
|
||||
|
||||
let header_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(changes_label)
|
||||
.with_child(toggle_row)
|
||||
.finish();
|
||||
|
||||
let changes_box = render_file_changes_box(
|
||||
&state.file_changes,
|
||||
state.changes_expanded,
|
||||
&state.summary_mouse_state,
|
||||
&state.changes_scroll_state,
|
||||
GitDialogAction::Commit(CommitSubAction::ToggleChangesExpanded),
|
||||
appearance,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(header_row).with_margin_bottom(8.).finish())
|
||||
.with_child(changes_box)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_message_editor(
|
||||
state: &CommitState,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let label = Text::new(
|
||||
"Commit message",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_1())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let line_height = state
|
||||
.message_editor
|
||||
.as_ref(app)
|
||||
.line_height(app.font_cache(), appearance);
|
||||
|
||||
let editor_element = appearance
|
||||
.ui_builder()
|
||||
.text_input(state.message_editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
border_color: Some(appearance.theme().surface_3().into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(6.))),
|
||||
height: Some(EDITOR_MIN_HEIGHT.max(line_height * 3.)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(label).with_margin_bottom(8.).finish())
|
||||
.with_child(editor_element)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_intent_buttons(state: &CommitState) -> Box<dyn Element> {
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(ChildView::new(&state.commit_button).finish())
|
||||
.with_child(
|
||||
Container::new(ChildView::new(&state.commit_and_push_button).finish())
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
);
|
||||
if let Some(button) = &state.commit_and_create_pr_button {
|
||||
column.add_child(
|
||||
Container::new(ChildView::new(button).finish())
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
column.finish()
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! Unified dialog for git operations (commit / push / create PR).
|
||||
//!
|
||||
//! `GitDialog` is a single view with multiple modes — each mode owns its own
|
||||
//! state, body renderer, and async op in its own submodule. The outer view
|
||||
//! owns everything shared: chrome (title, close/cancel/confirm buttons,
|
||||
//! overlay), the loading lifecycle, ESC keybinding, and dispatch.
|
||||
//!
|
||||
//! To add a new mode, add a submodule with a `State` + `new_*` + `render_body`
|
||||
//! + confirm async, extend `GitDialogMode`, add the per-mode action and
|
||||
//! outcome variant, and wire up dispatch.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable,
|
||||
Icon as IconElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth, Stack, Text,
|
||||
},
|
||||
keymap::{self, FixedBinding},
|
||||
platform::Cursor,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
use crate::terminal::local_shell::LocalShellState;
|
||||
use crate::{
|
||||
code::editor::{add_color, remove_color},
|
||||
settings::AISettings,
|
||||
ui_components::{
|
||||
dialog::{dialog_styles, Dialog},
|
||||
icons::Icon,
|
||||
},
|
||||
util::git::{Commit, FileChangeEntry},
|
||||
view_components::{
|
||||
action_button::{ActionButton, ButtonSize, NakedTheme, SecondaryTheme},
|
||||
DismissibleToast,
|
||||
},
|
||||
workspace::ToastStack,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
pub(crate) mod commit;
|
||||
pub(crate) mod pr;
|
||||
pub(crate) mod push;
|
||||
|
||||
pub use commit::{CommitState, CommitSubAction};
|
||||
pub use pr::{PrState, PrSubAction};
|
||||
pub use push::{PushState, PushSubAction};
|
||||
|
||||
/// Describes which kind of `GitDialog` to open. Passed to
|
||||
/// `CodeReviewView::open_git_dialog` so the open path can be fully shared
|
||||
/// across modes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum GitDialogKind {
|
||||
Commit,
|
||||
Push { publish: bool },
|
||||
CreatePr,
|
||||
}
|
||||
|
||||
pub fn init(ctx: &mut AppContext) {
|
||||
ctx.register_fixed_bindings(vec![FixedBinding::new(
|
||||
"escape",
|
||||
GitDialogAction::Cancel,
|
||||
warpui::id!("GitDialog"),
|
||||
)]);
|
||||
}
|
||||
|
||||
/// Future that resolves to the user's interactive-shell `PATH` (or `None`
|
||||
/// if capture failed). Result is cached in `LocalShellState`.
|
||||
#[cfg(feature = "local_tty")]
|
||||
pub(super) fn interactive_path_future(
|
||||
ctx: &mut ViewContext<GitDialog>,
|
||||
) -> futures::future::BoxFuture<'static, Option<String>> {
|
||||
LocalShellState::handle(ctx).update(ctx, |shell_state, ctx| {
|
||||
shell_state.get_interactive_path_env_var(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_tty"))]
|
||||
pub(super) fn interactive_path_future(
|
||||
_ctx: &mut ViewContext<GitDialog>,
|
||||
) -> futures::future::BoxFuture<'static, Option<String>> {
|
||||
use futures::FutureExt;
|
||||
futures::future::ready(None).boxed()
|
||||
}
|
||||
|
||||
/// Top-level action dispatched to `GitDialog`.
|
||||
///
|
||||
/// `Cancel` / `Confirm` are shared across modes; mode-specific actions are
|
||||
/// carried in per-mode sub-action enums.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GitDialogAction {
|
||||
Cancel,
|
||||
Confirm,
|
||||
Commit(CommitSubAction),
|
||||
Push(PushSubAction),
|
||||
Pr(PrSubAction),
|
||||
}
|
||||
|
||||
/// Events emitted to the parent view. Each mode handles its own success /
|
||||
/// failure toasts internally; the parent only needs to know whether the
|
||||
/// dialog completed (close + refresh state) or was cancelled (just close).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GitDialogEvent {
|
||||
/// The dialog's async op ran and emitted its own toast. Parent should
|
||||
/// close the dialog and refresh repo/PR metadata.
|
||||
Completed,
|
||||
/// The user cancelled (ESC / close button / cancel button). Parent
|
||||
/// should close the dialog; no refresh needed.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Shows an ephemeral toast for a git-dialog outcome. Submodules call this
|
||||
/// directly from their success/failure paths.
|
||||
fn show_toast(msg: impl Into<String>, ctx: &mut ViewContext<GitDialog>) {
|
||||
let window_id = ctx.window_id();
|
||||
let msg = msg.into();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = DismissibleToast::default(msg);
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the git-operations AI autogen flow should send an AI request.
|
||||
///
|
||||
/// Folds the parent feature flag, the user's dedicated per-feature AI toggle
|
||||
/// (which itself requires active AI / auth / remote-session org policy to
|
||||
/// allow AI), and an enterprise check with the same Warp-plan exception and
|
||||
/// dogfood override as `share_block_modal.rs::should_send_title_gen_request`.
|
||||
///
|
||||
/// When this returns `false`, call sites skip AI entirely: commit.rs opens
|
||||
/// with the manual-type placeholder and pr.rs goes straight to
|
||||
/// `gh pr create --fill`.
|
||||
fn should_send_git_ops_ai_request(app: &AppContext) -> bool {
|
||||
FeatureFlag::GitOperationsInCodeReview.is_enabled()
|
||||
&& AISettings::as_ref(app).is_git_operations_autogen_enabled(app)
|
||||
&& UserWorkspaces::as_ref(app).ai_allowed_for_current_team()
|
||||
}
|
||||
|
||||
/// Maps a raw git error string to a user-friendly toast message. Known
|
||||
/// failure modes get dedicated copy; anything else falls back to a generic
|
||||
/// message (the raw error is always logged separately at the call site).
|
||||
fn user_facing_git_error(raw: &str) -> &'static str {
|
||||
let lower = raw.to_lowercase();
|
||||
if lower.contains("nothing to commit") {
|
||||
"No changes to commit."
|
||||
} else if lower.contains("please tell me who you are")
|
||||
|| lower.contains("author identity unknown")
|
||||
{
|
||||
"Git identity not configured. Set user.name and user.email."
|
||||
} else if lower.contains("updates were rejected")
|
||||
|| lower.contains("non-fast-forward")
|
||||
|| lower.contains("fetch first")
|
||||
{
|
||||
"Remote has new changes \u{2014} pull before pushing."
|
||||
} else if lower.contains("does not appear to be a git repository")
|
||||
|| lower.contains("no configured push destination")
|
||||
|| lower.contains("no such remote")
|
||||
{
|
||||
"No remote configured for this branch."
|
||||
} else if lower.contains("authentication failed")
|
||||
|| lower.contains("permission denied (publickey)")
|
||||
{
|
||||
"Authentication failed. Check your Git credentials."
|
||||
} else if lower.contains("could not resolve host")
|
||||
|| lower.contains("network is unreachable")
|
||||
|| lower.contains("connection timed out")
|
||||
{
|
||||
"Network error. Check your connection."
|
||||
} else if lower.contains("repository not found") {
|
||||
"Remote repository not found."
|
||||
} else if lower.contains("failed to execute gh command") {
|
||||
// `run_gh_command` wraps spawn failures with this prefix, which is
|
||||
// the reliable "gh binary missing" signal.
|
||||
"GitHub CLI (gh) not installed. See https://cli.github.com/."
|
||||
} else if lower.contains("not logged in")
|
||||
|| lower.contains("authentication required")
|
||||
|| lower.contains("gh auth login")
|
||||
{
|
||||
// Phrases mirror `context_chips::current_prompt::is_gh_auth_error`,
|
||||
// which has been vetted against real `gh` failure output.
|
||||
"GitHub CLI not authenticated. Run `gh auth login`."
|
||||
} else {
|
||||
"Git operation failed."
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared rendering helpers ─────────────────────────────────────────
|
||||
//
|
||||
// These helpers are used by per-mode body renderers (`commit::render_body`,
|
||||
// `push::render_body`, etc.) and are kept here so the whole dialog lives in
|
||||
// one module.
|
||||
|
||||
/// Renders a "Branch" label with git-branch icon and branch name.
|
||||
fn render_branch_section(
|
||||
branch_name: impl Into<String>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let branch_name = branch_name.into();
|
||||
let theme = appearance.theme();
|
||||
let main_color = theme.main_text_color(theme.surface_1()).into_solid();
|
||||
let sub_color = theme.sub_text_color(theme.surface_1()).into_solid();
|
||||
|
||||
let label = Text::new(
|
||||
"Branch",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main_color)
|
||||
.finish();
|
||||
|
||||
let icon = ConstrainedBox::new(
|
||||
IconElement::new(
|
||||
<Icon as Into<&'static str>>::into(Icon::GitBranch),
|
||||
sub_color,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let branch_text = Text::new(
|
||||
branch_name,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(sub_color)
|
||||
.finish();
|
||||
|
||||
let branch_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(icon)
|
||||
.with_child(Container::new(branch_text).with_margin_left(4.).finish())
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(label).with_margin_bottom(4.).finish())
|
||||
.with_child(branch_row)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn split_file_path(path: &str) -> (&str, &str) {
|
||||
match path.rfind('/') {
|
||||
Some(idx) => (&path[idx + 1..], &path[..idx + 1]),
|
||||
None => (path, ""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a chevron icon (ChevronDown when expanded, ChevronRight when collapsed).
|
||||
fn render_chevron_icon(expanded: bool, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let icon = if expanded {
|
||||
Icon::ChevronDown
|
||||
} else {
|
||||
Icon::ChevronRight
|
||||
};
|
||||
let icon_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1())
|
||||
.into_solid();
|
||||
ConstrainedBox::new(
|
||||
IconElement::new(<Icon as Into<&'static str>>::into(icon), icon_color).finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the bordered, collapsible "Changes" box shared by the commit
|
||||
/// and create-PR modes: a clickable summary row showing totals (files /
|
||||
/// +adds / -dels) with a chevron, and an expandable scrollable file list
|
||||
/// below it. The caller supplies the action to dispatch when the summary
|
||||
/// is clicked, and stacks their own header above the box.
|
||||
fn render_file_changes_box(
|
||||
file_changes: &[FileChangeEntry],
|
||||
expanded: bool,
|
||||
summary_mouse_state: &MouseStateHandle,
|
||||
scroll_state: &ClippedScrollStateHandle,
|
||||
on_toggle: GitDialogAction,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let main_color = theme.main_text_color(theme.surface_1()).into_solid();
|
||||
|
||||
let total_files = file_changes.len();
|
||||
let total_additions: usize = file_changes.iter().map(|f| f.additions).sum();
|
||||
let total_deletions: usize = file_changes.iter().map(|f| f.deletions).sum();
|
||||
|
||||
let files_text = Text::new(
|
||||
format!(
|
||||
"{total_files} {}",
|
||||
if total_files == 1 { "file" } else { "files" }
|
||||
),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main_color)
|
||||
.finish();
|
||||
|
||||
let additions_text = Container::new(
|
||||
Text::new(
|
||||
format!("+{total_additions}"),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(add_color(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish();
|
||||
|
||||
let deletions_text = Container::new(
|
||||
Text::new(
|
||||
format!("-{total_deletions}"),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(remove_color(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(4.)
|
||||
.finish();
|
||||
|
||||
let summary_left = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(files_text)
|
||||
.with_child(additions_text)
|
||||
.with_child(deletions_text)
|
||||
.finish();
|
||||
|
||||
let summary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(summary_left)
|
||||
.with_child(render_chevron_icon(expanded, appearance))
|
||||
.finish();
|
||||
|
||||
let summary_container = Hoverable::new(summary_mouse_state.clone(), |_| {
|
||||
Container::new(summary_row)
|
||||
.with_padding_top(8.)
|
||||
.with_padding_bottom(8.)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_right(8.)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(on_toggle.clone());
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let mut content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(summary_container);
|
||||
|
||||
if expanded && !file_changes.is_empty() {
|
||||
let file_list = render_file_list(file_changes, appearance);
|
||||
let scrollable_file_list = ConstrainedBox::new(
|
||||
ClippedScrollable::vertical(
|
||||
scroll_state.clone(),
|
||||
file_list,
|
||||
ScrollbarWidth::Auto,
|
||||
theme.nonactive_ui_detail().into(),
|
||||
theme.active_ui_detail().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(130.)
|
||||
.finish();
|
||||
content.add_child(scrollable_file_list);
|
||||
}
|
||||
|
||||
Container::new(content.finish())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.surface_3()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a file list with per-file name, directory, and +/- stats.
|
||||
fn render_file_list(files: &[FileChangeEntry], appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let main_color = theme.main_text_color(theme.surface_1()).into_solid();
|
||||
let sub_color = theme.sub_text_color(theme.surface_1()).into_solid();
|
||||
|
||||
let mut list = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for entry in files {
|
||||
let (filename, directory) = split_file_path(&entry.path);
|
||||
|
||||
let mut name_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new(
|
||||
filename.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main_color)
|
||||
.soft_wrap(false)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if !directory.is_empty() {
|
||||
name_row.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
directory.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(sub_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut stats = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
stats.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
format!("+{}", entry.additions),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(add_color(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
);
|
||||
stats.add_child(
|
||||
Text::new(
|
||||
format!("-{}", entry.deletions),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(remove_color(appearance))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let row = Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(name_row.finish())
|
||||
.with_child(stats.finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(4.)
|
||||
.with_padding_bottom(4.)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_right(12.)
|
||||
.finish();
|
||||
|
||||
list.add_child(row);
|
||||
}
|
||||
|
||||
Container::new(list.finish())
|
||||
.with_padding_bottom(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Mode-specific state. Outer chrome lives on `GitDialog` itself.
|
||||
pub enum GitDialogMode {
|
||||
Commit(CommitState),
|
||||
Push(PushState),
|
||||
CreatePr(PrState),
|
||||
}
|
||||
|
||||
pub struct GitDialog {
|
||||
repo_path: PathBuf,
|
||||
branch_name: String,
|
||||
parent_branch_name: Option<String>,
|
||||
mode: GitDialogMode,
|
||||
loading: bool,
|
||||
confirm_button: ViewHandle<ActionButton>,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl GitDialog {
|
||||
pub fn new_for_commit(
|
||||
repo_path: PathBuf,
|
||||
branch_name: String,
|
||||
parent_branch_name: Option<String>,
|
||||
allow_create_pr: bool,
|
||||
has_upstream: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
// Commit's confirm button is a static "Confirm" with no icon; the
|
||||
// segmented intent selector inside the dialog is the sole UI that
|
||||
// communicates which of commit / commit-and-push / commit-and-create-PR
|
||||
// will actually run on click.
|
||||
let (confirm_button, cancel_button, close_button) =
|
||||
Self::build_dialog_buttons("Confirm", None, ctx);
|
||||
let state = commit::new_state(&repo_path, allow_create_pr, has_upstream, ctx);
|
||||
let this = Self {
|
||||
repo_path,
|
||||
branch_name,
|
||||
parent_branch_name,
|
||||
mode: GitDialogMode::Commit(state),
|
||||
loading: false,
|
||||
confirm_button,
|
||||
cancel_button,
|
||||
close_button,
|
||||
};
|
||||
this.refresh_confirm_enabled(ctx);
|
||||
this
|
||||
}
|
||||
|
||||
pub fn new_for_push(
|
||||
repo_path: PathBuf,
|
||||
branch_name: String,
|
||||
publish: bool,
|
||||
commits: Vec<Commit>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let (confirm_button, cancel_button, close_button) = Self::build_dialog_buttons(
|
||||
push::confirm_label(publish),
|
||||
Some(push::confirm_icon(publish)),
|
||||
ctx,
|
||||
);
|
||||
let state = push::new_state(publish, commits);
|
||||
Self {
|
||||
repo_path,
|
||||
branch_name,
|
||||
parent_branch_name: None,
|
||||
mode: GitDialogMode::Push(state),
|
||||
loading: false,
|
||||
confirm_button,
|
||||
cancel_button,
|
||||
close_button,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_pr(
|
||||
repo_path: PathBuf,
|
||||
branch_name: String,
|
||||
parent_branch_name: Option<String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let (confirm_button, cancel_button, close_button) =
|
||||
Self::build_dialog_buttons(pr::confirm_label_for(), Some(pr::confirm_icon_for()), ctx);
|
||||
let state = pr::new_state(&repo_path, parent_branch_name.as_deref(), ctx);
|
||||
Self {
|
||||
repo_path,
|
||||
branch_name,
|
||||
parent_branch_name,
|
||||
mode: GitDialogMode::CreatePr(state),
|
||||
loading: false,
|
||||
confirm_button,
|
||||
cancel_button,
|
||||
close_button,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_dialog_buttons(
|
||||
confirm_label: &'static str,
|
||||
confirm_icon: Option<Icon>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> (
|
||||
ViewHandle<ActionButton>,
|
||||
ViewHandle<ActionButton>,
|
||||
ViewHandle<ActionButton>,
|
||||
) {
|
||||
let confirm_button = ctx.add_typed_action_view(move |_ctx| {
|
||||
let mut button = ActionButton::new(confirm_label, SecondaryTheme)
|
||||
.with_size(ButtonSize::Small)
|
||||
.with_height(32.);
|
||||
if let Some(icon) = confirm_icon {
|
||||
button = button.with_icon(icon);
|
||||
}
|
||||
button.on_click(|ctx| ctx.dispatch_typed_action(GitDialogAction::Confirm))
|
||||
});
|
||||
let cancel_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Cancel", NakedTheme)
|
||||
.with_size(ButtonSize::Small)
|
||||
.with_height(32.)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(GitDialogAction::Cancel))
|
||||
});
|
||||
let close_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("", NakedTheme)
|
||||
.with_icon(Icon::X)
|
||||
.with_size(ButtonSize::Small)
|
||||
.with_tooltip("ESC")
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(GitDialogAction::Cancel))
|
||||
});
|
||||
(confirm_button, cancel_button, close_button)
|
||||
}
|
||||
|
||||
fn repo_path(&self) -> &PathBuf {
|
||||
&self.repo_path
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> &str {
|
||||
&self.branch_name
|
||||
}
|
||||
|
||||
fn mode(&self) -> &GitDialogMode {
|
||||
&self.mode
|
||||
}
|
||||
|
||||
fn mode_mut(&mut self) -> &mut GitDialogMode {
|
||||
&mut self.mode
|
||||
}
|
||||
|
||||
fn loading(&self) -> bool {
|
||||
self.loading
|
||||
}
|
||||
|
||||
/// Disables cancel/confirm/close and swaps the confirm label while the
|
||||
/// async op is running.
|
||||
fn set_loading(&mut self, loading_label: &'static str, ctx: &mut ViewContext<Self>) {
|
||||
self.loading = true;
|
||||
self.confirm_button.update(ctx, |b, ctx| {
|
||||
b.set_label(loading_label, ctx);
|
||||
b.set_disabled(true, ctx);
|
||||
});
|
||||
self.cancel_button.update(ctx, |b, ctx| {
|
||||
b.set_disabled(true, ctx);
|
||||
});
|
||||
self.close_button.update(ctx, |b, ctx| {
|
||||
b.set_disabled(true, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Re-evaluates the confirm button's disabled state based on mode-specific
|
||||
/// inputs (e.g. commit requires a message and some files). Push mode has
|
||||
/// no prerequisites, so it's always enabled when not loading.
|
||||
fn refresh_confirm_enabled(&self, ctx: &mut ViewContext<Self>) {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
let disabled = match &self.mode {
|
||||
GitDialogMode::Commit(state) => !commit::is_ready_to_confirm(state, ctx),
|
||||
GitDialogMode::Push(_) => false,
|
||||
GitDialogMode::CreatePr(state) => !pr::is_ready_to_confirm(state),
|
||||
};
|
||||
self.confirm_button.update(ctx, |b, ctx| {
|
||||
b.set_disabled(disabled, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
match &self.mode {
|
||||
GitDialogMode::Commit(_) => "Commit your changes",
|
||||
GitDialogMode::Push(state) => {
|
||||
if state.publish {
|
||||
"Publish branch"
|
||||
} else {
|
||||
"Push changes"
|
||||
}
|
||||
}
|
||||
GitDialogMode::CreatePr(_) => "Create pull request",
|
||||
}
|
||||
}
|
||||
|
||||
fn render_body(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
match &self.mode {
|
||||
GitDialogMode::Commit(state) => commit::render_body(state, &self.branch_name, app),
|
||||
GitDialogMode::Push(state) => push::render_body(state, &self.branch_name, appearance),
|
||||
GitDialogMode::CreatePr(state) => pr::render_body(state, &self.branch_name, appearance),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `Dialog` component (title, body, bottom buttons) and wraps
|
||||
/// it in centered overlay chrome with a blurred background.
|
||||
fn render_dialog(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let close = ChildView::new(&self.close_button).finish();
|
||||
let cancel = ChildView::new(&self.cancel_button).finish();
|
||||
let confirm = Container::new(ChildView::new(&self.confirm_button).finish())
|
||||
.with_margin_left(8.)
|
||||
.finish();
|
||||
|
||||
let body = self.render_body(app);
|
||||
|
||||
let dialog = Dialog::new(
|
||||
self.title().to_string(),
|
||||
None,
|
||||
UiComponentStyles {
|
||||
width: Some(460.),
|
||||
padding: Some(Coords::uniform(24.).bottom(12.)),
|
||||
..dialog_styles(appearance)
|
||||
},
|
||||
)
|
||||
.with_close_button(close)
|
||||
.with_child(body)
|
||||
.with_separator()
|
||||
.with_bottom_row_child(cancel)
|
||||
.with_bottom_row_child(confirm)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let dialog = Container::new(dialog).with_margin_top(35.).finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
dialog,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background_color(appearance.theme().blurred_background_overlay().into())
|
||||
.with_corner_radius(app.windows().window_corner_radius())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for GitDialog {
|
||||
type Event = GitDialogEvent;
|
||||
}
|
||||
|
||||
impl View for GitDialog {
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
self.render_dialog(app)
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if !focus_ctx.is_self_focused() {
|
||||
return;
|
||||
}
|
||||
match &self.mode {
|
||||
GitDialogMode::Commit(state) => commit::on_focus(state, ctx),
|
||||
GitDialogMode::Push(_) | GitDialogMode::CreatePr(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn keymap_context(&self, _: &AppContext) -> keymap::Context {
|
||||
let mut ctx = keymap::Context::default();
|
||||
ctx.set.insert(Self::ui_name());
|
||||
ctx
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"GitDialog"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for GitDialog {
|
||||
type Action = GitDialogAction;
|
||||
|
||||
fn handle_action(&mut self, action: &GitDialogAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
GitDialogAction::Cancel => {
|
||||
if !self.loading {
|
||||
ctx.emit(GitDialogEvent::Cancelled);
|
||||
}
|
||||
}
|
||||
GitDialogAction::Confirm => {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
match &self.mode {
|
||||
GitDialogMode::Commit(_) => commit::start_confirm(self, ctx),
|
||||
GitDialogMode::Push(_) => push::start_confirm(self, ctx),
|
||||
GitDialogMode::CreatePr(_) => pr::start_confirm(self, ctx),
|
||||
}
|
||||
}
|
||||
GitDialogAction::Commit(sub) => commit::handle_sub_action(self, sub, ctx),
|
||||
GitDialogAction::Push(sub) => push::handle_sub_action(self, sub, ctx),
|
||||
GitDialogAction::Pr(sub) => pr::handle_sub_action(self, sub, ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Create-PR mode for [`GitDialog`].
|
||||
//!
|
||||
//! Renders the branch's PR diff (what would be included in the pull request)
|
||||
//! with expandable per-file stats. On confirm, spawns `create_pr` and shows
|
||||
//! a toast with a clickable "Open PR" link.
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ClippedScrollStateHandle, Container, Element, Flex, MouseStateHandle, ParentElement, Text,
|
||||
},
|
||||
SingletonEntity, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::generate_code_review_content::api::{GenerateCodeReviewContentRequest, OutputType},
|
||||
code_review::git_dialog::{
|
||||
interactive_path_future, render_branch_section, render_file_changes_box,
|
||||
should_send_git_ops_ai_request, show_toast, user_facing_git_error, GitDialog,
|
||||
GitDialogAction, GitDialogEvent, GitDialogMode,
|
||||
},
|
||||
server::server_api::{ai::AIClient, ServerApiProvider},
|
||||
ui_components::icons::Icon,
|
||||
util::git::{
|
||||
create_pr, get_branch_commit_messages, get_branch_diff_entries, get_diff_for_pr,
|
||||
FileChangeEntry, PrInfo,
|
||||
},
|
||||
view_components::{DismissibleToast, ToastLink},
|
||||
workspace::ToastStack,
|
||||
};
|
||||
|
||||
/// PR-mode sub-actions, dispatched wrapped in `GitDialogAction::Pr`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PrSubAction {
|
||||
ToggleChangesExpanded,
|
||||
}
|
||||
|
||||
pub struct PrState {
|
||||
file_changes: Vec<FileChangeEntry>,
|
||||
changes_expanded: bool,
|
||||
summary_mouse_state: MouseStateHandle,
|
||||
changes_scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
pub(super) fn confirm_label_for() -> &'static str {
|
||||
"Create PR"
|
||||
}
|
||||
|
||||
pub(super) fn confirm_icon_for() -> Icon {
|
||||
Icon::Github
|
||||
}
|
||||
|
||||
fn loading_label_for() -> &'static str {
|
||||
"Creating\u{2026}"
|
||||
}
|
||||
|
||||
/// PR mode has no prerequisites beyond a branch with commits; confirm is
|
||||
/// always enabled when not loading.
|
||||
pub(super) fn is_ready_to_confirm(_state: &PrState) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn new_state(
|
||||
repo_path: &std::path::Path,
|
||||
parent_branch: Option<&str>,
|
||||
ctx: &mut ViewContext<GitDialog>,
|
||||
) -> PrState {
|
||||
let diff_repo_path = repo_path.to_path_buf();
|
||||
let parent_branch = parent_branch.map(|s| s.to_string());
|
||||
ctx.spawn(
|
||||
async move { get_branch_diff_entries(&diff_repo_path, parent_branch.as_deref()).await },
|
||||
|me, result, ctx| {
|
||||
if let GitDialogMode::CreatePr(state) = &mut me.mode {
|
||||
match result {
|
||||
Ok(entries) => {
|
||||
state.file_changes = entries;
|
||||
ctx.notify();
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to load branch diff entries: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
PrState {
|
||||
file_changes: Vec::new(),
|
||||
changes_expanded: false,
|
||||
summary_mouse_state: MouseStateHandle::default(),
|
||||
changes_scroll_state: ClippedScrollStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_sub_action(
|
||||
me: &mut GitDialog,
|
||||
action: &PrSubAction,
|
||||
ctx: &mut ViewContext<GitDialog>,
|
||||
) {
|
||||
match action {
|
||||
PrSubAction::ToggleChangesExpanded => {
|
||||
if let GitDialogMode::CreatePr(state) = me.mode_mut() {
|
||||
state.changes_expanded = !state.changes_expanded;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>) {
|
||||
let GitDialogMode::CreatePr(_) = me.mode() else {
|
||||
return;
|
||||
};
|
||||
let repo_path = me.repo_path().clone();
|
||||
let branch_name = me.branch_name().to_string();
|
||||
let parent_branch = me.parent_branch_name.clone();
|
||||
|
||||
me.set_loading(loading_label_for(), ctx);
|
||||
|
||||
let code_review_ai = if should_send_git_ops_ai_request(ctx) {
|
||||
Some(ServerApiProvider::handle(ctx).read(ctx, |p, _| p.get_ai_client()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let path_future = interactive_path_future(ctx);
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let path_env = path_future.await;
|
||||
if let Some(code_review_ai) = code_review_ai.as_ref() {
|
||||
create_pr_with_ai_content(
|
||||
&repo_path,
|
||||
&branch_name,
|
||||
parent_branch.as_deref(),
|
||||
code_review_ai.as_ref(),
|
||||
path_env.as_deref(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
create_pr(
|
||||
&repo_path,
|
||||
None,
|
||||
None,
|
||||
parent_branch.as_deref(),
|
||||
path_env.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
move |_me, result, ctx| {
|
||||
match result {
|
||||
Ok(pr_info) => {
|
||||
show_pr_created_toast(&pr_info, ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to create PR: {err}");
|
||||
show_toast(user_facing_git_error(&err.to_string()), ctx);
|
||||
}
|
||||
}
|
||||
ctx.emit(GitDialogEvent::Completed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates PR title and body via AI (in parallel) and creates the PR.
|
||||
/// Falls back to `gh pr create --fill` if AI generation fails or returns
|
||||
/// empty content.
|
||||
pub(super) async fn create_pr_with_ai_content(
|
||||
repo_path: &std::path::Path,
|
||||
branch_name: &str,
|
||||
parent_branch: Option<&str>,
|
||||
code_review_ai: &dyn AIClient,
|
||||
path_env: Option<&str>,
|
||||
) -> anyhow::Result<PrInfo> {
|
||||
let diff = get_diff_for_pr(repo_path, parent_branch).await?;
|
||||
let commit_messages = get_branch_commit_messages(repo_path, parent_branch)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let title_req = GenerateCodeReviewContentRequest {
|
||||
output_type: OutputType::PrTitle,
|
||||
diff: diff.clone(),
|
||||
branch_name: branch_name.to_string(),
|
||||
commit_messages: commit_messages.clone(),
|
||||
};
|
||||
let body_req = GenerateCodeReviewContentRequest {
|
||||
output_type: OutputType::PrDescription,
|
||||
diff,
|
||||
branch_name: branch_name.to_string(),
|
||||
commit_messages,
|
||||
};
|
||||
|
||||
match futures::try_join!(
|
||||
code_review_ai.generate_code_review_content(title_req),
|
||||
code_review_ai.generate_code_review_content(body_req),
|
||||
) {
|
||||
Ok((title_resp, body_resp))
|
||||
if !title_resp.content.trim().is_empty() && !body_resp.content.trim().is_empty() =>
|
||||
{
|
||||
create_pr(
|
||||
repo_path,
|
||||
Some(&title_resp.content),
|
||||
Some(&body_resp.content),
|
||||
parent_branch,
|
||||
path_env,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(_) => {
|
||||
// Empty title/body would make `gh pr create` fail; fall back to --fill.
|
||||
log::warn!(
|
||||
"AI PR content generation returned empty title/body, falling back to --fill"
|
||||
);
|
||||
crate::util::git::create_pr(repo_path, None, None, parent_branch, path_env).await
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("AI PR content generation failed, falling back to --fill: {err}");
|
||||
crate::util::git::create_pr(repo_path, None, None, parent_branch, path_env).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a toast announcing PR creation with a clickable "Open PR" link.
|
||||
pub(super) fn show_pr_created_toast(pr_info: &PrInfo, ctx: &mut ViewContext<GitDialog>) {
|
||||
let window_id = ctx.window_id();
|
||||
let url = pr_info.url.clone();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let link = ToastLink::new("Open PR".to_string()).with_href(url);
|
||||
let toast =
|
||||
DismissibleToast::default("PR successfully created.".to_string()).with_link(link);
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn render_body(
|
||||
state: &PrState,
|
||||
branch_name: &str,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(render_branch_section(branch_name, appearance))
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(render_changes_section(state, appearance))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_changes_section(state: &PrState, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let main_color = theme.main_text_color(theme.surface_1()).into_solid();
|
||||
|
||||
let label = Text::new(
|
||||
"Changes",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main_color)
|
||||
.finish();
|
||||
|
||||
let changes_box = render_file_changes_box(
|
||||
&state.file_changes,
|
||||
state.changes_expanded,
|
||||
&state.summary_mouse_state,
|
||||
&state.changes_scroll_state,
|
||||
GitDialogAction::Pr(PrSubAction::ToggleChangesExpanded),
|
||||
appearance,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(label).with_margin_bottom(8.).finish())
|
||||
.with_child(changes_box)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Push / publish mode for [`GitDialog`].
|
||||
//!
|
||||
//! Renders the branch's unpushed commit list with lazy per-commit file
|
||||
//! expansion. A single `publish: bool` flag toggles between pushing an
|
||||
//! existing branch and publishing a new one (setting upstream). On confirm,
|
||||
//! spawns `run_push`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
code::editor::{add_color, remove_color},
|
||||
code_review::git_dialog::{
|
||||
interactive_path_future, render_branch_section, render_chevron_icon, render_file_list,
|
||||
show_toast, user_facing_git_error, GitDialog, GitDialogAction, GitDialogEvent,
|
||||
GitDialogMode,
|
||||
},
|
||||
ui_components::icons::Icon,
|
||||
util::git::{Commit, FileChangeEntry},
|
||||
};
|
||||
|
||||
/// Push-specific sub-actions, dispatched wrapped in `GitDialogAction::Push`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PushSubAction {
|
||||
ToggleCommit(String),
|
||||
}
|
||||
|
||||
pub struct PushState {
|
||||
pub(super) publish: bool,
|
||||
commits: Vec<Commit>,
|
||||
expanded: HashMap<String, bool>,
|
||||
commit_files: HashMap<String, Vec<FileChangeEntry>>,
|
||||
commit_mouse_states: HashMap<String, MouseStateHandle>,
|
||||
commits_scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
pub(super) fn new_state(publish: bool, commits: Vec<Commit>) -> PushState {
|
||||
let commit_mouse_states = commits
|
||||
.iter()
|
||||
.map(|c| (c.hash.clone(), MouseStateHandle::default()))
|
||||
.collect();
|
||||
PushState {
|
||||
publish,
|
||||
commits,
|
||||
expanded: HashMap::new(),
|
||||
commit_files: HashMap::new(),
|
||||
commit_mouse_states,
|
||||
commits_scroll_state: ClippedScrollStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn confirm_label(publish: bool) -> &'static str {
|
||||
if publish {
|
||||
"Publish"
|
||||
} else {
|
||||
"Push"
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn confirm_icon(publish: bool) -> Icon {
|
||||
if publish {
|
||||
Icon::UploadCloud
|
||||
} else {
|
||||
Icon::ArrowUp
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_label(publish: bool) -> &'static str {
|
||||
if publish {
|
||||
"Publishing…"
|
||||
} else {
|
||||
"Pushing…"
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_sub_action(
|
||||
me: &mut GitDialog,
|
||||
action: &PushSubAction,
|
||||
ctx: &mut ViewContext<GitDialog>,
|
||||
) {
|
||||
match action {
|
||||
PushSubAction::ToggleCommit(hash) => {
|
||||
let (should_fetch, repo_path) = {
|
||||
let repo_path = me.repo_path().clone();
|
||||
let GitDialogMode::Push(state) = me.mode_mut() else {
|
||||
return;
|
||||
};
|
||||
let is_expanded = state.expanded.entry(hash.clone()).or_insert(false);
|
||||
*is_expanded = !*is_expanded;
|
||||
let should_fetch = *is_expanded && !state.commit_files.contains_key(hash);
|
||||
(should_fetch, repo_path)
|
||||
};
|
||||
|
||||
if should_fetch {
|
||||
let hash_for_cb = hash.clone();
|
||||
let hash_for_async = hash.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
crate::util::git::get_commit_files(&repo_path, &hash_for_async).await
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
if let GitDialogMode::Push(state) = &mut me.mode {
|
||||
match result {
|
||||
Ok(files) => {
|
||||
state.commit_files.insert(hash_for_cb, files);
|
||||
ctx.notify();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch files for commit: {e}");
|
||||
state.expanded.insert(hash_for_cb, false);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext<GitDialog>) {
|
||||
let publish = match me.mode() {
|
||||
GitDialogMode::Push(state) => state.publish,
|
||||
_ => return,
|
||||
};
|
||||
let repo_path = me.repo_path().clone();
|
||||
let branch = me.branch_name().to_string();
|
||||
|
||||
me.set_loading(loading_label(publish), ctx);
|
||||
|
||||
let path_future = interactive_path_future(ctx);
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let path_env = path_future.await;
|
||||
crate::util::git::run_push(&repo_path, &branch, path_env.as_deref()).await
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let toast_msg = if publish {
|
||||
"Branch successfully published."
|
||||
} else {
|
||||
"Changes successfully pushed."
|
||||
};
|
||||
show_toast(toast_msg, ctx);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Push failed: {e}");
|
||||
show_toast(user_facing_git_error(&e.to_string()), ctx);
|
||||
}
|
||||
}
|
||||
let _ = me;
|
||||
ctx.emit(GitDialogEvent::Completed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn render_body(
|
||||
state: &PushState,
|
||||
branch_name: &str,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut body = Flex::column().with_child(
|
||||
Container::new(render_branch_section(branch_name, appearance))
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if !state.commits.is_empty() {
|
||||
body.add_child(render_commits_section(state, appearance));
|
||||
}
|
||||
|
||||
body.finish()
|
||||
}
|
||||
|
||||
fn render_commits_section(state: &PushState, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let main_color = theme.main_text_color(theme.surface_1()).into_solid();
|
||||
let sub_color = theme.sub_text_color(theme.surface_1()).into_solid();
|
||||
|
||||
let label = Text::new(
|
||||
"Included commits",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main_color)
|
||||
.finish();
|
||||
|
||||
let mut commit_list = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for commit in &state.commits {
|
||||
let is_expanded = state.expanded.get(&commit.hash).copied().unwrap_or(false);
|
||||
let hash = commit.hash.clone();
|
||||
|
||||
let subject = Text::new(
|
||||
commit.subject.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main_color)
|
||||
.soft_wrap(false)
|
||||
.finish();
|
||||
|
||||
let stats_text = format!(
|
||||
"{} {}",
|
||||
commit.files_changed,
|
||||
if commit.files_changed == 1 {
|
||||
"file"
|
||||
} else {
|
||||
"files"
|
||||
},
|
||||
);
|
||||
|
||||
let mut stats_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new(
|
||||
stats_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(sub_color)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if commit.additions > 0 {
|
||||
stats_row.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
format!("+{}", commit.additions),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(add_color(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if commit.deletions > 0 {
|
||||
stats_row.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
format!("-{}", commit.deletions),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(remove_color(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let info_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(subject)
|
||||
.with_child(
|
||||
Container::new(stats_row.finish())
|
||||
.with_margin_top(2.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let chevron = render_chevron_icon(is_expanded, appearance);
|
||||
|
||||
let summary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(info_col)
|
||||
.with_child(chevron)
|
||||
.finish();
|
||||
|
||||
let mouse_state = state
|
||||
.commit_mouse_states
|
||||
.get(&commit.hash)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let clickable_summary = Hoverable::new(mouse_state, |_| {
|
||||
Container::new(summary_row)
|
||||
.with_padding_top(6.)
|
||||
.with_padding_bottom(6.)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_right(8.)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(GitDialogAction::Push(PushSubAction::ToggleCommit(
|
||||
hash.clone(),
|
||||
)));
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let mut commit_col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
commit_col.add_child(clickable_summary);
|
||||
|
||||
if is_expanded {
|
||||
if let Some(files) = state.commit_files.get(&commit.hash) {
|
||||
commit_col.add_child(render_file_list(files, appearance));
|
||||
} else {
|
||||
let loading = Container::new(
|
||||
Text::new(
|
||||
"Loading…",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(sub_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_bottom(6.)
|
||||
.finish();
|
||||
commit_col.add_child(loading);
|
||||
}
|
||||
}
|
||||
|
||||
let bordered_commit = Container::new(commit_col.finish())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.surface_3()))
|
||||
.finish();
|
||||
|
||||
commit_list.add_child(
|
||||
Container::new(bordered_commit)
|
||||
.with_margin_bottom(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_COMMITS_HEIGHT: f32 = 300.;
|
||||
|
||||
let commit_content = commit_list.finish();
|
||||
let commits_element = ConstrainedBox::new(
|
||||
ClippedScrollable::vertical(
|
||||
state.commits_scroll_state.clone(),
|
||||
commit_content,
|
||||
ScrollbarWidth::Auto,
|
||||
theme.nonactive_ui_detail().into(),
|
||||
theme.active_ui_detail().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(MAX_COMMITS_HEIGHT)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(label).with_margin_bottom(8.).finish())
|
||||
.with_child(commits_element)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use warpui::ModelContext;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use {
|
||||
crate::throttle::throttle,
|
||||
crate::util::git::{detect_current_branch_display, detect_main_branch},
|
||||
async_channel::Sender,
|
||||
repo_metadata::{
|
||||
repositories::DetectedRepositories,
|
||||
repository::{RepositorySubscriber, SubscriberId},
|
||||
Repository, RepositoryUpdate,
|
||||
},
|
||||
std::{collections::HashMap, time::Duration},
|
||||
warpui::{r#async::SpawnedFutureHandle, ModelHandle, WeakModelHandle},
|
||||
};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use super::diff_state::DiffStats;
|
||||
|
||||
/// Public metadata exposed to consumers — the subset of diff metadata
|
||||
/// that the git chip (prompt display, agent view footer) needs.
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStatusMetadata {
|
||||
pub current_branch_name: String,
|
||||
pub main_branch_name: String,
|
||||
pub stats_against_head: DiffStats,
|
||||
}
|
||||
|
||||
// ── GitStatusUpdateModel (singleton cache) ──────────────────────────────────
|
||||
|
||||
/// Singleton model that acts as a cache / factory for per-repository
|
||||
/// [`GitRepoStatusModel`] instances.
|
||||
///
|
||||
/// Multiple terminals in the same repo share a single sub-model. When the last
|
||||
/// strong handle to a sub-model is dropped, the watcher is torn down
|
||||
/// automatically.
|
||||
pub struct GitStatusUpdateModel {
|
||||
#[cfg(feature = "local_fs")]
|
||||
repos: HashMap<PathBuf, WeakModelHandle<GitRepoStatusModel>>,
|
||||
}
|
||||
|
||||
// ── Non-local_fs stub ───────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
#[allow(dead_code)]
|
||||
impl GitStatusUpdateModel {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── local_fs implementation ─────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl GitStatusUpdateModel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
repos: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a per-repo status model for `repo_path`.
|
||||
///
|
||||
/// If a live model already exists for this path, returns a new strong handle
|
||||
/// to it. Otherwise, creates a new [`GitRepoStatusModel`] with an active
|
||||
/// filesystem watcher and returns a handle to it.
|
||||
///
|
||||
/// Callers hold the returned `ModelHandle` for as long as they need updates.
|
||||
/// When all handles are dropped, the model (and its watcher) is torn down.
|
||||
pub fn subscribe(
|
||||
&mut self,
|
||||
repo_path: &Path,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<ModelHandle<GitRepoStatusModel>> {
|
||||
let repo_path_buf = repo_path.to_path_buf();
|
||||
|
||||
// Check the cache for an existing live model.
|
||||
if let Some(weak) = self.repos.get(&repo_path_buf) {
|
||||
if let Some(handle) = weak.upgrade(ctx) {
|
||||
return Ok(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new sub-model.
|
||||
let Some(repository_model) =
|
||||
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(repo_path, ctx)
|
||||
else {
|
||||
anyhow::bail!(
|
||||
"No watched repository found for path: {}",
|
||||
repo_path.display()
|
||||
);
|
||||
};
|
||||
|
||||
let handle = ctx
|
||||
.add_model(|ctx| GitRepoStatusModel::new(repo_path_buf.clone(), repository_model, ctx));
|
||||
|
||||
self.repos.insert(repo_path_buf, handle.downgrade());
|
||||
Ok(handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for GitStatusUpdateModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for GitStatusUpdateModel {}
|
||||
|
||||
// ── GitRepoStatusModel ──────────────────────────────────────────────────────
|
||||
|
||||
/// Per-repository model that owns the filesystem watcher and exposes git status
|
||||
/// metadata. Consumers hold a `ModelHandle<GitRepoStatusModel>` and subscribe
|
||||
/// to its events directly — no path-filtering required.
|
||||
///
|
||||
/// When all strong handles are dropped the model (and its watcher) is
|
||||
/// automatically torn down.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub struct GitRepoStatusModel {
|
||||
repo_path: PathBuf,
|
||||
repository: ModelHandle<Repository>,
|
||||
subscriber_id: Option<SubscriberId>,
|
||||
metadata: Option<GitStatusMetadata>,
|
||||
computing_metadata_abort_handle: Option<SpawnedFutureHandle>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug)]
|
||||
pub enum GitRepoStatusEvent {
|
||||
/// Emitted whenever the metadata changes (branch name, diff stats, etc.).
|
||||
MetadataChanged,
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl Entity for GitRepoStatusModel {
|
||||
type Event = GitRepoStatusEvent;
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl GitRepoStatusModel {
|
||||
/// Create a new per-repo status model, set up the filesystem watcher, and
|
||||
/// kick off the initial metadata computation.
|
||||
fn new(
|
||||
repo_path: PathBuf,
|
||||
repository_model: ModelHandle<Repository>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let mut model = Self {
|
||||
repo_path: repo_path.clone(),
|
||||
repository: repository_model.clone(),
|
||||
subscriber_id: None,
|
||||
metadata: None,
|
||||
computing_metadata_abort_handle: None,
|
||||
};
|
||||
|
||||
// Kick off initial metadata computation.
|
||||
model.refresh_metadata(ctx);
|
||||
|
||||
// Start watching for filesystem changes.
|
||||
let (repository_update_tx, repository_update_rx) = async_channel::unbounded();
|
||||
let (throttled_tx, throttled_rx) = async_channel::unbounded();
|
||||
let start = repository_model.update(ctx, |repo, ctx| {
|
||||
repo.start_watching(
|
||||
Box::new(GitStatusRepositorySubscriber {
|
||||
repository_update_tx,
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
model.subscriber_id = Some(start.subscriber_id);
|
||||
|
||||
// Handle watcher registration.
|
||||
ctx.spawn(start.registration_future, |me, result, ctx| {
|
||||
if let Err(err) = result {
|
||||
log::warn!("GitRepoStatusModel: watcher registration failed: {err}");
|
||||
if let Some(subscriber_id) = me.subscriber_id.take() {
|
||||
me.repository.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stream raw updates; determine whether a throttled metadata refresh is warranted.
|
||||
{
|
||||
let throttled_tx_clone = throttled_tx;
|
||||
ctx.spawn_stream_local(
|
||||
repository_update_rx,
|
||||
move |_me, update: RepositoryUpdate, _ctx| {
|
||||
if Self::should_refresh_metadata(&update) {
|
||||
let _ = throttled_tx_clone.try_send(());
|
||||
}
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
}
|
||||
|
||||
// Throttled metadata refresh (at most once every 5 seconds).
|
||||
ctx.spawn_stream_local(
|
||||
throttle(Duration::from_secs(5), throttled_rx),
|
||||
|me, _, ctx| {
|
||||
me.refresh_metadata(ctx);
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
model
|
||||
}
|
||||
|
||||
/// Read the current metadata. Returns `None` if metadata hasn't been
|
||||
/// computed yet.
|
||||
pub fn metadata(&self) -> Option<&GitStatusMetadata> {
|
||||
self.metadata.as_ref()
|
||||
}
|
||||
|
||||
/// The path to the repository root.
|
||||
pub fn repo_path(&self) -> &Path {
|
||||
&self.repo_path
|
||||
}
|
||||
|
||||
/// Manually trigger a metadata refresh. Called by the terminal view after
|
||||
/// events that may have changed git state (block completed, agent file
|
||||
/// edits, etc.).
|
||||
pub fn refresh_metadata(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(handle) = self.computing_metadata_abort_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
let repo_path_buf = self.repo_path.clone();
|
||||
self.computing_metadata_abort_handle = Some(ctx.spawn(
|
||||
async move { Self::load_metadata(repo_path_buf).await },
|
||||
|me, result, ctx| {
|
||||
me.handle_metadata_result(result, ctx);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// ── internal helpers ────────────────────────────────────────────────
|
||||
|
||||
fn handle_metadata_result(
|
||||
&mut self,
|
||||
result: anyhow::Result<GitStatusMetadata>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match result {
|
||||
Ok(metadata) => self.metadata = Some(metadata),
|
||||
Err(e) => {
|
||||
log::warn!("GitRepoStatusModel: metadata load failed: {e}");
|
||||
self.metadata = None;
|
||||
}
|
||||
}
|
||||
ctx.emit(GitRepoStatusEvent::MetadataChanged);
|
||||
}
|
||||
|
||||
/// Decide whether a `RepositoryUpdate` warrants a metadata refresh.
|
||||
fn should_refresh_metadata(update: &RepositoryUpdate) -> bool {
|
||||
if update.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if update.commit_updated || update.index_lock_detected {
|
||||
return true;
|
||||
}
|
||||
// Check if any non-ignored file was touched.
|
||||
let changed_count = update
|
||||
.added
|
||||
.iter()
|
||||
.chain(&update.modified)
|
||||
.chain(&update.deleted)
|
||||
.chain(update.moved.keys())
|
||||
.chain(update.moved.values())
|
||||
.filter(|f| !f.is_ignored)
|
||||
.count();
|
||||
changed_count > 0
|
||||
}
|
||||
|
||||
/// Compute metadata for a repo — branch names and diff stats against HEAD.
|
||||
///
|
||||
/// This reuses logic extracted from `DiffStateModel::load_metadata_for_repo`
|
||||
/// but only computes the HEAD (uncommitted) stats since that's all the git
|
||||
/// chip needs.
|
||||
async fn load_metadata(repo_path: PathBuf) -> anyhow::Result<GitStatusMetadata> {
|
||||
// Detect main branch.
|
||||
let main_branch_name = detect_main_branch(&repo_path).await?;
|
||||
// Detect current branch (using the display variant so detached HEAD
|
||||
// shows the short SHA instead of the literal "HEAD").
|
||||
let current_branch_name = detect_current_branch_display(&repo_path).await?;
|
||||
// Diff stats against HEAD.
|
||||
let stats_against_head =
|
||||
super::diff_state::DiffStateModel::diff_metadata_against_head(&repo_path).await?;
|
||||
|
||||
Ok(GitStatusMetadata {
|
||||
current_branch_name,
|
||||
main_branch_name,
|
||||
stats_against_head: stats_against_head.aggregate_stats,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "local_fs"))]
|
||||
impl GitRepoStatusModel {
|
||||
pub(crate) fn new_for_test(
|
||||
repository: ModelHandle<Repository>,
|
||||
metadata: Option<GitStatusMetadata>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo_path: PathBuf::from("/test"),
|
||||
repository,
|
||||
subscriber_id: None,
|
||||
metadata,
|
||||
computing_metadata_abort_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_metadata_for_test(
|
||||
&mut self,
|
||||
metadata: Option<GitStatusMetadata>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.metadata = metadata;
|
||||
ctx.emit(GitRepoStatusEvent::MetadataChanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl Drop for GitRepoStatusModel {
|
||||
fn drop(&mut self) {
|
||||
// Note: we cannot call `repository.update()` here because `Drop` does
|
||||
// not have access to `ModelContext`. The `Repository` model will clean
|
||||
// up the subscriber when it notices the channel has been dropped.
|
||||
if let Some(handle) = self.computing_metadata_abort_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Repository subscriber adapter ───────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
struct GitStatusRepositorySubscriber {
|
||||
repository_update_tx: Sender<RepositoryUpdate>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl RepositorySubscriber for GitStatusRepositorySubscriber {
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
repository: &Repository,
|
||||
update: &RepositoryUpdate,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>> {
|
||||
let tx = self.repository_update_tx.clone();
|
||||
let update = update.clone();
|
||||
let index_lock_path = repository.git_dir().join("index.lock");
|
||||
Box::pin(async move {
|
||||
// Suppress commit_updated events while the git index is locked to
|
||||
// avoid reacting to stale intermediate state during git operations.
|
||||
if update.commit_updated && async_fs::metadata(&index_lock_path).await.is_ok() {
|
||||
return;
|
||||
}
|
||||
let _ = tx.send(update).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use rangemap::RangeSet;
|
||||
use warp_editor::content::text::LineCount;
|
||||
use warp_editor::render::model::LineCount as RenderLineCount;
|
||||
|
||||
/// The number of context lines to show before and after each change
|
||||
const CONTEXT_LINES: usize = 4;
|
||||
|
||||
/// Calculate which lines should be hidden in a file after applying diffs.
|
||||
///
|
||||
/// This function takes a list of diff deltas and comment line numbers, then calculates which lines should be hidden
|
||||
/// (everything except for CONTEXT_LINES before and after each change and comment).
|
||||
///
|
||||
/// These are 0-indexed line numbers BEFORE the diffs are applied, so the first line is 0.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `diffs` - The list of diff deltas that will be applied to the file
|
||||
/// * `line_count` - The total number of lines in the file before any diffs are applied
|
||||
/// * `comment_line_numbers` - Line numbers where comments exist (0-indexed)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `RangeSet<usize>` containing the line ranges that should be hidden (0-indexed).
|
||||
///
|
||||
/// Note that DiffDelta uses 1-indexed line ranges, so we convert them to 0-indexed
|
||||
/// ```
|
||||
pub fn calculate_hidden_lines(
|
||||
diffs: &[DiffDelta],
|
||||
line_count: usize,
|
||||
comment_line_numbers: &[RenderLineCount],
|
||||
) -> RangeSet<LineCount> {
|
||||
// Calculate the visible line ranges (with context)
|
||||
let mut visible_ranges: RangeSet<LineCount> = RangeSet::new();
|
||||
|
||||
// Add ranges for diffs
|
||||
for diff in diffs {
|
||||
// Convert 1-indexed line ranges to 0-indexed
|
||||
let start_line = diff.replacement_line_range.start.saturating_sub(1);
|
||||
let end_line = diff.replacement_line_range.end.saturating_sub(1);
|
||||
|
||||
let context_start = start_line.saturating_sub(CONTEXT_LINES);
|
||||
let context_end = end_line + CONTEXT_LINES;
|
||||
|
||||
if context_start < context_end {
|
||||
visible_ranges.insert(context_start.into()..context_end.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Add ranges for comments
|
||||
for &comment_line in comment_line_numbers {
|
||||
let line_number = comment_line.as_usize();
|
||||
let context_start = line_number.saturating_sub(CONTEXT_LINES);
|
||||
let context_end = (line_number + CONTEXT_LINES + 1).min(line_count); // +1 because we want to include the line itself, clamped to file bounds
|
||||
|
||||
if context_start < context_end {
|
||||
visible_ranges.insert(LineCount::from(context_start)..LineCount::from(context_end));
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate hidden ranges as the complement of visible ranges
|
||||
let all_lines: Range<LineCount> = LineCount::from(0)..LineCount::from(line_count);
|
||||
|
||||
// Find gaps in the visible ranges
|
||||
visible_ranges
|
||||
.gaps(&all_lines)
|
||||
.collect::<RangeSet<LineCount>>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_diffs() {
|
||||
let hidden_lines = calculate_hidden_lines(&[], 20, &[]);
|
||||
|
||||
let expected_hidden = [LineCount::from(0)..LineCount::from(20)]
|
||||
.into_iter()
|
||||
.collect::<RangeSet<LineCount>>();
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_diff_middle_of_file() {
|
||||
// File with 20 lines, change at lines 10-12 (1-indexed)
|
||||
let diffs = vec![DiffDelta {
|
||||
replacement_line_range: 10..13, // 1-indexed, replacing lines 10, 11, 12
|
||||
insertion: "new line 1\nnew line 2\nnew line 3".to_string(), // 3 lines
|
||||
}];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
|
||||
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(0.into()..5.into()); // lines 1-5 (1-indexed)
|
||||
expected_hidden.insert(16.into()..20.into()); // lines 17-20 (1-indexed)
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insertion_at_beginning() {
|
||||
// Insert at the very beginning of file
|
||||
let diffs = vec![DiffDelta {
|
||||
replacement_line_range: 1..1, // Insert at beginning
|
||||
insertion: "new line 1\nnew line 2".to_string(), // 2 lines
|
||||
}];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
|
||||
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(4.into()..20.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_diffs_overlapping_context() {
|
||||
// Two changes close enough that their context overlaps
|
||||
let diffs = vec![
|
||||
DiffDelta {
|
||||
replacement_line_range: 5..6, // 1-indexed, replace line 5
|
||||
insertion: "change 1".to_string(), // 1 line
|
||||
},
|
||||
DiffDelta {
|
||||
replacement_line_range: 8..9, // 1-indexed, replace line 8
|
||||
insertion: "change 2".to_string(), // 1 line
|
||||
},
|
||||
];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(12.into()..20.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_diffs_separate_context() {
|
||||
// Two changes far apart
|
||||
let diffs = vec![
|
||||
DiffDelta {
|
||||
replacement_line_range: 3..4, // 1-indexed, replace line 3
|
||||
insertion: "change 1".to_string(), // 1 line
|
||||
},
|
||||
DiffDelta {
|
||||
replacement_line_range: 15..16, // 1-indexed, replace line 15
|
||||
insertion: "change 2".to_string(), // 1 line
|
||||
},
|
||||
];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&diffs, 20, &[]);
|
||||
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(7.into()..10.into());
|
||||
expected_hidden.insert(19.into()..20.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comments_only_no_diffs() {
|
||||
// File with 20 lines, comments at lines 5 and 15 (0-indexed)
|
||||
let comment_lines = vec![RenderLineCount::from(5), RenderLineCount::from(15)];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
|
||||
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
// Comment at line 5 shows lines 1-9 (0-indexed), so hide lines 0 and 10-14
|
||||
expected_hidden.insert(0.into()..1.into());
|
||||
expected_hidden.insert(10.into()..11.into());
|
||||
// Comment at line 15 shows lines 11-19 (0-indexed), so hide line 20
|
||||
// Note: lines 10-19 are visible due to comment at line 15
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_overlapping_with_diff_context() {
|
||||
// File with 20 lines, diff at lines 8-9 (1-indexed) and comment at line 10 (0-indexed)
|
||||
let diffs = vec![DiffDelta {
|
||||
replacement_line_range: 8..10, // 1-indexed, replacing lines 8, 9
|
||||
insertion: "new line 1\nnew line 2".to_string(),
|
||||
}];
|
||||
let comment_lines = vec![RenderLineCount::from(10)];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&diffs, 20, &comment_lines);
|
||||
|
||||
// Diff at lines 8-9 (1-indexed) = lines 7-8 (0-indexed) with ±4 context = lines 3-12
|
||||
// Comment at line 10 (0-indexed) with ±4 context = lines 6-14
|
||||
// Combined visible range: lines 3-14
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(0.into()..3.into());
|
||||
expected_hidden.insert(15.into()..20.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_separate_from_diffs() {
|
||||
// File with 30 lines, diff at lines 5-6 (1-indexed) and comment at line 20 (0-indexed)
|
||||
let diffs = vec![DiffDelta {
|
||||
replacement_line_range: 5..7, // 1-indexed, replacing lines 5, 6
|
||||
insertion: "changed line".to_string(),
|
||||
}];
|
||||
let comment_lines = vec![RenderLineCount::from(20)];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&diffs, 30, &comment_lines);
|
||||
|
||||
// Diff at lines 5-6 (1-indexed) = lines 4-5 (0-indexed) with ±4 context = lines 0-9
|
||||
// Comment at line 20 (0-indexed) with ±4 context = lines 16-24
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(10.into()..16.into());
|
||||
expected_hidden.insert(25.into()..30.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_outside_file_bounds() {
|
||||
// File with 10 lines, comment at line 15 (0-indexed) - outside bounds
|
||||
let comment_lines = vec![RenderLineCount::from(15)];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&[], 10, &comment_lines);
|
||||
|
||||
// Comment is outside file bounds, so everything should be hidden
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(0.into()..10.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_at_file_beginning() {
|
||||
// File with 20 lines, comment at line 1 (0-indexed)
|
||||
let comment_lines = vec![RenderLineCount::from(1)];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
|
||||
|
||||
// Comment at line 1 with ±4 context = lines 0-5 (saturating_sub handles negative)
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(6.into()..20.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_at_file_end() {
|
||||
// File with 20 lines, comment at line 18 (0-indexed)
|
||||
let comment_lines = vec![RenderLineCount::from(18)];
|
||||
|
||||
let hidden_lines = calculate_hidden_lines(&[], 20, &comment_lines);
|
||||
|
||||
// Comment at line 18 with ±4 context = lines 14-19 (clamped to file bounds)
|
||||
let mut expected_hidden = RangeSet::new();
|
||||
expected_hidden.insert(0.into()..14.into());
|
||||
|
||||
assert_eq!(hidden_lines, expected_hidden);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
pub mod code_review_view;
|
||||
pub mod comment_list_view;
|
||||
pub mod context;
|
||||
pub mod diff_size_limits;
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub mod diff_state;
|
||||
pub mod editor_state;
|
||||
pub(crate) mod find_model;
|
||||
pub(crate) mod git_dialog;
|
||||
pub mod git_status_update;
|
||||
mod hidden_lines;
|
||||
pub mod telemetry_event;
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_imports))]
|
||||
pub use telemetry_event::CodeReviewTelemetryEvent;
|
||||
|
||||
pub(crate) mod code_review_header;
|
||||
pub(crate) mod comment_rendering;
|
||||
pub mod comments;
|
||||
pub(crate) mod diff_menu;
|
||||
pub(crate) mod diff_selector;
|
||||
pub(crate) mod file_invalidation_queue;
|
||||
|
||||
use code_review_view::CodeReviewAction;
|
||||
use std::path::{Path, PathBuf};
|
||||
use warpui::{
|
||||
id,
|
||||
keymap::{EditableBinding, FixedBinding},
|
||||
AppContext, Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
|
||||
use crate::terminal::{view::TerminalView, CLIAgent};
|
||||
use crate::util::bindings::CustomAction;
|
||||
|
||||
/// Arguments needed to open or toggle the code review panel.
|
||||
/// Bundled into a struct so that events can atomically open the
|
||||
/// review and perform follow-up work without relying on event ordering.
|
||||
#[derive(Clone)]
|
||||
pub struct CodeReviewPanelArg {
|
||||
pub repo_path: Option<PathBuf>,
|
||||
pub terminal_view: WeakViewHandle<TerminalView>,
|
||||
pub entrypoint: CodeReviewPaneEntrypoint,
|
||||
pub focus_new_pane: bool,
|
||||
pub cli_agent: Option<CLIAgent>,
|
||||
}
|
||||
|
||||
/// Scope for diff set context attachment
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DiffSetScope {
|
||||
All,
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
/// Register keybindings for code review functionality.
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.register_editable_bindings([
|
||||
EditableBinding::new(
|
||||
"code_review:save_all_unsaved_files",
|
||||
"Save all unsaved files in code review",
|
||||
CodeReviewAction::SaveAllUnsavedFiles,
|
||||
)
|
||||
.with_context_predicate(id!("CodeReviewView"))
|
||||
.with_key_binding("cmdorctrl-s"),
|
||||
EditableBinding::new(
|
||||
"code_review:show_find_bar",
|
||||
"Show find bar in code review",
|
||||
CodeReviewAction::ShowFindBar,
|
||||
)
|
||||
.with_context_predicate(id!("CodeReviewView"))
|
||||
.with_key_binding("cmdorctrl-f")
|
||||
.with_enabled(|| crate::features::FeatureFlag::CodeReviewFind.is_enabled()),
|
||||
]);
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::custom(
|
||||
CustomAction::Undo,
|
||||
CodeReviewAction::UndoRevert,
|
||||
"Undo",
|
||||
id!("CodeReviewView") & !id!("IMEOpen"),
|
||||
)]);
|
||||
|
||||
diff_menu::init(app);
|
||||
diff_selector::init(app);
|
||||
git_dialog::init(app);
|
||||
}
|
||||
|
||||
/// Uses heuristics to determine if a file is auto-generated.
|
||||
fn is_file_autogenerated(file_path: &Path, content: Option<&str>) -> bool {
|
||||
const AUTOGEN_HEADERS: [&str; 3] = [
|
||||
"Code generated by",
|
||||
"This file is automatically generated",
|
||||
"AUTO-GENERATED FILE",
|
||||
];
|
||||
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Check for specific lock files and autogenerated files by exact name
|
||||
match file_name {
|
||||
// Package manager lock files
|
||||
"Cargo.lock" | "package-lock.json" | "yarn.lock" | "pnpm-lock.yaml" | "Gemfile.lock"
|
||||
| "composer.lock" | "Pipfile.lock" | "poetry.lock" | "go.sum" | "mix.lock" => return true,
|
||||
// Log files
|
||||
name if name.ends_with(".log") => return true,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Check for file path hints.
|
||||
if file_name.contains(".generated.")
|
||||
|| file_name.contains(".gen.")
|
||||
|| file_name.ends_with(".min.js")
|
||||
|| file_name.ends_with(".min.css")
|
||||
|| file_name.contains(".bundle.")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for directory structure hints.
|
||||
let file_path_str = file_path.to_string_lossy();
|
||||
if file_path_str.contains("__generated__/")
|
||||
|| file_path_str.contains(".auto/")
|
||||
|| file_path_str.contains("codegen/")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check the first line of the modified file for autogeneration headers.
|
||||
// We don't check any actual diffs because the user should probably inspect
|
||||
// auto-generated files if they are created for the first time.
|
||||
if let Some(content) = content {
|
||||
if let Some(first_line) = content.lines().next() {
|
||||
if AUTOGEN_HEADERS
|
||||
.iter()
|
||||
.any(|header| first_line.contains(header))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// A [`SingletonEntity`] that the tracks events for the code review model throughought the app.
|
||||
/// We need this because toasts are emitted in the Workspace, and want a click handler that triggers
|
||||
/// behavior in a _specific_ review pane. We use this model get around restrictions that make it hard
|
||||
/// to emit a CodeReviewView typed action from the toast because it's not in the view reponder chain of the
|
||||
/// Workspace.
|
||||
pub struct GlobalCodeReviewModel;
|
||||
|
||||
impl GlobalCodeReviewModel {
|
||||
pub fn undo_revert_in_code_review_pane(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.emit(GlobalCodeReviewEvent::DiffReverted { window_id, view_id });
|
||||
}
|
||||
}
|
||||
|
||||
pub enum GlobalCodeReviewEvent {
|
||||
DiffReverted {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
},
|
||||
}
|
||||
|
||||
impl SingletonEntity for GlobalCodeReviewModel {}
|
||||
|
||||
impl Entity for GlobalCodeReviewModel {
|
||||
type Event = GlobalCodeReviewEvent;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{elements::ScrollOffset, units::Pixels, ViewContext, ViewHandle};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::{AppContext, WeakViewHandle};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use super::FILE_HEADER_HEIGHT;
|
||||
use super::{CodeReviewView, CodeReviewViewState};
|
||||
use crate::code::editor::model::StableEditorLine;
|
||||
use crate::code::local_code_editor::LocalCodeEditorView;
|
||||
|
||||
/// Context for preserving scroll position across file diff content changes.
|
||||
/// The scroll position can be in different regions of the file diff item.
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub(super) enum RelocatableScrollContext {
|
||||
/// Scroll position is in the file header region.
|
||||
/// Stores the pixel offset from the top of the header.
|
||||
Header { offset: Pixels },
|
||||
/// Scroll is on a line in the editor content (current or removed).
|
||||
/// The [`StableEditorLine`] contains an internal anchor that
|
||||
/// automatically tracks through buffer edits.
|
||||
EditorLine {
|
||||
stable_line: StableEditorLine,
|
||||
intra_line_offset: Pixels,
|
||||
},
|
||||
/// Scroll position is in the footer region.
|
||||
/// Stores the pixel offset from the top of the footer.
|
||||
Footer { offset: Pixels },
|
||||
}
|
||||
|
||||
impl CodeReviewView {
|
||||
/// Computes the adjusted item-relative scroll offset for a file diff item
|
||||
/// based on the captured scroll context. Called by the `ListState`
|
||||
/// adjustment closure when an item's height changes during layout.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(super) fn adjust_scroll_offset(
|
||||
view_handle: &WeakViewHandle<Self>,
|
||||
index: usize,
|
||||
captured_context: &RelocatableScrollContext,
|
||||
app: &AppContext,
|
||||
) -> Option<Pixels> {
|
||||
if !FeatureFlag::CodeReviewScrollPreservation.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The adjustment function returns item-relative offsets (offset_from_start),
|
||||
// NOT absolute positions. The ListState stores the result directly in
|
||||
// scroll_top.offset_from_start, which is relative to the current scroll item.
|
||||
|
||||
match captured_context {
|
||||
RelocatableScrollContext::Header { offset } => Some(*offset),
|
||||
RelocatableScrollContext::EditorLine {
|
||||
stable_line,
|
||||
intra_line_offset,
|
||||
} => {
|
||||
let view_handle = view_handle.upgrade(app)?;
|
||||
let view = view_handle.as_ref(app);
|
||||
|
||||
let CodeReviewViewState::Loaded(state) = view.state() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let editor_state = state
|
||||
.file_states
|
||||
.get_index(index)?
|
||||
.1
|
||||
.editor_state
|
||||
.as_ref()?;
|
||||
let editor_view = editor_state.editor.as_ref(app).editor().as_ref(app);
|
||||
|
||||
let line_offset = editor_view.line_top(stable_line, app)?;
|
||||
|
||||
Some(Pixels::new(FILE_HEADER_HEIGHT) + line_offset + *intra_line_offset)
|
||||
}
|
||||
RelocatableScrollContext::Footer { offset } => {
|
||||
let view_handle = view_handle.upgrade(app)?;
|
||||
let view = view_handle.as_ref(app);
|
||||
|
||||
let CodeReviewViewState::Loaded(state) = view.state() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let editor_state = state
|
||||
.file_states
|
||||
.get_index(index)?
|
||||
.1
|
||||
.editor_state
|
||||
.as_ref()?;
|
||||
|
||||
let content_height = editor_state
|
||||
.editor
|
||||
.as_ref(app)
|
||||
.editor()
|
||||
.as_ref(app)
|
||||
.content_height(app);
|
||||
|
||||
Some(Pixels::new(FILE_HEADER_HEIGHT) + content_height + *offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the scroll preservation context for the given index and editor.
|
||||
/// Returns `Some(context)` only if the index is the currently scrolled item.
|
||||
/// Detects whether scroll is in header, editor content, or footer region.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(super) fn compute_scroll_context_for_index(
|
||||
&self,
|
||||
index: usize,
|
||||
editor: &ViewHandle<LocalCodeEditorView>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Option<RelocatableScrollContext> {
|
||||
// Only compute context if this is the currently scrolled item
|
||||
let current_scroll_index = self.viewported_list_state.get_scroll_index();
|
||||
if current_scroll_index != index {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get the scroll offset within this item
|
||||
let scroll_offset_in_item = self.viewported_list_state.get_scroll_offset();
|
||||
let file_header_height = Pixels::new(FILE_HEADER_HEIGHT);
|
||||
|
||||
// Check if scroll is in the header region
|
||||
if scroll_offset_in_item < file_header_height {
|
||||
return Some(RelocatableScrollContext::Header {
|
||||
offset: scroll_offset_in_item,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute offset relative to editor content
|
||||
let scroll_offset_in_editor = scroll_offset_in_item - file_header_height;
|
||||
|
||||
// Check footer region using the view-level accessor.
|
||||
let editor_view = editor.as_ref(ctx).editor();
|
||||
let content_height = editor_view.as_ref(ctx).content_height(ctx);
|
||||
|
||||
if scroll_offset_in_editor >= content_height {
|
||||
return Some(RelocatableScrollContext::Footer {
|
||||
offset: scroll_offset_in_editor - content_height,
|
||||
});
|
||||
}
|
||||
|
||||
// Clone the model handle so immutable borrows on ctx can be released
|
||||
// before the anchor creation which requires a mutable borrow.
|
||||
let editor_model_handle = editor_view.as_ref(ctx).model.clone();
|
||||
|
||||
// Identify the line and create anchors (requires mutable context).
|
||||
let (stable_line, intra_line_offset) = editor_model_handle.update(ctx, |model, ctx| {
|
||||
model.line_at_vertical_offset(scroll_offset_in_editor, ctx)
|
||||
})?;
|
||||
|
||||
Some(RelocatableScrollContext::EditorLine {
|
||||
stable_line,
|
||||
intra_line_offset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wasm stub - scroll preservation not supported
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub(super) fn compute_scroll_context_for_index(
|
||||
&self,
|
||||
_index: usize,
|
||||
_editor: &ViewHandle<LocalCodeEditorView>,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) -> Option<RelocatableScrollContext> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Called when scrolling settles (via debounced scroll events).
|
||||
/// Computes and stores the current scroll context on the ListState
|
||||
/// so the explicit invalidation path can adjust scroll position
|
||||
/// when a file diff item's height changes.
|
||||
fn on_scroll_settled(&mut self, _scroll_offset: ScrollOffset, ctx: &mut ViewContext<Self>) {
|
||||
let scroll_index = self.viewported_list_state.get_scroll_index();
|
||||
|
||||
let Some(repo) = self.active_repo.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let CodeReviewViewState::Loaded(state) = &repo.state else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some((_, file_state)) = state.file_states.get_index(scroll_index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(editor_state) = &file_state.editor_state else {
|
||||
return;
|
||||
};
|
||||
|
||||
let editor = editor_state.editor();
|
||||
|
||||
if let Some(context) = self.compute_scroll_context_for_index(scroll_index, editor, ctx) {
|
||||
self.viewported_list_state.set_scroll_context(Some(context));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up scroll tracking from a scroll event receiver.
|
||||
/// Calls [`Self::on_scroll_settled`] on every scroll event so the
|
||||
/// scroll context is always up-to-date.
|
||||
/// No-op when [`FeatureFlag::CodeReviewScrollPreservation`] is disabled.
|
||||
pub(super) fn setup_scroll_tracking(
|
||||
scroll_rx: async_channel::Receiver<ScrollOffset>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if FeatureFlag::CodeReviewScrollPreservation.is_enabled() {
|
||||
ctx.spawn_stream_local(scroll_rx, Self::on_scroll_settled, |_, _| {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
use crate::server::telemetry::CLIAgentType;
|
||||
use crate::view_components::find::FindDirection;
|
||||
use crate::{code_review::diff_state::DiffMode, features::FeatureFlag};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use serde_with::SerializeDisplay;
|
||||
use std::fmt::Display;
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
/// Entry points for opening the code review pane.
|
||||
#[derive(Clone, Copy, Debug, SerializeDisplay, Default)]
|
||||
pub enum CodeReviewPaneEntrypoint {
|
||||
/// Opened via the git diff chip (git changes button in AI control panel).
|
||||
GitDiffChip,
|
||||
/// Opened via the "View changes" button when Agent mode is done running.
|
||||
AgentModeCompleted,
|
||||
/// Opened via the "Review changes" button when Agent mode is running.
|
||||
AgentModeRunning,
|
||||
/// Opened via the "/code-review" slash command.
|
||||
SlashCommand,
|
||||
/// Opened by the agent tool call.
|
||||
InvokedByAgent,
|
||||
// Force opened when user accepted first diff of a conversation
|
||||
ForceOpened,
|
||||
// Opened via the agent mode diff header
|
||||
CodeDiffHeader,
|
||||
// Opened via the pane header
|
||||
PaneHeader,
|
||||
// Opened via the code mode v2 right panel button
|
||||
RightPanel,
|
||||
/// Opened via the CLI agent view footer (e.g., Claude Code).
|
||||
CLIAgentView,
|
||||
/// Opened via other means (unknown entry point).
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Display for CodeReviewPaneEntrypoint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitDiffChip => write!(f, "git_diff_chip"),
|
||||
Self::AgentModeCompleted => write!(f, "agent_mode_completed"),
|
||||
Self::AgentModeRunning => write!(f, "agent_mode_running"),
|
||||
Self::SlashCommand => write!(f, "slash_command"),
|
||||
Self::InvokedByAgent => write!(f, "invoked_by_agent"),
|
||||
Self::ForceOpened => write!(f, "force_opened"),
|
||||
Self::CodeDiffHeader => write!(f, "agent_mode_diff_header"),
|
||||
Self::PaneHeader => write!(f, "pane_header"),
|
||||
Self::RightPanel => write!(f, "right_panel"),
|
||||
Self::CLIAgentView => write!(f, "cli_agent_view"),
|
||||
Self::Other => write!(f, "other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Origin of an "Add to context" action.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub enum AddToContextOrigin {
|
||||
/// User selected text and added it to context.
|
||||
#[serde(rename = "selected_text")]
|
||||
SelectedText,
|
||||
/// User clicked the gutter to add a line/hunk to context.
|
||||
#[serde(rename = "gutter")]
|
||||
Gutter,
|
||||
/// User clicked the "Add diff set as context" button in code review header.
|
||||
#[serde(rename = "code_review_header")]
|
||||
#[allow(unused)]
|
||||
CodeReviewHeader,
|
||||
}
|
||||
|
||||
/// Where code review content was sent after the user action.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub enum CodeReviewContextDestination {
|
||||
/// Written directly to the terminal PTY for an active CLI agent.
|
||||
#[serde(rename = "pty")]
|
||||
Pty,
|
||||
/// Inserted into the Warp AI input buffer as plain text.
|
||||
#[serde(rename = "agent_input")]
|
||||
AgentInput,
|
||||
/// Registered as an AI attachment and referenced from the input.
|
||||
#[serde(rename = "agent_attachment")]
|
||||
AgentAttachment,
|
||||
/// Inserted into the active command buffer while a command is running.
|
||||
#[serde(rename = "active_command_buffer")]
|
||||
ActiveCommandBuffer,
|
||||
/// Submitted as an inline code review request through the Warp AI path.
|
||||
#[serde(rename = "agent_review")]
|
||||
AgentReview,
|
||||
/// Inserted into CLI agent rich input.
|
||||
#[serde(rename = "rich_input")]
|
||||
RichInput,
|
||||
}
|
||||
|
||||
/// Scope of a diff set attachment initiated from code review.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub enum DiffSetContextScope {
|
||||
/// Attach the full diff set for the current review.
|
||||
#[serde(rename = "all")]
|
||||
All,
|
||||
/// Attach the diff set for a single file.
|
||||
#[serde(rename = "file")]
|
||||
File,
|
||||
}
|
||||
|
||||
/// Pane state change for minimize/maximize events.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub enum PaneStateChange {
|
||||
/// Pane was minimized.
|
||||
#[serde(rename = "minimized")]
|
||||
Minimized,
|
||||
/// Pane was maximized.
|
||||
#[serde(rename = "maximized")]
|
||||
Maximized,
|
||||
}
|
||||
|
||||
/// Telemetry events associated with the code review pane.
|
||||
#[derive(Serialize, Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub enum CodeReviewTelemetryEvent {
|
||||
/// Emitted when the code review pane is opened.
|
||||
PaneOpened {
|
||||
entrypoint: CodeReviewPaneEntrypoint,
|
||||
is_code_mode_v2: bool,
|
||||
/// The CLI agent type if opened from a CLI agent footer (e.g., Claude Code).
|
||||
cli_agent: Option<CLIAgentType>,
|
||||
},
|
||||
/// Emitted when a user adds content to AI context from code review.
|
||||
AddToContext {
|
||||
origin: AddToContextOrigin,
|
||||
destination: CodeReviewContextDestination,
|
||||
diff_set_scope: Option<DiffSetContextScope>,
|
||||
},
|
||||
/// Emitted when a user clicks the revert hunk button.
|
||||
RevertHunkClicked,
|
||||
/// Emitted when a file is saved in the code review pane.
|
||||
FileSaved,
|
||||
/// Emitted when the code review pane is minimized or maximized.
|
||||
PaneStateChanged { state_change: PaneStateChange },
|
||||
/// Emitted when the diff base is changed (e.g., from uncommitted to main branch).
|
||||
BaseChanged {
|
||||
/// The new diff mode.
|
||||
mode: DiffMode,
|
||||
},
|
||||
/// Failure when we are calculating the diff metadata.
|
||||
CalculateDiffMetadataFailed { error: String },
|
||||
/// Failure when we are loading the actual diff content.
|
||||
LoadDiffFailed { error: String },
|
||||
/// Emitted when the code review find bar is opened or closed.
|
||||
FindBarToggled {
|
||||
/// Whether the find bar is now open.
|
||||
is_open: bool,
|
||||
},
|
||||
/// Emitted when search mode settings are changed.
|
||||
FindBarModeChanged {
|
||||
/// Whether case-sensitive search is enabled.
|
||||
case_sensitive: bool,
|
||||
/// Whether regex search is enabled.
|
||||
regex: bool,
|
||||
},
|
||||
/// Emitted when the user navigates to the next or previous match.
|
||||
FindNavigated {
|
||||
/// Direction of navigation.
|
||||
direction: FindDirection,
|
||||
},
|
||||
/// Emitted when the inline comment editor is opened in the code review pane.
|
||||
CommentEditorOpened,
|
||||
/// Emitted when a new comment is added to the inline review.
|
||||
CommentAdded,
|
||||
/// Emitted when an existing comment is edited.
|
||||
CommentEdited,
|
||||
/// Emitted when a comment is deleted from the inline review.
|
||||
CommentDeleted { is_imported: bool },
|
||||
/// Emitted when the bottom comment list panel is expanded.
|
||||
CommentListExpanded {
|
||||
/// Number of comments currently in the list.
|
||||
comment_count: usize,
|
||||
},
|
||||
/// Emitted when the user submits an inline review to the agent.
|
||||
ReviewSubmitted {
|
||||
/// Number of comments in the submitted review.
|
||||
comment_count: usize,
|
||||
/// Number of unique files with comments.
|
||||
file_count: usize,
|
||||
/// Where the review was submitted.
|
||||
destination: CodeReviewContextDestination,
|
||||
},
|
||||
/// Emitted when a comment in the list view is clicked to jump to its location.
|
||||
CommentListItemClicked,
|
||||
/// Emitted when one or more comments fail to be precisely relocated after code changes.
|
||||
CommentRelocationFailed {
|
||||
/// Number of comments that could not be matched to an exact line and had to fall back.
|
||||
fallback_count: usize,
|
||||
},
|
||||
/// Emitted when one or more comments are resolved.
|
||||
CommentResolved {
|
||||
/// Number of comments resolved by this operation.
|
||||
resolved_count: usize,
|
||||
},
|
||||
/// Emitted when the agent's insert_code_review_comments tool call is received and processed.
|
||||
CommentsReceived {
|
||||
/// Number of raw InsertReviewComment items from the tool call.
|
||||
raw_count: usize,
|
||||
/// Number of successfully converted PendingImportedReviewComments.
|
||||
converted_count: usize,
|
||||
/// Number of AttachedReviewComments after thread flattening.
|
||||
thread_count: usize,
|
||||
},
|
||||
/// Emitted after newly-imported comments are relocated against editor lines.
|
||||
CommentsAttached {
|
||||
/// Number of non-outdated imported comments after relocation.
|
||||
active_count: usize,
|
||||
/// Number of outdated imported comments after relocation.
|
||||
outdated_count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelemetryEvent for CodeReviewTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
CodeReviewTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<serde_json::Value> {
|
||||
match self {
|
||||
CodeReviewTelemetryEvent::PaneOpened {
|
||||
entrypoint,
|
||||
is_code_mode_v2,
|
||||
cli_agent,
|
||||
} => Some(
|
||||
json!({ "entrypoint": entrypoint, "is_code_mode_v2": is_code_mode_v2, "agent_name": cli_agent}),
|
||||
),
|
||||
CodeReviewTelemetryEvent::AddToContext {
|
||||
origin,
|
||||
destination,
|
||||
diff_set_scope,
|
||||
} => Some(json!({
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"diff_set_scope": diff_set_scope,
|
||||
})),
|
||||
CodeReviewTelemetryEvent::RevertHunkClicked => None,
|
||||
CodeReviewTelemetryEvent::FileSaved => None,
|
||||
CodeReviewTelemetryEvent::PaneStateChanged { state_change } => {
|
||||
Some(json!({ "state_change": state_change }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::BaseChanged { mode } => Some(json!({ "mode": mode })),
|
||||
CodeReviewTelemetryEvent::CalculateDiffMetadataFailed { error } => {
|
||||
Some(json!({ "error": error }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::LoadDiffFailed { error } => Some(json!({ "error": error })),
|
||||
CodeReviewTelemetryEvent::FindBarToggled { is_open } => {
|
||||
Some(json!({ "is_open": is_open }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::FindBarModeChanged {
|
||||
case_sensitive,
|
||||
regex,
|
||||
} => Some(json!({
|
||||
"case_sensitive": case_sensitive,
|
||||
"regex": regex,
|
||||
})),
|
||||
CodeReviewTelemetryEvent::FindNavigated { direction } => {
|
||||
Some(json!({ "direction": direction }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::CommentEditorOpened => None,
|
||||
CodeReviewTelemetryEvent::CommentAdded => None,
|
||||
CodeReviewTelemetryEvent::CommentEdited => None,
|
||||
CodeReviewTelemetryEvent::CommentDeleted { is_imported } => {
|
||||
Some(json!({ "is_imported": is_imported }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::CommentListExpanded { comment_count } => {
|
||||
Some(json!({ "comment_count": comment_count }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::ReviewSubmitted {
|
||||
comment_count,
|
||||
file_count,
|
||||
destination,
|
||||
} => Some(json!({
|
||||
"comment_count": comment_count,
|
||||
"file_count": file_count,
|
||||
"destination": destination,
|
||||
})),
|
||||
CodeReviewTelemetryEvent::CommentListItemClicked => None,
|
||||
CodeReviewTelemetryEvent::CommentRelocationFailed { fallback_count } => {
|
||||
Some(json!({ "fallback_count": fallback_count }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::CommentResolved { resolved_count } => {
|
||||
Some(json!({ "resolved_count": resolved_count }))
|
||||
}
|
||||
CodeReviewTelemetryEvent::CommentsReceived {
|
||||
raw_count,
|
||||
converted_count,
|
||||
thread_count,
|
||||
} => Some(json!({
|
||||
"raw_count": raw_count,
|
||||
"converted_count": converted_count,
|
||||
"thread_count": thread_count,
|
||||
})),
|
||||
CodeReviewTelemetryEvent::CommentsAttached {
|
||||
active_count,
|
||||
outdated_count,
|
||||
} => Some(json!({
|
||||
"active_count": active_count,
|
||||
"outdated_count": outdated_count,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
CodeReviewTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
CodeReviewTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
CodeReviewTelemetryEventDiscriminants::from(self).contains_ugc()
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeReviewTelemetryEventDiscriminants {
|
||||
pub fn contains_ugc(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for CodeReviewTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PaneOpened => "CodeReview.PaneOpened",
|
||||
Self::AddToContext => "CodeReview.AddToContext",
|
||||
Self::RevertHunkClicked => "CodeReview.RevertHunkClicked",
|
||||
Self::FileSaved => "CodeReview.FileSaved",
|
||||
Self::PaneStateChanged => "CodeReview.PaneStateChanged",
|
||||
Self::BaseChanged => "CodeReview.BaseChanged",
|
||||
Self::CalculateDiffMetadataFailed => "CodeReview.CalculateDiffMetadataFailed",
|
||||
Self::LoadDiffFailed => "CodeReview.LoadDiffFailed",
|
||||
Self::FindBarToggled => "CodeReview.FindBarToggled",
|
||||
Self::FindBarModeChanged => "CodeReview.FindBarModeChanged",
|
||||
Self::FindNavigated => "CodeReview.FindNavigated",
|
||||
Self::CommentEditorOpened => "CodeReview.CommentEditorOpened",
|
||||
Self::CommentAdded => "CodeReview.CommentAdded",
|
||||
Self::CommentEdited => "CodeReview.CommentEdited",
|
||||
Self::CommentDeleted => "CodeReview.CommentDeleted",
|
||||
Self::CommentListExpanded => "CodeReview.CommentListExpanded",
|
||||
Self::ReviewSubmitted => "CodeReview.ReviewSubmitted",
|
||||
Self::CommentListItemClicked => "CodeReview.CommentListItemClicked",
|
||||
Self::CommentRelocationFailed => "CodeReview.CommentRelocationFailed",
|
||||
Self::CommentResolved => "CodeReview.CommentResolved",
|
||||
Self::CommentsReceived => "CodeReview.CommentsReceived",
|
||||
Self::CommentsAttached => "CodeReview.CommentsAttached",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PaneOpened => "Code review pane opened",
|
||||
Self::AddToContext => "Content added to AI context from code review",
|
||||
Self::RevertHunkClicked => "Revert hunk button clicked",
|
||||
Self::FileSaved => "File saved in code review pane",
|
||||
Self::PaneStateChanged => "Code review pane minimized or maximized",
|
||||
Self::BaseChanged => "Diff base changed in code review",
|
||||
Self::CalculateDiffMetadataFailed => "Failure when calculating diff metadata",
|
||||
Self::LoadDiffFailed => "Failure when loading diff content",
|
||||
Self::FindBarToggled => "Code review find bar opened or closed",
|
||||
Self::FindBarModeChanged => "Search mode changed in code review find bar",
|
||||
Self::FindNavigated => "Navigated to next or previous match in code review find bar",
|
||||
Self::CommentEditorOpened => "Inline code review comment editor opened",
|
||||
Self::CommentAdded => "Inline code review comment added",
|
||||
Self::CommentEdited => "Inline code review comment edited",
|
||||
Self::CommentDeleted => "Inline code review comment deleted",
|
||||
Self::CommentListExpanded => "Inline code review comment list expanded",
|
||||
Self::ReviewSubmitted => "Inline code review submitted to agent",
|
||||
Self::CommentListItemClicked => "Inline code review comment list item clicked",
|
||||
Self::CommentRelocationFailed => {
|
||||
"Inline code review comment relocation fell back to approximate line"
|
||||
}
|
||||
Self::CommentResolved => "Inline code review comment resolved",
|
||||
Self::CommentsReceived => {
|
||||
"Agent insert_code_review_comments tool call received and processed"
|
||||
}
|
||||
Self::CommentsAttached => "Newly-imported comments relocated against editor lines",
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
match self {
|
||||
Self::CommentsReceived | Self::CommentsAttached => {
|
||||
EnablementState::Flag(FeatureFlag::PRCommentsV2)
|
||||
}
|
||||
_ => EnablementState::Always,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warp_core::register_telemetry_event!(CodeReviewTelemetryEvent);
|
||||
Reference in New Issue
Block a user