Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -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(&current), 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
+481
View File
@@ -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()),
}
}
}
+220
View File
@@ -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;
+157
View File
@@ -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");
});
});
}
+229
View File
@@ -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");
}
+117
View File
@@ -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");
}
+139
View File
@@ -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);
}
}
}
+16
View File
@@ -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,
}
}
}
+130
View File
@@ -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);
});
}
+501
View File
@@ -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()
}
}
+265
View File
@@ -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()
}
}
+94
View File
@@ -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
+238
View File
@@ -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);
}
+58
View File
@@ -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)
})
}
}
+427
View File
@@ -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;
+297
View File
@@ -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"
);
});
});
}
+656
View File
@@ -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()
}
+785
View File
@@ -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),
}
}
}
+275
View File
@@ -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()
}
+370
View File
@@ -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()
}
+374
View File
@@ -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;
})
}
}
+263
View File
@@ -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);
}
}
+174
View File
@@ -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;
}
+212
View File
@@ -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, |_, _| {});
}
}
}
+402
View File
@@ -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);