From 75deffd4f75f741aaead7639e5a71c1f91952438 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Fri, 8 May 2026 18:24:30 +0200 Subject: [PATCH] editor: Extract more git related stuff out of `editor.rs` (#56198) cc @SomeoneToIgnore ## Summary Follow-up to #56155. I extracted the remaining git related things (again not all of them, leftovers are more tricky as there are git + fold + some others things combined) into `git.rs` We nod reached a good milestone, the `editor.rs` would be below 20k lines now Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/editor.rs | 2519 +++------------------------------ crates/editor/src/git.rs | 2612 +++++++++++++++++++++++++++++++---- 2 files changed, 2579 insertions(+), 2552 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 7147677d91e47cb19f9fcc037710028e12a420e8..4bb88de555e395af7582f75586ec1abfe9259fba 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -89,6 +89,13 @@ pub use element::{ render_breadcrumb_text, }; pub use git::blame::BlameRenderer; +pub(crate) use git::{DiffHunkKey, StoredReviewComment}; +use git::{ + DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, render_diff_hunk_controls, + update_uncommitted_diff_for_buffer, +}; +pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator}; +pub use git::{RenderDiffHunkControlsFn, set_blame_renderer}; pub use hover_popover::hover_markdown_style; pub use inlays::Inlay; pub use items::MAX_TAB_TITLE_LEN; @@ -104,11 +111,10 @@ pub use split::{SplittableEditor, ToggleSplitDiff}; pub use split_editor_view::SplitEditorView; pub use text::Bias; -use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; +use ::git::status::FileStatus; use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError}; use anyhow::{Context as _, Result, anyhow, bail}; use blink_manager::BlinkManager; -use buffer_diff::DiffHunkStatus; use client::{Collaborator, ParticipantIndex, parse_zed_link}; use clock::ReplicaId; use code_context_menus::{ @@ -282,19 +288,6 @@ pub const LSP_REQUEST_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50); pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction"; pub(crate) const MINIMAP_FONT_SIZE: AbsoluteLength = AbsoluteLength::Pixels(px(2.)); -pub type RenderDiffHunkControlsFn = Arc< - dyn Fn( - u32, - &DiffHunkStatus, - Range, - bool, - Pixels, - &Entity, - &mut Window, - &mut App, - ) -> AnyElement, ->; - enum ReportEditorEvent { Saved { auto_saved: bool }, EditorOpened, @@ -335,21 +328,6 @@ impl Navigated { } } -#[derive(Debug, Clone, PartialEq, Eq)] -enum DisplayDiffHunk { - Folded { - display_row: DisplayRow, - }, - Unfolded { - is_created_file: bool, - diff_base_byte_range: Range, - display_row_range: Range, - multi_buffer_range: Range, - status: DiffHunkStatus, - word_diffs: Vec>, - }, -} - pub fn init(cx: &mut App) { cx.set_global(GlobalBlameRenderer(Arc::new(()))); cx.set_global(breadcrumbs::RenderBreadcrumbText(render_breadcrumb_text)); @@ -402,10 +380,6 @@ pub fn init(cx: &mut App) { _ = multi_buffer::EXCERPT_CONTEXT_LINES.set(multibuffer_context_lines); } -pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) { - cx.set_global(GlobalBlameRenderer(Arc::new(renderer))); -} - pub struct SearchWithinRange; trait InvalidationRegion { @@ -957,21 +931,6 @@ impl ChangeList { } } -#[derive(Clone)] -struct InlineBlamePopoverState { - scroll_handle: ScrollHandle, - commit_message: Option, - markdown: Entity, -} - -struct InlineBlamePopover { - position: gpui::Point, - hide_task: Option>, - popover_bounds: Option>, - popover_state: InlineBlamePopoverState, - keyboard_grace: bool, -} - enum SelectionDragState { /// State when no drag related activity is detected. None, @@ -1009,94 +968,6 @@ struct GutterHoverButton { is_active: bool, } -/// Represents a diff review button indicator that shows up when hovering over lines in the gutter -/// in diff view mode. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct PhantomDiffReviewIndicator { - /// The starting anchor of the selection (or the only row if not dragging). - pub start: Anchor, - /// The ending anchor of the selection. Equal to start_anchor for single-line selection. - pub end: Anchor, - /// There's a small debounce between hovering over the line and showing the indicator. - /// We don't want to show the indicator when moving the mouse from editor to e.g. project panel. - pub is_active: bool, -} - -#[derive(Clone, Debug)] -pub(crate) struct DiffReviewDragState { - pub start_anchor: Anchor, - pub current_anchor: Anchor, -} - -impl DiffReviewDragState { - pub fn row_range(&self, snapshot: &DisplaySnapshot) -> std::ops::RangeInclusive { - let start = self.start_anchor.to_display_point(snapshot).row(); - let current = self.current_anchor.to_display_point(snapshot).row(); - - (start..=current).sorted() - } -} - -/// Identifies a specific hunk in the diff buffer. -/// Used as a key to group comments by their location. -#[derive(Clone, Debug)] -pub struct DiffHunkKey { - /// The file path (relative to worktree) this hunk belongs to. - pub file_path: Arc, - /// An anchor at the start of the hunk. This tracks position as the buffer changes. - pub hunk_start_anchor: Anchor, -} - -/// A review comment stored locally before being sent to the Agent panel. -#[derive(Clone)] -pub struct StoredReviewComment { - /// Unique identifier for this comment (for edit/delete operations). - pub id: usize, - /// The comment text entered by the user. - pub comment: String, - /// Anchors for the code range being reviewed. - pub range: Range, - /// Timestamp when the comment was created (for chronological ordering). - pub created_at: Instant, - /// Whether this comment is currently being edited inline. - pub is_editing: bool, -} - -impl StoredReviewComment { - pub fn new(id: usize, comment: String, anchor_range: Range) -> Self { - Self { - id, - comment, - range: anchor_range, - created_at: Instant::now(), - is_editing: false, - } - } -} - -/// Represents an active diff review overlay that appears when clicking the "Add Review" button. -pub(crate) struct DiffReviewOverlay { - pub anchor_range: Range, - /// The block ID for the overlay. - pub block_id: CustomBlockId, - /// The editor entity for the review input. - pub prompt_editor: Entity, - /// The hunk key this overlay belongs to. - pub hunk_key: DiffHunkKey, - /// Whether the comments section is expanded. - pub comments_expanded: bool, - /// Editors for comments currently being edited inline. - /// Key: comment ID, Value: Editor entity for inline editing. - pub inline_edit_editors: HashMap>, - /// Subscriptions for inline edit editors' action handlers. - /// Key: comment ID, Value: Subscription keeping the Newline action handler alive. - pub inline_edit_subscriptions: HashMap, - /// The current user's avatar URI for display in comment rows. - pub user_avatar_uri: Option, - /// Subscription to keep the action handler alive. - _subscription: Subscription, -} - enum CodeActionsForSelection { None, Fetching(Shared>>), @@ -2913,6 +2784,61 @@ impl Editor { self.last_bounds.as_ref() } + pub fn working_directory(&self, cx: &App) -> Option { + if let Some(buffer) = self.buffer().read(cx).as_singleton() { + if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) + && let Some(dir) = file.abs_path(cx).parent() + { + return Some(dir.to_owned()); + } + } + + None + } + + pub fn target_file_abs_path(&self, cx: &mut Context) -> Option { + self.active_buffer(cx).and_then(|buffer| { + let buffer = buffer.read(cx); + if let Some(project_path) = buffer.project_path(cx) { + let project = self.project()?.read(cx); + project.absolute_path(&project_path, cx) + } else { + buffer + .file() + .and_then(|file| file.as_local().map(|file| file.abs_path(cx))) + } + }) + } + + /// Returns the project path for the editor's buffer, if any buffer is + /// opened in the editor. + pub fn project_path(&self, cx: &App) -> Option { + if let Some(buffer) = self.buffer.read(cx).as_singleton() { + buffer.read(cx).project_path(cx) + } else { + None + } + } + + pub fn selection_menu_enabled(&self, cx: &App) -> bool { + self.show_selection_menu + .unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu) + } + + pub fn toggle_selection_menu( + &mut self, + _: &ToggleSelectionMenu, + _: &mut Window, + cx: &mut Context, + ) { + self.show_selection_menu = self + .show_selection_menu + .map(|show_selections_menu| !show_selections_menu) + .or_else(|| Some(!EditorSettings::get_global(cx).toolbar.selections_menu)); + + cx.notify(); + } + fn accept_edit_prediction_keystroke( &self, granularity: EditPredictionGranularity, @@ -3820,162 +3746,10 @@ impl Editor { Ok(()) } - fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() { - self.show_git_blame_inline = false; - - self.show_git_blame_inline_delay_task = - Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(delay).await; - - this.update(cx, |this, cx| { - this.show_git_blame_inline = true; - cx.notify(); - }) - .log_err(); - })); - } - } - - pub fn blame_hover(&mut self, _: &BlameHover, window: &mut Window, cx: &mut Context) { - let snapshot = self.snapshot(window, cx); - let cursor = self - .selections - .newest::(&snapshot.display_snapshot) - .head(); - let Some((buffer, point)) = snapshot.buffer_snapshot().point_to_buffer_point(cursor) else { - return; - }; - - if self.blame.is_none() { - self.start_git_blame(true, window, cx); - } - let Some(blame) = self.blame.as_ref() else { - return; - }; - - let row_info = RowInfo { - buffer_id: Some(buffer.remote_id()), - buffer_row: Some(point.row), - ..Default::default() - }; - let Some((buffer, blame_entry)) = blame - .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) - .flatten() - else { - return; - }; - - let anchor = self.selections.newest_anchor().head(); - let position = self.to_pixel_point(anchor, &snapshot, window, cx); - if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { - self.show_blame_popover( - buffer, - &blame_entry, - position + last_bounds.origin, - true, - cx, - ); - }; - } - - fn show_blame_popover( - &mut self, - buffer: BufferId, - blame_entry: &BlameEntry, - position: gpui::Point, - ignore_timeout: bool, - cx: &mut Context, - ) { - if let Some(state) = &mut self.inline_blame_popover { - state.hide_task.take(); - } else { - let blame_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; - let blame_entry = blame_entry.clone(); - let show_task = cx.spawn(async move |editor, cx| { - if !ignore_timeout { - cx.background_executor() - .timer(std::time::Duration::from_millis(blame_popover_delay)) - .await; - } - editor - .update(cx, |editor, cx| { - editor.inline_blame_popover_show_task.take(); - let Some(blame) = editor.blame.as_ref() else { - return; - }; - let blame = blame.read(cx); - let details = blame.details_for_entry(buffer, &blame_entry); - let markdown = cx.new(|cx| { - Markdown::new( - details - .as_ref() - .map(|message| message.message.clone()) - .unwrap_or_default(), - None, - None, - cx, - ) - }); - editor.inline_blame_popover = Some(InlineBlamePopover { - position, - hide_task: None, - popover_bounds: None, - popover_state: InlineBlamePopoverState { - scroll_handle: ScrollHandle::new(), - commit_message: details, - markdown, - }, - keyboard_grace: ignore_timeout, - }); - cx.notify(); - }) - .ok(); - }); - self.inline_blame_popover_show_task = Some(show_task); - } - } - pub fn has_mouse_context_menu(&self) -> bool { self.mouse_context_menu.is_some() } - /// Hides the inline blame popover element, in case it's already visible, or - /// interrupts the task meant to show it, in case the task is running. - /// - /// When `ignore_timeout` is set to `true`, the popover is hidden - /// immediately, otherwise it'll be hidden after a short delay. - /// - /// Returns `true` if the popover was visible and was hidden, `false` - /// otherwise. - pub fn hide_blame_popover(&mut self, ignore_timeout: bool, cx: &mut Context) -> bool { - self.inline_blame_popover_show_task.take(); - - if let Some(state) = &mut self.inline_blame_popover { - if ignore_timeout { - self.inline_blame_popover.take(); - cx.notify(); - } else { - state.hide_task = Some(cx.spawn(async move |editor, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(100)) - .await; - - editor - .update(cx, |editor, cx| { - editor.inline_blame_popover.take(); - cx.notify(); - }) - .ok(); - })); - } - - true - } else { - false - } - } - fn refresh_document_highlights(&mut self, cx: &mut Context) -> Option<()> { if self.pending_rename.is_some() { return None; @@ -8287,152 +8061,6 @@ impl Editor { self.detach_and_notify_err(task, window, cx); } - pub fn restore_file( - &mut self, - _: &::git::RestoreFile, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - let mut buffer_ids = HashSet::default(); - let snapshot = self.buffer().read(cx).snapshot(cx); - for selection in self - .selections - .all::(&self.display_snapshot(cx)) - { - buffer_ids.extend(snapshot.buffer_ids_for_range(selection.range())) - } - - let ranges = buffer_ids - .into_iter() - .flat_map(|buffer_id| snapshot.range_for_buffer(buffer_id)) - .collect::>(); - - self.restore_hunks_in_ranges(ranges, window, cx); - } - - pub fn git_restore(&mut self, _: &Restore, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - let selections = self - .selections - .all(&self.display_snapshot(cx)) - .into_iter() - .map(|s| s.range()) - .collect(); - self.restore_hunks_in_ranges(selections, window, cx); - } - - /// Restores the diff hunks in the editor's selections and moves the cursor - /// to the next diff hunk. Wraps around to the beginning of the buffer if - /// not all diff hunks are expanded. - pub fn restore_and_next( - &mut self, - _: &::git::RestoreAndNext, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - let selections = self - .selections - .all(&self.display_snapshot(cx)) - .into_iter() - .map(|selection| selection.range()) - .collect(); - - self.restore_hunks_in_ranges(selections, window, cx); - - let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); - let wrap_around = !all_diff_hunks_expanded; - let snapshot = self.snapshot(window, cx); - let position = self - .selections - .newest::(&snapshot.display_snapshot) - .head(); - - self.go_to_hunk_before_or_after_position( - &snapshot, - position, - Direction::Next, - wrap_around, - window, - cx, - ); - } - - pub fn restore_hunks_in_ranges( - &mut self, - ranges: Vec>, - window: &mut Window, - cx: &mut Context, - ) { - if self.delegate_stage_and_restore { - let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); - if !hunks.is_empty() { - cx.emit(EditorEvent::RestoreRequested { hunks }); - } - return; - } - let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); - self.transact(window, cx, |editor, window, cx| { - editor.restore_diff_hunks(hunks, cx); - let selections = editor - .selections - .all::(&editor.display_snapshot(cx)); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select(selections); - }); - }); - } - - pub(crate) fn restore_diff_hunks(&self, hunks: Vec, cx: &mut App) { - let mut revert_changes = HashMap::default(); - let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - let hunks = hunks.collect::>(); - for hunk in &hunks { - self.prepare_restore_change(&mut revert_changes, hunk, cx); - } - self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx); - } - if !revert_changes.is_empty() { - self.buffer().update(cx, |multi_buffer, cx| { - for (buffer_id, changes) in revert_changes { - if let Some(buffer) = multi_buffer.buffer(buffer_id) { - buffer.update(cx, |buffer, cx| { - buffer.edit( - changes - .into_iter() - .map(|(range, text)| (range, text.to_string())), - None, - cx, - ); - }); - } - } - }); - } - } - - pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { - if let Some(status) = self - .addons - .iter() - .find_map(|(_, addon)| addon.override_status_for_buffer_id(buffer_id, cx)) - { - return Some(status); - } - self.project - .as_ref()? - .read(cx) - .status_for_buffer_id(buffer_id, cx) - } - pub fn open_active_item_in_terminal( &mut self, _: &OpenInTerminal, @@ -8923,43 +8551,64 @@ impl Editor { self.breakpoint_store.clone() } - pub fn prepare_restore_change( - &self, - revert_changes: &mut HashMap, Rope)>>, - hunk: &MultiBufferDiffHunk, - cx: &mut App, - ) -> Option<()> { - if hunk.is_created_file() { - return None; - } - let multi_buffer = self.buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let diff_snapshot = multi_buffer_snapshot.diff_for_buffer_id(hunk.buffer_id)?; - let original_text = diff_snapshot - .base_text() - .as_rope() - .slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0); - let buffer = multi_buffer.buffer(hunk.buffer_id)?; - let buffer = buffer.read(cx); - let buffer_snapshot = buffer.snapshot(); - let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); - if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { - probe - .0 - .start - .cmp(&hunk.buffer_range.start, &buffer_snapshot) - .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot)) - }) { - buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text)); - Some(()) - } else { - None - } - } + fn go_to_active_debug_line(&mut self, window: &mut Window, cx: &mut Context) -> bool { + maybe!({ + let breakpoint_store = self.breakpoint_store.as_ref()?; - pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context) { - self.manipulate_immutable_lines(window, cx, |lines| lines.reverse()) - } + let (active_stack_frame, debug_line_pane_id) = { + let store = breakpoint_store.read(cx); + let active_stack_frame = store.active_position().cloned(); + let debug_line_pane_id = store.active_debug_line_pane_id(); + (active_stack_frame, debug_line_pane_id) + }; + + let Some(active_stack_frame) = active_stack_frame else { + self.clear_row_highlights::(); + return None; + }; + + if let Some(debug_line_pane_id) = debug_line_pane_id { + if let Some(workspace) = self + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + { + let editor_pane_id = workspace + .read(cx) + .pane_for_item_id(cx.entity_id()) + .map(|pane| pane.entity_id()); + + if editor_pane_id.is_some_and(|id| id != debug_line_pane_id) { + self.clear_row_highlights::(); + return None; + } + } + } + + let position = active_stack_frame.position; + + let snapshot = self.buffer.read(cx).snapshot(cx); + let multibuffer_anchor = snapshot.anchor_in_excerpt(position)?; + + self.clear_row_highlights::(); + + self.go_to_line::( + multibuffer_anchor, + Some(cx.theme().colors().editor_debugger_active_line_background), + window, + cx, + ); + + cx.notify(); + + Some(()) + }) + .is_some() + } + + pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context) { + self.manipulate_immutable_lines(window, cx, |lines| lines.reverse()) + } pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context) { self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng())) @@ -14054,102 +13703,6 @@ impl Editor { ); } - pub fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context) { - let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(&self.display_snapshot(cx)); - self.go_to_hunk_before_or_after_position( - &snapshot, - selection.head(), - Direction::Next, - true, - window, - cx, - ); - } - - pub fn go_to_hunk_before_or_after_position( - &mut self, - snapshot: &EditorSnapshot, - position: Point, - direction: Direction, - wrap_around: bool, - window: &mut Window, - cx: &mut Context, - ) { - let row = if direction == Direction::Next { - self.hunk_after_position(snapshot, position, wrap_around) - .map(|hunk| hunk.row_range.start) - } else { - self.hunk_before_position(snapshot, position, wrap_around) - }; - - if let Some(row) = row { - let destination = Point::new(row.0, 0); - let autoscroll = Autoscroll::center(); - - self.unfold_ranges(&[destination..destination], false, false, cx); - self.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| { - s.select_ranges([destination..destination]); - }); - } - } - - fn hunk_after_position( - &mut self, - snapshot: &EditorSnapshot, - position: Point, - wrap_around: bool, - ) -> Option { - let result = snapshot - .buffer_snapshot() - .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point()) - .find(|hunk| hunk.row_range.start.0 > position.row); - - if wrap_around { - result.or_else(|| { - snapshot - .buffer_snapshot() - .diff_hunks_in_range(Point::zero()..position) - .find(|hunk| hunk.row_range.end.0 < position.row) - }) - } else { - result - } - } - - fn go_to_prev_hunk( - &mut self, - _: &GoToPreviousHunk, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(&snapshot.display_snapshot); - self.go_to_hunk_before_or_after_position( - &snapshot, - selection.head(), - Direction::Prev, - true, - window, - cx, - ); - } - - fn hunk_before_position( - &mut self, - snapshot: &EditorSnapshot, - position: Point, - wrap_around: bool, - ) -> Option { - let result = snapshot.buffer_snapshot().diff_hunk_before(position); - - if wrap_around { - result.or_else(|| snapshot.buffer_snapshot().diff_hunk_before(Point::MAX)) - } else { - result - } - } - fn go_to_next_change( &mut self, _: &GoToNextChange, @@ -16093,49 +15646,6 @@ impl Editor { workspace.activate_item(&item, true, true, window, cx); } - pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) { - self.buffer.update(cx, |buffer, cx| { - buffer.set_all_diff_hunks_expanded(cx); - }); - } - - pub fn expand_all_diff_hunks( - &mut self, - _: &ExpandAllDiffHunks, - _window: &mut Window, - cx: &mut Context, - ) { - self.buffer.update(cx, |buffer, cx| { - buffer.expand_diff_hunks(vec![Anchor::Min..Anchor::Max], cx) - }); - } - - pub fn collapse_all_diff_hunks( - &mut self, - _: &CollapseAllDiffHunks, - _window: &mut Window, - cx: &mut Context, - ) { - self.buffer.update(cx, |buffer, cx| { - buffer.collapse_diff_hunks(vec![Anchor::Min..Anchor::Max], cx) - }); - } - - pub fn toggle_selected_diff_hunks( - &mut self, - _: &ToggleSelectedDiffHunks, - _window: &mut Window, - cx: &mut Context, - ) { - let ranges: Vec<_> = self - .selections - .disjoint_anchors() - .iter() - .map(|s| s.range()) - .collect(); - self.toggle_diff_hunks_in_ranges(ranges, cx); - } - pub fn set_gutter_hovered(&mut self, hovered: bool, cx: &mut Context) { if hovered != self.gutter_hovered { self.gutter_hovered = hovered; @@ -16331,1400 +15841,144 @@ impl Editor { .filter(|_| self.minimap_visibility.visible()) } - pub fn show_diff_review_button(&self) -> bool { - self.show_diff_review_button + pub fn set_masked(&mut self, masked: bool, cx: &mut Context) { + if self.display_map.read(cx).masked != masked { + self.display_map.update(cx, |map, _| map.masked = masked); + } + cx.notify() } - pub fn render_diff_review_button( - &self, - display_row: DisplayRow, - width: Pixels, - cx: &mut Context, - ) -> impl IntoElement { - let text_color = cx.theme().colors().text; - let icon_color = cx.theme().colors().icon_accent; - - h_flex() - .id("diff_review_button") - .cursor_pointer() - .w(width - px(1.)) - .h(relative(0.9)) - .justify_center() - .rounded_sm() - .border_1() - .border_color(text_color.opacity(0.1)) - .bg(text_color.opacity(0.15)) - .hover(|s| { - s.bg(icon_color.opacity(0.4)) - .border_color(icon_color.opacity(0.5)) - }) - .child(Icon::new(IconName::Plus).size(IconSize::Small)) - .tooltip(Tooltip::text("Add Review (drag to select multiple lines)")) - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(move |editor, _event: &gpui::MouseDownEvent, window, cx| { - editor.start_diff_review_drag(display_row, window, cx); - }), - ) + fn target_file<'a>(&self, cx: &'a App) -> Option<&'a dyn language::LocalFile> { + self.active_buffer(cx)? + .read(cx) + .file() + .and_then(|f| f.as_local()) } - pub fn start_diff_review_drag( + fn reveal_in_finder( &mut self, - display_row: DisplayRow, - window: &mut Window, + _: &RevealInFileManager, + _window: &mut Window, cx: &mut Context, ) { - let snapshot = self.snapshot(window, cx); - let point = snapshot - .display_snapshot - .display_point_to_point(DisplayPoint::new(display_row, 0), Bias::Left); - let anchor = snapshot.buffer_snapshot().anchor_before(point); - self.diff_review_drag_state = Some(DiffReviewDragState { - start_anchor: anchor, - current_anchor: anchor, - }); - cx.notify(); + if let Some(path) = self.target_file_abs_path(cx) { + if let Some(project) = self.project() { + project.update(cx, |project, cx| project.reveal_path(&path, cx)); + } else { + cx.reveal_path(&path); + } + } } - pub fn update_diff_review_drag( + fn copy_path( &mut self, - display_row: DisplayRow, - window: &mut Window, + _: &zed_actions::workspace::CopyPath, + _window: &mut Window, cx: &mut Context, ) { - if self.diff_review_drag_state.is_none() { - return; - } - let snapshot = self.snapshot(window, cx); - let point = snapshot - .display_snapshot - .display_point_to_point(display_row.as_display_point(), Bias::Left); - let anchor = snapshot.buffer_snapshot().anchor_before(point); - if let Some(drag_state) = &mut self.diff_review_drag_state { - drag_state.current_anchor = anchor; - cx.notify(); + if let Some(path) = self.target_file_abs_path(cx) + && let Some(path) = path.to_str() + { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } else { + cx.propagate(); } } - pub fn end_diff_review_drag(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(drag_state) = self.diff_review_drag_state.take() { - let snapshot = self.snapshot(window, cx); - let range = drag_state.row_range(&snapshot.display_snapshot); - self.show_diff_review_overlay(*range.start()..*range.end(), window, cx); + fn copy_relative_path( + &mut self, + _: &zed_actions::workspace::CopyRelativePath, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(path) = self.active_buffer(cx).and_then(|buffer| { + let project = self.project()?.read(cx); + let path = buffer.read(cx).file()?.path(); + let path = path.display(project.path_style(cx)); + Some(path) + }) { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } else { + cx.propagate(); } - cx.notify(); } - pub fn cancel_diff_review_drag(&mut self, cx: &mut Context) { - self.diff_review_drag_state = None; - cx.notify(); + pub fn copy_file_name_without_extension( + &mut self, + _: &CopyFileNameWithoutExtension, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(file_stem) = self.active_buffer(cx).and_then(|buffer| { + let file = buffer.read(cx).file()?; + file.path().file_stem() + }) { + cx.write_to_clipboard(ClipboardItem::new_string(file_stem.to_string())); + } } - /// Calculates the appropriate block height for the diff review overlay. - /// Height is in lines: 2 for input row, 1 for header when comments exist, - /// and 2 lines per comment when expanded. - fn calculate_overlay_height( - &self, - hunk_key: &DiffHunkKey, - comments_expanded: bool, - snapshot: &MultiBufferSnapshot, - ) -> u32 { - let comment_count = self.hunk_comment_count(hunk_key, snapshot); - let base_height: u32 = 2; // Input row with avatar and buttons - - if comment_count == 0 { - base_height - } else if comments_expanded { - // Header (1 line) + 2 lines per comment - base_height + 1 + (comment_count as u32 * 2) - } else { - // Just header when collapsed - base_height + 1 + pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { + if let Some(file_name) = self.active_buffer(cx).and_then(|buffer| { + let file = buffer.read(cx).file()?; + Some(file.file_name(cx)) + }) { + cx.write_to_clipboard(ClipboardItem::new_string(file_name.to_string())); } } - pub fn show_diff_review_overlay( + pub fn copy_file_location( &mut self, - display_range: Range, - window: &mut Window, + _: &CopyFileLocation, + _: &mut Window, cx: &mut Context, ) { - let Range { start, end } = display_range.sorted(); + let selection = self.selections.newest::(&self.display_snapshot(cx)); - let buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let editor_snapshot = self.snapshot(window, cx); + let start_line = selection.start.row + 1; + let end_line = selection.end.row + 1; - // Convert display rows to multibuffer points - let start_point = editor_snapshot - .display_snapshot - .display_point_to_point(start.as_display_point(), Bias::Left); - let end_point = editor_snapshot - .display_snapshot - .display_point_to_point(end.as_display_point(), Bias::Left); - let end_multi_buffer_row = MultiBufferRow(end_point.row); - - // Create anchor range for the selected lines (start of first line to end of last line) - let line_end = Point::new( - end_point.row, - buffer_snapshot.line_len(end_multi_buffer_row), - ); - let anchor_range = - buffer_snapshot.anchor_after(start_point)..buffer_snapshot.anchor_before(line_end); - - // Compute the hunk key for this display row - let file_path = buffer_snapshot - .file_at(start_point) - .map(|file: &Arc| file.path().clone()) - .unwrap_or_else(|| Arc::from(util::rel_path::RelPath::empty())); - let hunk_start_anchor = buffer_snapshot.anchor_before(start_point); - let new_hunk_key = DiffHunkKey { - file_path, - hunk_start_anchor, + let end_line = if selection.end.column == 0 && end_line > start_line { + end_line - 1 + } else { + end_line }; - // Check if we already have an overlay for this hunk - if let Some(existing_overlay) = self.diff_review_overlays.iter().find(|overlay| { - Self::hunk_keys_match(&overlay.hunk_key, &new_hunk_key, &buffer_snapshot) + if let Some(file_location) = self.active_buffer(cx).and_then(|buffer| { + let project = self.project()?.read(cx); + let file = buffer.read(cx).file()?; + let path = file.path().display(project.path_style(cx)); + + let location = if start_line == end_line { + format!("{path}:{start_line}") + } else { + format!("{path}:{start_line}-{end_line}") + }; + Some(location) }) { - // Just focus the existing overlay's prompt editor - let focus_handle = existing_overlay.prompt_editor.focus_handle(cx); - window.focus(&focus_handle, cx); - return; + cx.write_to_clipboard(ClipboardItem::new_string(file_location)); } + } - // Dismiss overlays that have no comments for their hunks - self.dismiss_overlays_without_comments(cx); - - // Get the current user's avatar URI from the project's user_store - let user_avatar_uri = self.project.as_ref().and_then(|project| { - let user_store = project.read(cx).user_store(); - user_store - .read(cx) - .current_user() - .map(|user| user.avatar_uri.clone()) - }); - - // Create anchor at the end of the last row so the block appears immediately below it - // Use multibuffer coordinates for anchor creation - let line_len = buffer_snapshot.line_len(end_multi_buffer_row); - let anchor = buffer_snapshot.anchor_after(Point::new(end_multi_buffer_row.0, line_len)); - - // Use the hunk key we already computed - let hunk_key = new_hunk_key; - - // Create the prompt editor for the review input - let prompt_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Add a review comment...", window, cx); - editor - }); - - // Register the Newline action on the prompt editor to submit the review - let parent_editor = cx.entity().downgrade(); - let subscription = prompt_editor.update(cx, |prompt_editor, _cx| { - prompt_editor.register_action({ - let parent_editor = parent_editor.clone(); - move |_: &crate::actions::Newline, window, cx| { - if let Some(editor) = parent_editor.upgrade() { - editor.update(cx, |editor, cx| { - editor.submit_diff_review_comment(window, cx); - }); - } - } - }) - }); - - // Calculate initial height based on existing comments for this hunk - let initial_height = self.calculate_overlay_height(&hunk_key, true, &buffer_snapshot); - - // Create the overlay block - let prompt_editor_for_render = prompt_editor.clone(); - let hunk_key_for_render = hunk_key.clone(); - let editor_handle = cx.entity().downgrade(); - let block = BlockProperties { - style: BlockStyle::Sticky, - placement: BlockPlacement::Below(anchor), - height: Some(initial_height), - render: Arc::new(move |cx| { - Self::render_diff_review_overlay( - &prompt_editor_for_render, - &hunk_key_for_render, - &editor_handle, - cx, - ) - }), - priority: 0, - }; - - let block_ids = self.insert_blocks([block], None, cx); - let Some(block_id) = block_ids.into_iter().next() else { - log::error!("Failed to insert diff review overlay block"); - return; - }; - - self.diff_review_overlays.push(DiffReviewOverlay { - anchor_range, - block_id, - prompt_editor: prompt_editor.clone(), - hunk_key, - comments_expanded: true, - inline_edit_editors: HashMap::default(), - inline_edit_subscriptions: HashMap::default(), - user_avatar_uri, - _subscription: subscription, - }); - - // Focus the prompt editor - let focus_handle = prompt_editor.focus_handle(cx); - window.focus(&focus_handle, cx); + pub fn insert_uuid_v4( + &mut self, + _: &InsertUuidV4, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_uuid(UuidVersion::V4, window, cx); + } - cx.notify(); + pub fn insert_uuid_v7( + &mut self, + _: &InsertUuidV7, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_uuid(UuidVersion::V7, window, cx); } - /// Dismisses all diff review overlays. - pub fn dismiss_all_diff_review_overlays(&mut self, cx: &mut Context) { - if self.diff_review_overlays.is_empty() { - return; - } - let block_ids: HashSet<_> = self - .diff_review_overlays - .drain(..) - .map(|overlay| overlay.block_id) - .collect(); - self.remove_blocks(block_ids, None, cx); - cx.notify(); - } - - /// Dismisses overlays that have no comments stored for their hunks. - /// Keeps overlays that have at least one comment. - fn dismiss_overlays_without_comments(&mut self, cx: &mut Context) { - let snapshot = self.buffer.read(cx).snapshot(cx); - - // First, compute which overlays have comments (to avoid borrow issues with retain) - let overlays_with_comments: Vec = self - .diff_review_overlays - .iter() - .map(|overlay| self.hunk_comment_count(&overlay.hunk_key, &snapshot) > 0) - .collect(); - - // Now collect block IDs to remove and retain overlays - let mut block_ids_to_remove = HashSet::default(); - let mut index = 0; - self.diff_review_overlays.retain(|overlay| { - let has_comments = overlays_with_comments[index]; - index += 1; - if !has_comments { - block_ids_to_remove.insert(overlay.block_id); - } - has_comments - }); - - if !block_ids_to_remove.is_empty() { - self.remove_blocks(block_ids_to_remove, None, cx); - cx.notify(); - } - } - - /// Refreshes the diff review overlay block to update its height and render function. - /// Uses resize_blocks and replace_blocks to avoid visual flicker from remove+insert. - fn refresh_diff_review_overlay_height( - &mut self, - hunk_key: &DiffHunkKey, - _window: &mut Window, - cx: &mut Context, - ) { - // Extract all needed data from overlay first to avoid borrow conflicts - let snapshot = self.buffer.read(cx).snapshot(cx); - let (comments_expanded, block_id, prompt_editor) = { - let Some(overlay) = self - .diff_review_overlays - .iter() - .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, hunk_key, &snapshot)) - else { - return; - }; - - ( - overlay.comments_expanded, - overlay.block_id, - overlay.prompt_editor.clone(), - ) - }; - - // Calculate new height - let snapshot = self.buffer.read(cx).snapshot(cx); - let new_height = self.calculate_overlay_height(hunk_key, comments_expanded, &snapshot); - - // Update the block height using resize_blocks (avoids flicker) - let mut heights = HashMap::default(); - heights.insert(block_id, new_height); - self.resize_blocks(heights, None, cx); - - // Update the render function using replace_blocks (avoids flicker) - let hunk_key_for_render = hunk_key.clone(); - let editor_handle = cx.entity().downgrade(); - let render: Arc AnyElement + Send + Sync> = - Arc::new(move |cx| { - Self::render_diff_review_overlay( - &prompt_editor, - &hunk_key_for_render, - &editor_handle, - cx, - ) - }); - - let mut renderers = HashMap::default(); - renderers.insert(block_id, render); - self.replace_blocks(renderers, None, cx); - } - - /// Action handler for SubmitDiffReviewComment. - pub fn submit_diff_review_comment_action( - &mut self, - _: &SubmitDiffReviewComment, - window: &mut Window, - cx: &mut Context, - ) { - self.submit_diff_review_comment(window, cx); - } - - /// Stores the diff review comment locally. - /// Comments are stored per-hunk and can later be batch-submitted to the Agent panel. - pub fn submit_diff_review_comment(&mut self, window: &mut Window, cx: &mut Context) { - // Find the overlay that currently has focus - let overlay_index = self - .diff_review_overlays - .iter() - .position(|overlay| overlay.prompt_editor.focus_handle(cx).is_focused(window)); - let Some(overlay_index) = overlay_index else { - return; - }; - let overlay = &self.diff_review_overlays[overlay_index]; - - let comment_text = overlay.prompt_editor.read(cx).text(cx).trim().to_string(); - if comment_text.is_empty() { - return; - } - - let anchor_range = overlay.anchor_range.clone(); - let hunk_key = overlay.hunk_key.clone(); - - self.add_review_comment(hunk_key.clone(), comment_text, anchor_range, cx); - - // Clear the prompt editor but keep the overlay open - if let Some(overlay) = self.diff_review_overlays.get(overlay_index) { - overlay.prompt_editor.update(cx, |editor, cx| { - editor.clear(window, cx); - }); - } - - // Refresh the overlay to update the block height for the new comment - self.refresh_diff_review_overlay_height(&hunk_key, window, cx); - - cx.notify(); - } - - /// Returns the prompt editor for the diff review overlay, if one is active. - /// This is primarily used for testing. - pub fn diff_review_prompt_editor(&self) -> Option<&Entity> { - self.diff_review_overlays - .first() - .map(|overlay| &overlay.prompt_editor) - } - - /// Returns the line range for the first diff review overlay, if one is active. - /// Returns (start_row, end_row) as physical line numbers in the underlying file. - pub fn diff_review_line_range(&self, cx: &App) -> Option<(u32, u32)> { - let overlay = self.diff_review_overlays.first()?; - let snapshot = self.buffer.read(cx).snapshot(cx); - let start_point = overlay.anchor_range.start.to_point(&snapshot); - let end_point = overlay.anchor_range.end.to_point(&snapshot); - let start_row = snapshot - .point_to_buffer_point(start_point) - .map(|(_, p)| p.row) - .unwrap_or(start_point.row); - let end_row = snapshot - .point_to_buffer_point(end_point) - .map(|(_, p)| p.row) - .unwrap_or(end_point.row); - Some((start_row, end_row)) - } - - /// Sets whether the comments section is expanded in the diff review overlay. - /// This is primarily used for testing. - pub fn set_diff_review_comments_expanded(&mut self, expanded: bool, cx: &mut Context) { - for overlay in &mut self.diff_review_overlays { - overlay.comments_expanded = expanded; - } - cx.notify(); - } - - /// Compares two DiffHunkKeys for equality by resolving their anchors. - fn hunk_keys_match(a: &DiffHunkKey, b: &DiffHunkKey, snapshot: &MultiBufferSnapshot) -> bool { - a.file_path == b.file_path - && a.hunk_start_anchor.to_point(snapshot) == b.hunk_start_anchor.to_point(snapshot) - } - - /// Returns comments for a specific hunk, ordered by creation time. - pub fn comments_for_hunk<'a>( - &'a self, - key: &DiffHunkKey, - snapshot: &MultiBufferSnapshot, - ) -> &'a [StoredReviewComment] { - let key_point = key.hunk_start_anchor.to_point(snapshot); - self.stored_review_comments - .iter() - .find(|(k, _)| { - k.file_path == key.file_path && k.hunk_start_anchor.to_point(snapshot) == key_point - }) - .map(|(_, comments)| comments.as_slice()) - .unwrap_or(&[]) - } - - /// Returns the total count of stored review comments across all hunks. - pub fn total_review_comment_count(&self) -> usize { - self.stored_review_comments - .iter() - .map(|(_, v)| v.len()) - .sum() - } - - /// Returns the count of comments for a specific hunk. - pub fn hunk_comment_count(&self, key: &DiffHunkKey, snapshot: &MultiBufferSnapshot) -> usize { - let key_point = key.hunk_start_anchor.to_point(snapshot); - self.stored_review_comments - .iter() - .find(|(k, _)| { - k.file_path == key.file_path && k.hunk_start_anchor.to_point(snapshot) == key_point - }) - .map(|(_, v)| v.len()) - .unwrap_or(0) - } - - /// Adds a new review comment to a specific hunk. - pub fn add_review_comment( - &mut self, - hunk_key: DiffHunkKey, - comment: String, - anchor_range: Range, - cx: &mut Context, - ) -> usize { - let id = self.next_review_comment_id; - self.next_review_comment_id += 1; - - let stored_comment = StoredReviewComment::new(id, comment, anchor_range); - - let snapshot = self.buffer.read(cx).snapshot(cx); - let key_point = hunk_key.hunk_start_anchor.to_point(&snapshot); - - // Find existing entry for this hunk or add a new one - if let Some((_, comments)) = self.stored_review_comments.iter_mut().find(|(k, _)| { - k.file_path == hunk_key.file_path - && k.hunk_start_anchor.to_point(&snapshot) == key_point - }) { - comments.push(stored_comment); - } else { - self.stored_review_comments - .push((hunk_key, vec![stored_comment])); - } - - cx.emit(EditorEvent::ReviewCommentsChanged { - total_count: self.total_review_comment_count(), - }); - cx.notify(); - id - } - - /// Removes a review comment by ID from any hunk. - pub fn remove_review_comment(&mut self, id: usize, cx: &mut Context) -> bool { - for (_, comments) in self.stored_review_comments.iter_mut() { - if let Some(index) = comments.iter().position(|c| c.id == id) { - comments.remove(index); - cx.emit(EditorEvent::ReviewCommentsChanged { - total_count: self.total_review_comment_count(), - }); - cx.notify(); - return true; - } - } - false - } - - /// Updates a review comment's text by ID. - pub fn update_review_comment( - &mut self, - id: usize, - new_comment: String, - cx: &mut Context, - ) -> bool { - for (_, comments) in self.stored_review_comments.iter_mut() { - if let Some(comment) = comments.iter_mut().find(|c| c.id == id) { - comment.comment = new_comment; - comment.is_editing = false; - cx.emit(EditorEvent::ReviewCommentsChanged { - total_count: self.total_review_comment_count(), - }); - cx.notify(); - return true; - } - } - false - } - - /// Sets a comment's editing state. - pub fn set_comment_editing(&mut self, id: usize, is_editing: bool, cx: &mut Context) { - for (_, comments) in self.stored_review_comments.iter_mut() { - if let Some(comment) = comments.iter_mut().find(|c| c.id == id) { - comment.is_editing = is_editing; - cx.notify(); - return; - } - } - } - - /// Takes all stored comments from all hunks, clearing the storage. - /// Returns a Vec of (hunk_key, comments) pairs. - pub fn take_all_review_comments( - &mut self, - cx: &mut Context, - ) -> Vec<(DiffHunkKey, Vec)> { - // Dismiss all overlays when taking comments (e.g., when sending to agent) - self.dismiss_all_diff_review_overlays(cx); - let comments = std::mem::take(&mut self.stored_review_comments); - // Reset the ID counter since all comments have been taken - self.next_review_comment_id = 0; - cx.emit(EditorEvent::ReviewCommentsChanged { total_count: 0 }); - cx.notify(); - comments - } - - /// Removes review comments whose anchors are no longer valid or whose - /// associated diff hunks no longer exist. - /// - /// This should be called when the buffer changes to prevent orphaned comments - /// from accumulating. - pub fn cleanup_orphaned_review_comments(&mut self, cx: &mut Context) { - let snapshot = self.buffer.read(cx).snapshot(cx); - let original_count = self.total_review_comment_count(); - - // Remove comments with invalid hunk anchors - self.stored_review_comments - .retain(|(hunk_key, _)| hunk_key.hunk_start_anchor.is_valid(&snapshot)); - - // Also clean up individual comments with invalid anchor ranges - for (_, comments) in &mut self.stored_review_comments { - comments.retain(|comment| { - comment.range.start.is_valid(&snapshot) && comment.range.end.is_valid(&snapshot) - }); - } - - // Remove empty hunk entries - self.stored_review_comments - .retain(|(_, comments)| !comments.is_empty()); - - let new_count = self.total_review_comment_count(); - if new_count != original_count { - cx.emit(EditorEvent::ReviewCommentsChanged { - total_count: new_count, - }); - cx.notify(); - } - } - - /// Toggles the expanded state of the comments section in the overlay. - pub fn toggle_review_comments_expanded( - &mut self, - _: &ToggleReviewCommentsExpanded, - window: &mut Window, - cx: &mut Context, - ) { - // Find the overlay that currently has focus, or use the first one - let overlay_info = self.diff_review_overlays.iter_mut().find_map(|overlay| { - if overlay.prompt_editor.focus_handle(cx).is_focused(window) { - overlay.comments_expanded = !overlay.comments_expanded; - Some(overlay.hunk_key.clone()) - } else { - None - } - }); - - // If no focused overlay found, toggle the first one - let hunk_key = overlay_info.or_else(|| { - self.diff_review_overlays.first_mut().map(|overlay| { - overlay.comments_expanded = !overlay.comments_expanded; - overlay.hunk_key.clone() - }) - }); - - if let Some(hunk_key) = hunk_key { - self.refresh_diff_review_overlay_height(&hunk_key, window, cx); - cx.notify(); - } - } - - /// Handles the EditReviewComment action - sets a comment into editing mode. - pub fn edit_review_comment( - &mut self, - action: &EditReviewComment, - window: &mut Window, - cx: &mut Context, - ) { - let comment_id = action.id; - - // Set the comment to editing mode - self.set_comment_editing(comment_id, true, cx); - - // Find the overlay that contains this comment and create an inline editor if needed - // First, find which hunk this comment belongs to - let hunk_key = self - .stored_review_comments - .iter() - .find_map(|(key, comments)| { - if comments.iter().any(|c| c.id == comment_id) { - Some(key.clone()) - } else { - None - } - }); - - let snapshot = self.buffer.read(cx).snapshot(cx); - if let Some(hunk_key) = hunk_key { - if let Some(overlay) = self - .diff_review_overlays - .iter_mut() - .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, &hunk_key, &snapshot)) - { - if let std::collections::hash_map::Entry::Vacant(entry) = - overlay.inline_edit_editors.entry(comment_id) - { - // Find the comment text - let comment_text = self - .stored_review_comments - .iter() - .flat_map(|(_, comments)| comments) - .find(|c| c.id == comment_id) - .map(|c| c.comment.clone()) - .unwrap_or_default(); - - // Create inline editor - let parent_editor = cx.entity().downgrade(); - let inline_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_text(&*comment_text, window, cx); - // Select all text for easy replacement - editor.select_all(&crate::actions::SelectAll, window, cx); - editor - }); - - // Register the Newline action to confirm the edit - let subscription = inline_editor.update(cx, |inline_editor, _cx| { - inline_editor.register_action({ - let parent_editor = parent_editor.clone(); - move |_: &crate::actions::Newline, window, cx| { - if let Some(editor) = parent_editor.upgrade() { - editor.update(cx, |editor, cx| { - editor.confirm_edit_review_comment(comment_id, window, cx); - }); - } - } - }) - }); - - // Store the subscription to keep the action handler alive - overlay - .inline_edit_subscriptions - .insert(comment_id, subscription); - - // Focus the inline editor - let focus_handle = inline_editor.focus_handle(cx); - window.focus(&focus_handle, cx); - - entry.insert(inline_editor); - } - } - } - - cx.notify(); - } - - /// Confirms an inline edit of a review comment. - pub fn confirm_edit_review_comment( - &mut self, - comment_id: usize, - _window: &mut Window, - cx: &mut Context, - ) { - // Get the new text from the inline editor - // Find the overlay containing this comment's inline editor - let snapshot = self.buffer.read(cx).snapshot(cx); - let hunk_key = self - .stored_review_comments - .iter() - .find_map(|(key, comments)| { - if comments.iter().any(|c| c.id == comment_id) { - Some(key.clone()) - } else { - None - } - }); - - let new_text = hunk_key - .as_ref() - .and_then(|hunk_key| { - self.diff_review_overlays - .iter() - .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, hunk_key, &snapshot)) - }) - .as_ref() - .and_then(|overlay| overlay.inline_edit_editors.get(&comment_id)) - .map(|editor| editor.read(cx).text(cx).trim().to_string()); - - if let Some(new_text) = new_text { - if !new_text.is_empty() { - self.update_review_comment(comment_id, new_text, cx); - } - } - - // Remove the inline editor and its subscription - if let Some(hunk_key) = hunk_key { - if let Some(overlay) = self - .diff_review_overlays - .iter_mut() - .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, &hunk_key, &snapshot)) - { - overlay.inline_edit_editors.remove(&comment_id); - overlay.inline_edit_subscriptions.remove(&comment_id); - } - } - - // Clear editing state - self.set_comment_editing(comment_id, false, cx); - } - - /// Cancels an inline edit of a review comment. - pub fn cancel_edit_review_comment( - &mut self, - comment_id: usize, - _window: &mut Window, - cx: &mut Context, - ) { - // Find which hunk this comment belongs to - let hunk_key = self - .stored_review_comments - .iter() - .find_map(|(key, comments)| { - if comments.iter().any(|c| c.id == comment_id) { - Some(key.clone()) - } else { - None - } - }); - - // Remove the inline editor and its subscription - if let Some(hunk_key) = hunk_key { - let snapshot = self.buffer.read(cx).snapshot(cx); - if let Some(overlay) = self - .diff_review_overlays - .iter_mut() - .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, &hunk_key, &snapshot)) - { - overlay.inline_edit_editors.remove(&comment_id); - overlay.inline_edit_subscriptions.remove(&comment_id); - } - } - - // Clear editing state - self.set_comment_editing(comment_id, false, cx); - } - - /// Action handler for ConfirmEditReviewComment. - pub fn confirm_edit_review_comment_action( - &mut self, - action: &ConfirmEditReviewComment, - window: &mut Window, - cx: &mut Context, - ) { - self.confirm_edit_review_comment(action.id, window, cx); - } - - /// Action handler for CancelEditReviewComment. - pub fn cancel_edit_review_comment_action( - &mut self, - action: &CancelEditReviewComment, - window: &mut Window, - cx: &mut Context, - ) { - self.cancel_edit_review_comment(action.id, window, cx); - } - - /// Handles the DeleteReviewComment action - removes a comment. - pub fn delete_review_comment( - &mut self, - action: &DeleteReviewComment, - window: &mut Window, - cx: &mut Context, - ) { - // Get the hunk key before removing the comment - // Find the hunk key from the comment itself - let comment_id = action.id; - let hunk_key = self - .stored_review_comments - .iter() - .find_map(|(key, comments)| { - if comments.iter().any(|c| c.id == comment_id) { - Some(key.clone()) - } else { - None - } - }); - - // Also get it from the overlay for refresh purposes - let overlay_hunk_key = self - .diff_review_overlays - .first() - .map(|o| o.hunk_key.clone()); - - self.remove_review_comment(action.id, cx); - - // Refresh the overlay height after removing a comment - if let Some(hunk_key) = hunk_key.or(overlay_hunk_key) { - self.refresh_diff_review_overlay_height(&hunk_key, window, cx); - } - } - - fn render_diff_review_overlay( - prompt_editor: &Entity, - hunk_key: &DiffHunkKey, - editor_handle: &WeakEntity, - cx: &mut BlockContext, - ) -> AnyElement { - fn format_line_ranges(ranges: &[(u32, u32)]) -> Option { - if ranges.is_empty() { - return None; - } - let formatted: Vec = ranges - .iter() - .map(|(start, end)| { - let start_line = start + 1; - let end_line = end + 1; - if start_line == end_line { - format!("Line {start_line}") - } else { - format!("Lines {start_line}-{end_line}") - } - }) - .collect(); - // Don't show label for single line in single excerpt - if ranges.len() == 1 && ranges[0].0 == ranges[0].1 { - return None; - } - Some(formatted.join(" ⋯ ")) - } - - let theme = cx.theme(); - let colors = theme.colors(); - - let (comments, comments_expanded, inline_editors, user_avatar_uri, line_ranges) = - editor_handle - .upgrade() - .map(|editor| { - let editor = editor.read(cx); - let snapshot = editor.buffer().read(cx).snapshot(cx); - let comments = editor.comments_for_hunk(hunk_key, &snapshot).to_vec(); - let (expanded, editors, avatar_uri, line_ranges) = editor - .diff_review_overlays - .iter() - .find(|overlay| { - Editor::hunk_keys_match(&overlay.hunk_key, hunk_key, &snapshot) - }) - .map(|o| { - let start_point = o.anchor_range.start.to_point(&snapshot); - let end_point = o.anchor_range.end.to_point(&snapshot); - // Get line ranges per excerpt to detect discontinuities - let buffer_ranges = - snapshot.range_to_buffer_ranges(start_point..end_point); - let ranges: Vec<(u32, u32)> = buffer_ranges - .iter() - .map(|(buffer_snapshot, range, _)| { - let start = buffer_snapshot.offset_to_point(range.start.0).row; - let end = buffer_snapshot.offset_to_point(range.end.0).row; - (start, end) - }) - .collect(); - ( - o.comments_expanded, - o.inline_edit_editors.clone(), - o.user_avatar_uri.clone(), - if ranges.is_empty() { - None - } else { - Some(ranges) - }, - ) - }) - .unwrap_or((true, HashMap::default(), None, None)); - (comments, expanded, editors, avatar_uri, line_ranges) - }) - .unwrap_or((Vec::new(), true, HashMap::default(), None, None)); - - let comment_count = comments.len(); - let avatar_size = px(20.); - let action_icon_size = IconSize::XSmall; - - v_flex() - .w_full() - .bg(colors.editor_background) - .border_b_1() - .border_color(colors.border) - .px_2() - .pb_2() - .gap_2() - // Line range indicator (only shown for multi-line selections or multiple excerpts) - .when_some(line_ranges, |el, ranges| { - let label = format_line_ranges(&ranges); - if let Some(label) = label { - el.child( - h_flex() - .w_full() - .px_2() - .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)), - ) - } else { - el - } - }) - // Top row: editable input with user's avatar - .child( - h_flex() - .w_full() - .items_center() - .gap_2() - .px_2() - .py_1p5() - .rounded_md() - .bg(colors.surface_background) - .child( - div() - .size(avatar_size) - .flex_shrink_0() - .rounded_full() - .overflow_hidden() - .child(if let Some(ref avatar_uri) = user_avatar_uri { - Avatar::new(avatar_uri.clone()) - .size(avatar_size) - .into_any_element() - } else { - Icon::new(IconName::Person) - .size(IconSize::Small) - .color(ui::Color::Muted) - .into_any_element() - }), - ) - .child( - div() - .flex_1() - .border_1() - .border_color(colors.border) - .rounded_md() - .bg(colors.editor_background) - .px_2() - .py_1() - .child(prompt_editor.clone()), - ) - .child( - h_flex() - .flex_shrink_0() - .gap_1() - .child( - IconButton::new("diff-review-close", IconName::Close) - .icon_color(ui::Color::Muted) - .icon_size(action_icon_size) - .tooltip(Tooltip::text("Close")) - .on_click(|_, window, cx| { - window - .dispatch_action(Box::new(crate::actions::Cancel), cx); - }), - ) - .child( - IconButton::new("diff-review-add", IconName::Return) - .icon_color(ui::Color::Muted) - .icon_size(action_icon_size) - .tooltip(Tooltip::text("Add comment")) - .on_click(|_, window, cx| { - window.dispatch_action( - Box::new(crate::actions::SubmitDiffReviewComment), - cx, - ); - }), - ), - ), - ) - // Expandable comments section (only shown when there are comments) - .when(comment_count > 0, |el| { - el.child(Self::render_comments_section( - comments, - comments_expanded, - inline_editors, - user_avatar_uri, - avatar_size, - action_icon_size, - colors, - )) - }) - .into_any_element() - } - - fn render_comments_section( - comments: Vec, - expanded: bool, - inline_editors: HashMap>, - user_avatar_uri: Option, - avatar_size: Pixels, - action_icon_size: IconSize, - colors: &theme::ThemeColors, - ) -> impl IntoElement { - let comment_count = comments.len(); - - v_flex() - .w_full() - .gap_1() - // Header with expand/collapse toggle - .child( - h_flex() - .id("review-comments-header") - .w_full() - .items_center() - .gap_1() - .px_2() - .py_1() - .cursor_pointer() - .rounded_md() - .hover(|style| style.bg(colors.ghost_element_hover)) - .on_click(|_, window: &mut Window, cx| { - window.dispatch_action( - Box::new(crate::actions::ToggleReviewCommentsExpanded), - cx, - ); - }) - .child( - Icon::new(if expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }) - .size(IconSize::Small) - .color(ui::Color::Muted), - ) - .child( - Label::new(format!( - "{} Comment{}", - comment_count, - if comment_count == 1 { "" } else { "s" } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - // Comments list (when expanded) - .when(expanded, |el| { - el.children(comments.into_iter().map(|comment| { - let inline_editor = inline_editors.get(&comment.id).cloned(); - Self::render_comment_row( - comment, - inline_editor, - user_avatar_uri.clone(), - avatar_size, - action_icon_size, - colors, - ) - })) - }) - } - - fn render_comment_row( - comment: StoredReviewComment, - inline_editor: Option>, - user_avatar_uri: Option, - avatar_size: Pixels, - action_icon_size: IconSize, - colors: &theme::ThemeColors, - ) -> impl IntoElement { - let comment_id = comment.id; - let is_editing = inline_editor.is_some(); - - h_flex() - .w_full() - .items_center() - .gap_2() - .px_2() - .py_1p5() - .rounded_md() - .bg(colors.surface_background) - .child( - div() - .size(avatar_size) - .flex_shrink_0() - .rounded_full() - .overflow_hidden() - .child(if let Some(ref avatar_uri) = user_avatar_uri { - Avatar::new(avatar_uri.clone()) - .size(avatar_size) - .into_any_element() - } else { - Icon::new(IconName::Person) - .size(IconSize::Small) - .color(ui::Color::Muted) - .into_any_element() - }), - ) - .child(if let Some(editor) = inline_editor { - // Inline edit mode: show an editable text field - div() - .flex_1() - .border_1() - .border_color(colors.border) - .rounded_md() - .bg(colors.editor_background) - .px_2() - .py_1() - .child(editor) - .into_any_element() - } else { - // Display mode: show the comment text - div() - .flex_1() - .text_sm() - .text_color(colors.text) - .child(comment.comment) - .into_any_element() - }) - .child(if is_editing { - // Editing mode: show close and confirm buttons - h_flex() - .gap_1() - .child( - IconButton::new( - format!("diff-review-cancel-edit-{comment_id}"), - IconName::Close, - ) - .icon_color(ui::Color::Muted) - .icon_size(action_icon_size) - .tooltip(Tooltip::text("Cancel")) - .on_click(move |_, window, cx| { - window.dispatch_action( - Box::new(crate::actions::CancelEditReviewComment { - id: comment_id, - }), - cx, - ); - }), - ) - .child( - IconButton::new( - format!("diff-review-confirm-edit-{comment_id}"), - IconName::Return, - ) - .icon_color(ui::Color::Muted) - .icon_size(action_icon_size) - .tooltip(Tooltip::text("Confirm")) - .on_click(move |_, window, cx| { - window.dispatch_action( - Box::new(crate::actions::ConfirmEditReviewComment { - id: comment_id, - }), - cx, - ); - }), - ) - .into_any_element() - } else { - // Display mode: no action buttons for now (edit/delete not yet implemented) - gpui::Empty.into_any_element() - }) - } - - pub fn set_masked(&mut self, masked: bool, cx: &mut Context) { - if self.display_map.read(cx).masked != masked { - self.display_map.update(cx, |map, _| map.masked = masked); - } - cx.notify() - } - - fn get_permalink_to_line(&self, cx: &mut Context) -> Task> { - let buffer_and_selection = maybe!({ - let selection = self.selections.newest::(&self.display_snapshot(cx)); - let selection_range = selection.range(); - - let multi_buffer = self.buffer().read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let buffer_ranges = multi_buffer_snapshot - .range_to_buffer_ranges(selection_range.start..selection_range.end); - - let (buffer_snapshot, range, _) = if selection.reversed { - buffer_ranges.first() - } else { - buffer_ranges.last() - }?; - - let buffer_range = range.to_point(buffer_snapshot); - let buffer = multi_buffer.buffer(buffer_snapshot.remote_id()).unwrap(); - - let Some(buffer_diff) = multi_buffer.diff_for(buffer_snapshot.remote_id()) else { - return Some((buffer, buffer_range.start.row..buffer_range.end.row)); - }; - - let buffer_diff_snapshot = buffer_diff.read(cx).snapshot(cx); - let start = buffer_diff_snapshot - .buffer_point_to_base_text_point(buffer_range.start, &buffer_snapshot); - let end = buffer_diff_snapshot - .buffer_point_to_base_text_point(buffer_range.end, &buffer_snapshot); - - Some((buffer, start.row..end.row)) - }); - - let Some((buffer, selection)) = buffer_and_selection else { - return Task::ready(Err(anyhow!("failed to determine buffer and selection"))); - }; - - let Some(project) = self.project() else { - return Task::ready(Err(anyhow!("editor does not have project"))); - }; - - project.update(cx, |project, cx| { - project.get_permalink_to_line(&buffer, selection, cx) - }) - } - - pub fn copy_permalink_to_line( - &mut self, - _: &CopyPermalinkToLine, - window: &mut Window, - cx: &mut Context, - ) { - let permalink_task = self.get_permalink_to_line(cx); - let workspace = self.workspace(); - - cx.spawn_in(window, async move |_, cx| match permalink_task.await { - Ok(permalink) => { - cx.update(|_, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string())); - }) - .ok(); - } - Err(err) => { - let message = format!("Failed to copy permalink: {err}"); - - anyhow::Result::<()>::Err(err).log_err(); - - if let Some(workspace) = workspace { - workspace - .update_in(cx, |workspace, _, cx| { - struct CopyPermalinkToLine; - - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - message, - ), - cx, - ) - }) - .ok(); - } - } - }) - .detach(); - } - - pub fn copy_file_location( - &mut self, - _: &CopyFileLocation, - _: &mut Window, - cx: &mut Context, - ) { - let selection = self.selections.newest::(&self.display_snapshot(cx)); - - let start_line = selection.start.row + 1; - let end_line = selection.end.row + 1; - - let end_line = if selection.end.column == 0 && end_line > start_line { - end_line - 1 - } else { - end_line - }; - - if let Some(file_location) = self.active_buffer(cx).and_then(|buffer| { - let project = self.project()?.read(cx); - let file = buffer.read(cx).file()?; - let path = file.path().display(project.path_style(cx)); - - let location = if start_line == end_line { - format!("{path}:{start_line}") - } else { - format!("{path}:{start_line}-{end_line}") - }; - Some(location) - }) { - cx.write_to_clipboard(ClipboardItem::new_string(file_location)); - } - } - - pub fn open_permalink_to_line( - &mut self, - _: &OpenPermalinkToLine, - window: &mut Window, - cx: &mut Context, - ) { - let permalink_task = self.get_permalink_to_line(cx); - let workspace = self.workspace(); - - cx.spawn_in(window, async move |_, cx| match permalink_task.await { - Ok(permalink) => { - cx.update(|_, cx| { - cx.open_url(permalink.as_ref()); - }) - .ok(); - } - Err(err) => { - let message = format!("Failed to open permalink: {err}"); - - anyhow::Result::<()>::Err(err).log_err(); - - if let Some(workspace) = workspace { - workspace.update(cx, |workspace, cx| { - struct OpenPermalinkToLine; - - workspace.show_toast( - Toast::new(NotificationId::unique::(), message), - cx, - ) - }); - } - } - }) - .detach(); - } - - pub fn insert_uuid_v4( - &mut self, - _: &InsertUuidV4, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_uuid(UuidVersion::V4, window, cx); - } - - pub fn insert_uuid_v7( - &mut self, - _: &InsertUuidV7, - window: &mut Window, - cx: &mut Context, - ) { - self.insert_uuid(UuidVersion::V7, window, cx); - } - - fn insert_uuid(&mut self, version: UuidVersion, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { + fn insert_uuid(&mut self, version: UuidVersion, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { return; } self.transact(window, cx, |this, window, cx| { @@ -18679,32 +16933,6 @@ impl Editor { }; } - pub fn start_temporary_diff_override(&mut self) { - self.load_diff_task.take(); - self.temporary_diff_override = true; - } - - pub fn end_temporary_diff_override(&mut self, cx: &mut Context) { - self.temporary_diff_override = false; - self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx); - self.buffer.update(cx, |buffer, cx| { - buffer.set_all_diff_hunks_collapsed(cx); - }); - - if let Some(project) = self.project.clone() { - self.load_diff_task = Some( - update_uncommitted_diff_for_buffer( - cx.entity(), - &project, - self.buffer.read(cx).all_buffers(), - self.buffer.clone(), - cx, - ) - .shared(), - ); - } - } - fn on_display_map_changed( &mut self, _: Entity, @@ -20167,35 +18395,6 @@ struct CompletionEdit { snippet: Option, } -fn update_uncommitted_diff_for_buffer( - editor: Entity, - project: &Entity, - buffers: impl IntoIterator>, - buffer: Entity, - cx: &mut App, -) -> Task<()> { - let mut tasks = Vec::new(); - project.update(cx, |project, cx| { - for buffer in buffers { - if project::File::from_dyn(buffer.read(cx).file()).is_some() { - tasks.push(project.open_uncommitted_diff(buffer.clone(), cx)) - } - } - }); - cx.spawn(async move |cx| { - let diffs = future::join_all(tasks).await; - if editor.read_with(cx, |editor, _cx| editor.temporary_diff_override) { - return; - } - - buffer.update(cx, |buffer, cx| { - for diff in diffs.into_iter().flatten() { - buffer.add_diff(diff, cx); - } - }); - }) -} - pub trait CollaborationHub { fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap; fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap; @@ -20560,98 +18759,6 @@ impl EditorSnapshot { }) } - pub fn hunks_for_ranges( - &self, - ranges: impl IntoIterator>, - ) -> Vec { - let mut hunks = Vec::new(); - let mut processed_buffer_rows: HashMap>> = - HashMap::default(); - for query_range in ranges { - let query_rows = - MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1); - for hunk in self.buffer_snapshot().diff_hunks_in_range( - Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0), - ) { - // Include deleted hunks that are adjacent to the query range, because - // otherwise they would be missed. - let mut intersects_range = hunk.row_range.overlaps(&query_rows); - if hunk.status().is_deleted() { - intersects_range |= hunk.row_range.start == query_rows.end; - intersects_range |= hunk.row_range.end == query_rows.start; - } - if intersects_range { - if !processed_buffer_rows - .entry(hunk.buffer_id) - .or_default() - .insert(hunk.buffer_range.start..hunk.buffer_range.end) - { - continue; - } - hunks.push(hunk); - } - } - } - - hunks - } - - fn display_diff_hunks_for_rows<'a>( - &'a self, - display_rows: Range, - folded_buffers: &'a HashSet, - ) -> impl 'a + Iterator { - let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(self); - let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(self); - - self.buffer_snapshot() - .diff_hunks_in_range(buffer_start..buffer_end) - .filter_map(|hunk| { - if folded_buffers.contains(&hunk.buffer_id) - || (hunk.row_range.is_empty() && self.buffer.all_diff_hunks_expanded()) - { - return None; - } - - let hunk_start_point = Point::new(hunk.row_range.start.0, 0); - let hunk_end_point = if hunk.row_range.end > hunk.row_range.start { - let last_row = MultiBufferRow(hunk.row_range.end.0 - 1); - let line_len = self.buffer_snapshot().line_len(last_row); - Point::new(last_row.0, line_len) - } else { - Point::new(hunk.row_range.end.0, 0) - }; - - let hunk_display_start = self.point_to_display_point(hunk_start_point, Bias::Left); - let hunk_display_end = self.point_to_display_point(hunk_end_point, Bias::Right); - - let display_hunk = if hunk_display_start.column() != 0 { - DisplayDiffHunk::Folded { - display_row: hunk_display_start.row(), - } - } else { - let mut end_row = hunk_display_end.row(); - if hunk.row_range.end > hunk.row_range.start || hunk_display_end.column() > 0 { - end_row.0 += 1; - } - let is_created_file = hunk.is_created_file(); - let multi_buffer_range = hunk.multi_buffer_range.clone(); - - DisplayDiffHunk::Unfolded { - status: hunk.status(), - diff_base_byte_range: hunk.diff_base_byte_range.start.0 - ..hunk.diff_base_byte_range.end.0, - word_diffs: hunk.word_diffs, - display_row_range: hunk_display_start.row()..end_row, - multi_buffer_range, - is_created_file, - } - }; - - Some(display_hunk) - }) - } - pub fn language_at(&self, position: T) -> Option<&Arc> { self.display_snapshot .buffer_snapshot() @@ -20672,6 +18779,11 @@ impl EditorSnapshot { self.scroll_anchor.scroll_position(&self.display_snapshot) } + pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { + let digit_count = self.widest_line_number().ilog10() + 1; + column_pixels(style, digit_count as usize, window) + } + pub fn gutter_dimensions( &self, font_id: FontId, @@ -20769,11 +18881,6 @@ impl EditorSnapshot { } } - pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { - let digit_count = self.widest_line_number().ilog10() + 1; - column_pixels(style, digit_count as usize, window) - } - /// Returns the line delta from `base` to `line` in the multibuffer, ignoring wrapped lines. /// /// This is positive if `base` is before `line`. @@ -21686,178 +19793,6 @@ struct LineManipulationResult { pub line_count_after: usize, } -fn render_diff_hunk_controls( - row: u32, - status: &DiffHunkStatus, - hunk_range: Range, - is_created_file: bool, - line_height: Pixels, - editor: &Entity, - _window: &mut Window, - cx: &mut App, -) -> AnyElement { - h_flex() - .h(line_height) - .mr_1() - .gap_1() - .px_0p5() - .pb_1() - .border_x_1() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .rounded_b_lg() - .bg(cx.theme().colors().editor_background) - .gap_1() - .block_mouse_except_scroll() - .shadow_md() - .child(if status.has_secondary_hunk() { - Button::new(("stage", row as u64), "Stage") - .alpha(if status.is_pending() { 0.66 } else { 1.0 }) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Stage Hunk", - &::git::ToggleStaged, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, _window, cx| { - editor.update(cx, |editor, cx| { - editor.stage_or_unstage_diff_hunks( - true, - vec![hunk_range.start..hunk_range.start], - cx, - ); - }); - } - }) - } else { - Button::new(("unstage", row as u64), "Unstage") - .alpha(if status.is_pending() { 0.66 } else { 1.0 }) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Unstage Hunk", - &::git::ToggleStaged, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, _window, cx| { - editor.update(cx, |editor, cx| { - editor.stage_or_unstage_diff_hunks( - false, - vec![hunk_range.start..hunk_range.start], - cx, - ); - }); - } - }) - }) - .child( - Button::new(("restore", row as u64), "Restore") - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in("Restore Hunk", &::git::Restore, &focus_handle, cx) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, window, cx| { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let point = hunk_range.start.to_point(&snapshot.buffer_snapshot()); - editor.restore_hunks_in_ranges(vec![point..point], window, cx); - }); - } - }) - .disabled(is_created_file), - ) - .when( - !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(), - |el| { - el.child( - IconButton::new(("next-hunk", row as u64), IconName::ArrowDown) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - // .disabled(!has_multiple_hunks) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, window, cx| { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let position = - hunk_range.end.to_point(&snapshot.buffer_snapshot()); - editor.go_to_hunk_before_or_after_position( - &snapshot, - position, - Direction::Next, - true, - window, - cx, - ); - editor.expand_selected_diff_hunks(cx); - }); - } - }), - ) - .child( - IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - // .disabled(!has_multiple_hunks) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Previous Hunk", - &GoToPreviousHunk, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - move |_event, window, cx| { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let point = - hunk_range.start.to_point(&snapshot.buffer_snapshot()); - editor.go_to_hunk_before_or_after_position( - &snapshot, - point, - Direction::Prev, - true, - window, - cx, - ); - editor.expand_selected_diff_hunks(cx); - }); - } - }), - ) - }, - ) - .into_any_element() -} - pub fn multibuffer_context_lines(cx: &App) -> u32 { EditorSettings::try_get(cx) .map(|settings| settings.excerpt_context_lines) diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index dd4b156dcab8640cfee4ff1593bbf34425b8496f..027a8e8d18e78f3106b513823e8074c594683cba 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1,6 +1,139 @@ -pub mod blame; +pub(super) mod blame; use super::*; +use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; +use buffer_diff::DiffHunkStatus; + +pub type RenderDiffHunkControlsFn = Arc< + dyn Fn( + u32, + &DiffHunkStatus, + Range, + bool, + Pixels, + &Entity, + &mut Window, + &mut App, + ) -> AnyElement, +>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DisplayDiffHunk { + Folded { + display_row: DisplayRow, + }, + Unfolded { + is_created_file: bool, + diff_base_byte_range: Range, + display_row_range: Range, + multi_buffer_range: Range, + status: DiffHunkStatus, + word_diffs: Vec>, + }, +} + +#[derive(Clone)] +pub(super) struct InlineBlamePopoverState { + pub(super) scroll_handle: ScrollHandle, + pub(super) commit_message: Option, + pub(super) markdown: Entity, +} + +pub(super) struct InlineBlamePopover { + pub(super) position: gpui::Point, + pub(super) hide_task: Option>, + pub(super) popover_bounds: Option>, + pub(super) popover_state: InlineBlamePopoverState, + pub(super) keyboard_grace: bool, +} + +/// Represents a diff review button indicator that shows up when hovering over lines in the gutter +/// in diff view mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct PhantomDiffReviewIndicator { + /// The starting anchor of the selection (or the only row if not dragging). + pub(super) start: Anchor, + /// The ending anchor of the selection. Equal to start_anchor for single-line selection. + pub(super) end: Anchor, + /// There's a small debounce between hovering over the line and showing the indicator. + /// We don't want to show the indicator when moving the mouse from editor to e.g. project panel. + pub(super) is_active: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct DiffReviewDragState { + start_anchor: Anchor, + current_anchor: Anchor, +} + +/// Identifies a specific hunk in the diff buffer. +/// Used as a key to group comments by their location. +#[derive(Clone, Debug)] +pub(super) struct DiffHunkKey { + /// The file path (relative to worktree) this hunk belongs to. + pub(super) file_path: Arc, + /// An anchor at the start of the hunk. This tracks position as the buffer changes. + pub(super) hunk_start_anchor: Anchor, +} + +/// A review comment stored locally before being sent to the Agent panel. +#[derive(Clone)] +pub(super) struct StoredReviewComment { + /// Unique identifier for this comment (for edit/delete operations). + pub(super) id: usize, + /// The comment text entered by the user. + pub(super) comment: String, + /// Anchors for the code range being reviewed. + pub(super) range: Range, + /// Whether this comment is currently being edited inline. + pub(super) is_editing: bool, +} + +/// Represents an active diff review overlay that appears when clicking the "Add Review" button. +pub(super) struct DiffReviewOverlay { + pub(super) anchor_range: Range, + /// The block ID for the overlay. + pub(super) block_id: CustomBlockId, + /// The editor entity for the review input. + pub(super) prompt_editor: Entity, + /// The hunk key this overlay belongs to. + pub(super) hunk_key: DiffHunkKey, + /// Whether the comments section is expanded. + pub(super) comments_expanded: bool, + /// Editors for comments currently being edited inline. + /// Key: comment ID, Value: Editor entity for inline editing. + pub(super) inline_edit_editors: HashMap>, + /// Subscriptions for inline edit editors' action handlers. + /// Key: comment ID, Value: Subscription keeping the Newline action handler alive. + pub(super) inline_edit_subscriptions: HashMap, + /// The current user's avatar URI for display in comment rows. + pub(super) user_avatar_uri: Option, + /// Subscription to keep the action handler alive. + _subscription: Subscription, +} + +impl DiffReviewDragState { + pub(super) fn row_range( + &self, + snapshot: &DisplaySnapshot, + ) -> std::ops::RangeInclusive { + let start = self.start_anchor.to_display_point(snapshot).row(); + let current = self.current_anchor.to_display_point(snapshot).row(); + + (start..=current).sorted() + } +} + +impl StoredReviewComment { + fn new(id: usize, comment: String, anchor_range: Range) -> Self { + Self { + id, + comment, + range: anchor_range, + is_editing: false, + } + } +} impl Editor { pub fn diff_hunks_in_ranges<'a>( @@ -33,139 +166,1180 @@ impl Editor { }) } - pub fn set_render_diff_hunk_controls( + pub fn set_render_diff_hunk_controls( + &mut self, + render_diff_hunk_controls: RenderDiffHunkControlsFn, + cx: &mut Context, + ) { + self.render_diff_hunk_controls = render_diff_hunk_controls; + cx.notify(); + } + + pub fn git_blame_inline_enabled(&self) -> bool { + self.git_blame_inline_enabled + } + + pub fn blame(&self) -> Option<&Entity> { + self.blame.as_ref() + } + + pub fn show_git_blame_gutter(&self) -> bool { + self.show_git_blame_gutter + } + + pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); + self.buffer + .update(cx, |buffer, cx| buffer.expand_diff_hunks(ranges, cx)) + } + + pub fn toggle_git_blame( + &mut self, + _: &::git::Blame, + window: &mut Window, + cx: &mut Context, + ) { + self.show_git_blame_gutter = !self.show_git_blame_gutter; + + if self.show_git_blame_gutter && !self.has_blame_entries(cx) { + self.start_git_blame(true, window, cx); + } + + cx.notify(); + } + + pub fn toggle_git_blame_inline( + &mut self, + _: &ToggleGitBlameInline, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_git_blame_inline_internal(true, window, cx); + cx.notify(); + } + + pub fn start_temporary_diff_override(&mut self) { + self.load_diff_task.take(); + self.temporary_diff_override = true; + } + + pub fn end_temporary_diff_override(&mut self, cx: &mut Context) { + self.temporary_diff_override = false; + self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx); + self.buffer.update(cx, |buffer, cx| { + buffer.set_all_diff_hunks_collapsed(cx); + }); + + if let Some(project) = self.project.clone() { + self.load_diff_task = Some( + update_uncommitted_diff_for_buffer( + cx.entity(), + &project, + self.buffer.read(cx).all_buffers(), + self.buffer.clone(), + cx, + ) + .shared(), + ); + } + } + + /// Hides the inline blame popover element, in case it's already visible, or + /// interrupts the task meant to show it, in case the task is running. + /// + /// When `ignore_timeout` is set to `true`, the popover is hidden + /// immediately, otherwise it'll be hidden after a short delay. + /// + /// Returns `true` if the popover was visible and was hidden, `false` + /// otherwise. + pub fn hide_blame_popover(&mut self, ignore_timeout: bool, cx: &mut Context) -> bool { + self.inline_blame_popover_show_task.take(); + + if let Some(state) = &mut self.inline_blame_popover { + if ignore_timeout { + self.inline_blame_popover.take(); + cx.notify(); + } else { + state.hide_task = Some(cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(100)) + .await; + + editor + .update(cx, |editor, cx| { + editor.inline_blame_popover.take(); + cx.notify(); + }) + .ok(); + })); + } + + true + } else { + false + } + } + + pub fn git_restore(&mut self, _: &Restore, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + let selections = self + .selections + .all(&self.display_snapshot(cx)) + .into_iter() + .map(|s| s.range()) + .collect(); + self.restore_hunks_in_ranges(selections, window, cx); + } + + pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { + if let Some(status) = self + .addons + .iter() + .find_map(|(_, addon)| addon.override_status_for_buffer_id(buffer_id, cx)) + { + return Some(status); + } + self.project + .as_ref()? + .read(cx) + .status_for_buffer_id(buffer_id, cx) + } + + pub fn go_to_hunk_before_or_after_position( + &mut self, + snapshot: &EditorSnapshot, + position: Point, + direction: Direction, + wrap_around: bool, + window: &mut Window, + cx: &mut Context, + ) { + let row = if direction == Direction::Next { + self.hunk_after_position(snapshot, position, wrap_around) + .map(|hunk| hunk.row_range.start) + } else { + self.hunk_before_position(snapshot, position, wrap_around) + }; + + if let Some(row) = row { + let destination = Point::new(row.0, 0); + let autoscroll = Autoscroll::center(); + + self.unfold_ranges(&[destination..destination], false, false, cx); + self.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| { + s.select_ranges([destination..destination]); + }); + } + } + + pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) { + self.buffer.update(cx, |buffer, cx| { + buffer.set_all_diff_hunks_expanded(cx); + }); + } + + pub fn expand_all_diff_hunks( + &mut self, + _: &ExpandAllDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + self.buffer.update(cx, |buffer, cx| { + buffer.expand_diff_hunks(vec![Anchor::Min..Anchor::Max], cx) + }); + } + + pub fn show_diff_review_overlay( + &mut self, + display_range: Range, + window: &mut Window, + cx: &mut Context, + ) { + let Range { start, end } = display_range.sorted(); + + let buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let editor_snapshot = self.snapshot(window, cx); + + // Convert display rows to multibuffer points + let start_point = editor_snapshot + .display_snapshot + .display_point_to_point(start.as_display_point(), Bias::Left); + let end_point = editor_snapshot + .display_snapshot + .display_point_to_point(end.as_display_point(), Bias::Left); + let end_multi_buffer_row = MultiBufferRow(end_point.row); + + // Create anchor range for the selected lines (start of first line to end of last line) + let line_end = Point::new( + end_point.row, + buffer_snapshot.line_len(end_multi_buffer_row), + ); + let anchor_range = + buffer_snapshot.anchor_after(start_point)..buffer_snapshot.anchor_before(line_end); + + // Compute the hunk key for this display row + let file_path = buffer_snapshot + .file_at(start_point) + .map(|file: &Arc| file.path().clone()) + .unwrap_or_else(|| Arc::from(util::rel_path::RelPath::empty())); + let hunk_start_anchor = buffer_snapshot.anchor_before(start_point); + let new_hunk_key = DiffHunkKey { + file_path, + hunk_start_anchor, + }; + + // Check if we already have an overlay for this hunk + if let Some(existing_overlay) = self.diff_review_overlays.iter().find(|overlay| { + Self::hunk_keys_match(&overlay.hunk_key, &new_hunk_key, &buffer_snapshot) + }) { + // Just focus the existing overlay's prompt editor + let focus_handle = existing_overlay.prompt_editor.focus_handle(cx); + window.focus(&focus_handle, cx); + return; + } + + // Dismiss overlays that have no comments for their hunks + self.dismiss_overlays_without_comments(cx); + + // Get the current user's avatar URI from the project's user_store + let user_avatar_uri = self.project.as_ref().and_then(|project| { + let user_store = project.read(cx).user_store(); + user_store + .read(cx) + .current_user() + .map(|user| user.avatar_uri.clone()) + }); + + // Create anchor at the end of the last row so the block appears immediately below it + // Use multibuffer coordinates for anchor creation + let line_len = buffer_snapshot.line_len(end_multi_buffer_row); + let anchor = buffer_snapshot.anchor_after(Point::new(end_multi_buffer_row.0, line_len)); + + // Use the hunk key we already computed + let hunk_key = new_hunk_key; + + // Create the prompt editor for the review input + let prompt_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Add a review comment...", window, cx); + editor + }); + + // Register the Newline action on the prompt editor to submit the review + let parent_editor = cx.entity().downgrade(); + let subscription = prompt_editor.update(cx, |prompt_editor, _cx| { + prompt_editor.register_action({ + let parent_editor = parent_editor.clone(); + move |_: &crate::actions::Newline, window, cx| { + if let Some(editor) = parent_editor.upgrade() { + editor.update(cx, |editor, cx| { + editor.submit_diff_review_comment(window, cx); + }); + } + } + }) + }); + + // Calculate initial height based on existing comments for this hunk + let initial_height = self.calculate_overlay_height(&hunk_key, true, &buffer_snapshot); + + // Create the overlay block + let prompt_editor_for_render = prompt_editor.clone(); + let hunk_key_for_render = hunk_key.clone(); + let editor_handle = cx.entity().downgrade(); + let block = BlockProperties { + style: BlockStyle::Sticky, + placement: BlockPlacement::Below(anchor), + height: Some(initial_height), + render: Arc::new(move |cx| { + Self::render_diff_review_overlay( + &prompt_editor_for_render, + &hunk_key_for_render, + &editor_handle, + cx, + ) + }), + priority: 0, + }; + + let block_ids = self.insert_blocks([block], None, cx); + let Some(block_id) = block_ids.into_iter().next() else { + log::error!("Failed to insert diff review overlay block"); + return; + }; + + self.diff_review_overlays.push(DiffReviewOverlay { + anchor_range, + block_id, + prompt_editor: prompt_editor.clone(), + hunk_key, + comments_expanded: true, + inline_edit_editors: HashMap::default(), + inline_edit_subscriptions: HashMap::default(), + user_avatar_uri, + _subscription: subscription, + }); + + // Focus the prompt editor + let focus_handle = prompt_editor.focus_handle(cx); + window.focus(&focus_handle, cx); + + cx.notify(); + } + + /// Stores the diff review comment locally. + /// Comments are stored per-hunk and can later be batch-submitted to the Agent panel. + pub fn submit_diff_review_comment(&mut self, window: &mut Window, cx: &mut Context) { + // Find the overlay that currently has focus + let overlay_index = self + .diff_review_overlays + .iter() + .position(|overlay| overlay.prompt_editor.focus_handle(cx).is_focused(window)); + let Some(overlay_index) = overlay_index else { + return; + }; + let overlay = &self.diff_review_overlays[overlay_index]; + + let comment_text = overlay.prompt_editor.read(cx).text(cx).trim().to_string(); + if comment_text.is_empty() { + return; + } + + let anchor_range = overlay.anchor_range.clone(); + let hunk_key = overlay.hunk_key.clone(); + + self.add_review_comment(hunk_key.clone(), comment_text, anchor_range, cx); + + // Clear the prompt editor but keep the overlay open + if let Some(overlay) = self.diff_review_overlays.get(overlay_index) { + overlay.prompt_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + }); + } + + // Refresh the overlay to update the block height for the new comment + self.refresh_diff_review_overlay_height(&hunk_key, window, cx); + + cx.notify(); + } + + /// Returns the prompt editor for the diff review overlay, if one is active. + /// This is primarily used for testing. + pub fn diff_review_prompt_editor(&self) -> Option<&Entity> { + self.diff_review_overlays + .first() + .map(|overlay| &overlay.prompt_editor) + } + + /// Sets whether the comments section is expanded in the diff review overlay. + /// This is primarily used for testing. + pub fn set_diff_review_comments_expanded(&mut self, expanded: bool, cx: &mut Context) { + for overlay in &mut self.diff_review_overlays { + overlay.comments_expanded = expanded; + } + cx.notify(); + } + + /// Returns the total count of stored review comments across all hunks. + pub(super) fn total_review_comment_count(&self) -> usize { + self.stored_review_comments + .iter() + .map(|(_, v)| v.len()) + .sum() + } + + /// Adds a new review comment to a specific hunk. + pub(super) fn add_review_comment( + &mut self, + hunk_key: DiffHunkKey, + comment: String, + anchor_range: Range, + cx: &mut Context, + ) -> usize { + let id = self.next_review_comment_id; + self.next_review_comment_id += 1; + + let stored_comment = StoredReviewComment::new(id, comment, anchor_range); + + let snapshot = self.buffer.read(cx).snapshot(cx); + let key_point = hunk_key.hunk_start_anchor.to_point(&snapshot); + + // Find existing entry for this hunk or add a new one + if let Some((_, comments)) = self.stored_review_comments.iter_mut().find(|(k, _)| { + k.file_path == hunk_key.file_path + && k.hunk_start_anchor.to_point(&snapshot) == key_point + }) { + comments.push(stored_comment); + } else { + self.stored_review_comments + .push((hunk_key, vec![stored_comment])); + } + + cx.emit(EditorEvent::ReviewCommentsChanged { + total_count: self.total_review_comment_count(), + }); + cx.notify(); + id + } + + pub(super) fn blame_hover( + &mut self, + _: &BlameHover, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.snapshot(window, cx); + let cursor = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + let Some((buffer, point)) = snapshot.buffer_snapshot().point_to_buffer_point(cursor) else { + return; + }; + + if self.blame.is_none() { + self.start_git_blame(true, window, cx); + } + let Some(blame) = self.blame.as_ref() else { + return; + }; + + let row_info = RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(point.row), + ..Default::default() + }; + let Some((buffer, blame_entry)) = blame + .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) + .flatten() + else { + return; + }; + + let anchor = self.selections.newest_anchor().head(); + let position = self.to_pixel_point(anchor, &snapshot, window, cx); + if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { + self.show_blame_popover( + buffer, + &blame_entry, + position + last_bounds.origin, + true, + cx, + ); + }; + } + + pub(super) fn restore_file( + &mut self, + _: &::git::RestoreFile, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + let mut buffer_ids = HashSet::default(); + let snapshot = self.buffer().read(cx).snapshot(cx); + for selection in self + .selections + .all::(&self.display_snapshot(cx)) + { + buffer_ids.extend(snapshot.buffer_ids_for_range(selection.range())) + } + + let ranges = buffer_ids + .into_iter() + .flat_map(|buffer_id| snapshot.range_for_buffer(buffer_id)) + .collect::>(); + + self.restore_hunks_in_ranges(ranges, window, cx); + } + + /// Restores the diff hunks in the editor's selections and moves the cursor + /// to the next diff hunk. Wraps around to the beginning of the buffer if + /// not all diff hunks are expanded. + pub(super) fn restore_and_next( + &mut self, + _: &::git::RestoreAndNext, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + let selections = self + .selections + .all(&self.display_snapshot(cx)) + .into_iter() + .map(|selection| selection.range()) + .collect(); + + self.restore_hunks_in_ranges(selections, window, cx); + + let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); + let wrap_around = !all_diff_hunks_expanded; + let snapshot = self.snapshot(window, cx); + let position = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + + self.go_to_hunk_before_or_after_position( + &snapshot, + position, + Direction::Next, + wrap_around, + window, + cx, + ); + } + + pub(super) fn restore_diff_hunks(&self, hunks: Vec, cx: &mut App) { + let mut revert_changes = HashMap::default(); + let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); + for (buffer_id, hunks) in &chunk_by { + let hunks = hunks.collect::>(); + for hunk in &hunks { + self.prepare_restore_change(&mut revert_changes, hunk, cx); + } + self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx); + } + if !revert_changes.is_empty() { + self.buffer().update(cx, |multi_buffer, cx| { + for (buffer_id, changes) in revert_changes { + if let Some(buffer) = multi_buffer.buffer(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.edit( + changes + .into_iter() + .map(|(range, text)| (range, text.to_string())), + None, + cx, + ); + }); + } + } + }); + } + } + + pub(super) fn go_to_next_hunk( + &mut self, + _: &GoToHunk, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.snapshot(window, cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); + self.go_to_hunk_before_or_after_position( + &snapshot, + selection.head(), + Direction::Next, + true, + window, + cx, + ); + } + + pub(super) fn collapse_all_diff_hunks( + &mut self, + _: &CollapseAllDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + self.buffer.update(cx, |buffer, cx| { + buffer.collapse_diff_hunks(vec![Anchor::Min..Anchor::Max], cx) + }); + } + + pub(super) fn toggle_selected_diff_hunks( + &mut self, + _: &ToggleSelectedDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); + self.toggle_diff_hunks_in_ranges(ranges, cx); + } + + pub(super) fn show_diff_review_button(&self) -> bool { + self.show_diff_review_button + } + + pub(super) fn render_diff_review_button( + &self, + display_row: DisplayRow, + width: Pixels, + cx: &mut Context, + ) -> impl IntoElement { + let text_color = cx.theme().colors().text; + let icon_color = cx.theme().colors().icon_accent; + + h_flex() + .id("diff_review_button") + .cursor_pointer() + .w(width - px(1.)) + .h(relative(0.9)) + .justify_center() + .rounded_sm() + .border_1() + .border_color(text_color.opacity(0.1)) + .bg(text_color.opacity(0.15)) + .hover(|s| { + s.bg(icon_color.opacity(0.4)) + .border_color(icon_color.opacity(0.5)) + }) + .child(Icon::new(IconName::Plus).size(IconSize::Small)) + .tooltip(Tooltip::text("Add Review (drag to select multiple lines)")) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(move |editor, _event: &gpui::MouseDownEvent, window, cx| { + editor.start_diff_review_drag(display_row, window, cx); + }), + ) + } + + pub(super) fn start_diff_review_drag( + &mut self, + display_row: DisplayRow, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.snapshot(window, cx); + let point = snapshot + .display_snapshot + .display_point_to_point(DisplayPoint::new(display_row, 0), Bias::Left); + let anchor = snapshot.buffer_snapshot().anchor_before(point); + self.diff_review_drag_state = Some(DiffReviewDragState { + start_anchor: anchor, + current_anchor: anchor, + }); + cx.notify(); + } + + pub(super) fn update_diff_review_drag( + &mut self, + display_row: DisplayRow, + window: &mut Window, + cx: &mut Context, + ) { + if self.diff_review_drag_state.is_none() { + return; + } + let snapshot = self.snapshot(window, cx); + let point = snapshot + .display_snapshot + .display_point_to_point(display_row.as_display_point(), Bias::Left); + let anchor = snapshot.buffer_snapshot().anchor_before(point); + if let Some(drag_state) = &mut self.diff_review_drag_state { + drag_state.current_anchor = anchor; + cx.notify(); + } + } + + pub(super) fn end_diff_review_drag(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(drag_state) = self.diff_review_drag_state.take() { + let snapshot = self.snapshot(window, cx); + let range = drag_state.row_range(&snapshot.display_snapshot); + self.show_diff_review_overlay(*range.start()..*range.end(), window, cx); + } + cx.notify(); + } + + pub(super) fn cancel_diff_review_drag(&mut self, cx: &mut Context) { + self.diff_review_drag_state = None; + cx.notify(); + } + + /// Dismisses all diff review overlays. + pub(super) fn dismiss_all_diff_review_overlays(&mut self, cx: &mut Context) { + if self.diff_review_overlays.is_empty() { + return; + } + let block_ids: HashSet<_> = self + .diff_review_overlays + .drain(..) + .map(|overlay| overlay.block_id) + .collect(); + self.remove_blocks(block_ids, None, cx); + cx.notify(); + } + + /// Action handler for SubmitDiffReviewComment. + pub(super) fn submit_diff_review_comment_action( + &mut self, + _: &SubmitDiffReviewComment, + window: &mut Window, + cx: &mut Context, + ) { + self.submit_diff_review_comment(window, cx); + } + + /// Returns comments for a specific hunk, ordered by creation time. + pub(super) fn comments_for_hunk<'a>( + &'a self, + key: &DiffHunkKey, + snapshot: &MultiBufferSnapshot, + ) -> &'a [StoredReviewComment] { + let key_point = key.hunk_start_anchor.to_point(snapshot); + self.stored_review_comments + .iter() + .find(|(k, _)| { + k.file_path == key.file_path && k.hunk_start_anchor.to_point(snapshot) == key_point + }) + .map(|(_, comments)| comments.as_slice()) + .unwrap_or(&[]) + } + + /// Returns the count of comments for a specific hunk. + pub(super) fn hunk_comment_count( + &self, + key: &DiffHunkKey, + snapshot: &MultiBufferSnapshot, + ) -> usize { + let key_point = key.hunk_start_anchor.to_point(snapshot); + self.stored_review_comments + .iter() + .find(|(k, _)| { + k.file_path == key.file_path && k.hunk_start_anchor.to_point(snapshot) == key_point + }) + .map(|(_, v)| v.len()) + .unwrap_or(0) + } + + /// Removes a review comment by ID from any hunk. + pub(super) fn remove_review_comment(&mut self, id: usize, cx: &mut Context) -> bool { + for (_, comments) in self.stored_review_comments.iter_mut() { + if let Some(index) = comments.iter().position(|c| c.id == id) { + comments.remove(index); + cx.emit(EditorEvent::ReviewCommentsChanged { + total_count: self.total_review_comment_count(), + }); + cx.notify(); + return true; + } + } + false + } + + /// Updates a review comment's text by ID. + pub(super) fn update_review_comment( + &mut self, + id: usize, + new_comment: String, + cx: &mut Context, + ) -> bool { + for (_, comments) in self.stored_review_comments.iter_mut() { + if let Some(comment) = comments.iter_mut().find(|c| c.id == id) { + comment.comment = new_comment; + comment.is_editing = false; + cx.emit(EditorEvent::ReviewCommentsChanged { + total_count: self.total_review_comment_count(), + }); + cx.notify(); + return true; + } + } + false + } + + /// Sets a comment's editing state. + pub(super) fn set_comment_editing( + &mut self, + id: usize, + is_editing: bool, + cx: &mut Context, + ) { + for (_, comments) in self.stored_review_comments.iter_mut() { + if let Some(comment) = comments.iter_mut().find(|c| c.id == id) { + comment.is_editing = is_editing; + cx.notify(); + return; + } + } + } + + /// Removes review comments whose anchors are no longer valid or whose + /// associated diff hunks no longer exist. + /// + /// This should be called when the buffer changes to prevent orphaned comments + /// from accumulating. + pub(super) fn cleanup_orphaned_review_comments(&mut self, cx: &mut Context) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let original_count = self.total_review_comment_count(); + + // Remove comments with invalid hunk anchors + self.stored_review_comments + .retain(|(hunk_key, _)| hunk_key.hunk_start_anchor.is_valid(&snapshot)); + + // Also clean up individual comments with invalid anchor ranges + for (_, comments) in &mut self.stored_review_comments { + comments.retain(|comment| { + comment.range.start.is_valid(&snapshot) && comment.range.end.is_valid(&snapshot) + }); + } + + // Remove empty hunk entries + self.stored_review_comments + .retain(|(_, comments)| !comments.is_empty()); + + let new_count = self.total_review_comment_count(); + if new_count != original_count { + cx.emit(EditorEvent::ReviewCommentsChanged { + total_count: new_count, + }); + cx.notify(); + } + } + + /// Toggles the expanded state of the comments section in the overlay. + pub(super) fn toggle_review_comments_expanded( &mut self, - render_diff_hunk_controls: RenderDiffHunkControlsFn, + _: &ToggleReviewCommentsExpanded, + window: &mut Window, cx: &mut Context, ) { - self.render_diff_hunk_controls = render_diff_hunk_controls; - cx.notify(); + // Find the overlay that currently has focus, or use the first one + let overlay_info = self.diff_review_overlays.iter_mut().find_map(|overlay| { + if overlay.prompt_editor.focus_handle(cx).is_focused(window) { + overlay.comments_expanded = !overlay.comments_expanded; + Some(overlay.hunk_key.clone()) + } else { + None + } + }); + + // If no focused overlay found, toggle the first one + let hunk_key = overlay_info.or_else(|| { + self.diff_review_overlays.first_mut().map(|overlay| { + overlay.comments_expanded = !overlay.comments_expanded; + overlay.hunk_key.clone() + }) + }); + + if let Some(hunk_key) = hunk_key { + self.refresh_diff_review_overlay_height(&hunk_key, window, cx); + cx.notify(); + } } - pub fn working_directory(&self, cx: &App) -> Option { - if let Some(buffer) = self.buffer().read(cx).as_singleton() { - if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) - && let Some(dir) = file.abs_path(cx).parent() + /// Handles the EditReviewComment action - sets a comment into editing mode. + pub(super) fn edit_review_comment( + &mut self, + action: &EditReviewComment, + window: &mut Window, + cx: &mut Context, + ) { + let comment_id = action.id; + + // Set the comment to editing mode + self.set_comment_editing(comment_id, true, cx); + + // Find the overlay that contains this comment and create an inline editor if needed + // First, find which hunk this comment belongs to + let hunk_key = self + .stored_review_comments + .iter() + .find_map(|(key, comments)| { + if comments.iter().any(|c| c.id == comment_id) { + Some(key.clone()) + } else { + None + } + }); + + let snapshot = self.buffer.read(cx).snapshot(cx); + if let Some(hunk_key) = hunk_key { + if let Some(overlay) = self + .diff_review_overlays + .iter_mut() + .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, &hunk_key, &snapshot)) { - return Some(dir.to_owned()); + if let std::collections::hash_map::Entry::Vacant(entry) = + overlay.inline_edit_editors.entry(comment_id) + { + // Find the comment text + let comment_text = self + .stored_review_comments + .iter() + .flat_map(|(_, comments)| comments) + .find(|c| c.id == comment_id) + .map(|c| c.comment.clone()) + .unwrap_or_default(); + + // Create inline editor + let parent_editor = cx.entity().downgrade(); + let inline_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(&*comment_text, window, cx); + // Select all text for easy replacement + editor.select_all(&crate::actions::SelectAll, window, cx); + editor + }); + + // Register the Newline action to confirm the edit + let subscription = inline_editor.update(cx, |inline_editor, _cx| { + inline_editor.register_action({ + let parent_editor = parent_editor.clone(); + move |_: &crate::actions::Newline, window, cx| { + if let Some(editor) = parent_editor.upgrade() { + editor.update(cx, |editor, cx| { + editor.confirm_edit_review_comment(comment_id, window, cx); + }); + } + } + }) + }); + + // Store the subscription to keep the action handler alive + overlay + .inline_edit_subscriptions + .insert(comment_id, subscription); + + // Focus the inline editor + let focus_handle = inline_editor.focus_handle(cx); + window.focus(&focus_handle, cx); + + entry.insert(inline_editor); + } } } - None + cx.notify(); } - pub fn target_file_abs_path(&self, cx: &mut Context) -> Option { - self.active_buffer(cx).and_then(|buffer| { - let buffer = buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let project = self.project()?.read(cx); - project.absolute_path(&project_path, cx) - } else { - buffer - .file() - .and_then(|file| file.as_local().map(|file| file.abs_path(cx))) - } - }) - } + /// Confirms an inline edit of a review comment. + pub(super) fn confirm_edit_review_comment( + &mut self, + comment_id: usize, + _window: &mut Window, + cx: &mut Context, + ) { + // Get the new text from the inline editor + // Find the overlay containing this comment's inline editor + let snapshot = self.buffer.read(cx).snapshot(cx); + let hunk_key = self + .stored_review_comments + .iter() + .find_map(|(key, comments)| { + if comments.iter().any(|c| c.id == comment_id) { + Some(key.clone()) + } else { + None + } + }); - /// Returns the project path for the editor's buffer, if any buffer is - /// opened in the editor. - pub fn project_path(&self, cx: &App) -> Option { - if let Some(buffer) = self.buffer.read(cx).as_singleton() { - buffer.read(cx).project_path(cx) - } else { - None + let new_text = hunk_key + .as_ref() + .and_then(|hunk_key| { + self.diff_review_overlays + .iter() + .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, hunk_key, &snapshot)) + }) + .as_ref() + .and_then(|overlay| overlay.inline_edit_editors.get(&comment_id)) + .map(|editor| editor.read(cx).text(cx).trim().to_string()); + + if let Some(new_text) = new_text { + if !new_text.is_empty() { + self.update_review_comment(comment_id, new_text, cx); + } } - } - pub fn git_blame_inline_enabled(&self) -> bool { - self.git_blame_inline_enabled - } + // Remove the inline editor and its subscription + if let Some(hunk_key) = hunk_key { + if let Some(overlay) = self + .diff_review_overlays + .iter_mut() + .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, &hunk_key, &snapshot)) + { + overlay.inline_edit_editors.remove(&comment_id); + overlay.inline_edit_subscriptions.remove(&comment_id); + } + } - pub fn selection_menu_enabled(&self, cx: &App) -> bool { - self.show_selection_menu - .unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu) + // Clear editing state + self.set_comment_editing(comment_id, false, cx); } - pub fn toggle_selection_menu( + /// Cancels an inline edit of a review comment. + pub(super) fn cancel_edit_review_comment( &mut self, - _: &ToggleSelectionMenu, - _: &mut Window, + comment_id: usize, + _window: &mut Window, cx: &mut Context, ) { - self.show_selection_menu = self - .show_selection_menu - .map(|show_selections_menu| !show_selections_menu) - .or_else(|| Some(!EditorSettings::get_global(cx).toolbar.selections_menu)); + // Find which hunk this comment belongs to + let hunk_key = self + .stored_review_comments + .iter() + .find_map(|(key, comments)| { + if comments.iter().any(|c| c.id == comment_id) { + Some(key.clone()) + } else { + None + } + }); - cx.notify(); - } + // Remove the inline editor and its subscription + if let Some(hunk_key) = hunk_key { + let snapshot = self.buffer.read(cx).snapshot(cx); + if let Some(overlay) = self + .diff_review_overlays + .iter_mut() + .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, &hunk_key, &snapshot)) + { + overlay.inline_edit_editors.remove(&comment_id); + overlay.inline_edit_subscriptions.remove(&comment_id); + } + } - pub fn blame(&self) -> Option<&Entity> { - self.blame.as_ref() + // Clear editing state + self.set_comment_editing(comment_id, false, cx); } - pub fn show_git_blame_gutter(&self) -> bool { - self.show_git_blame_gutter + /// Action handler for ConfirmEditReviewComment. + pub(super) fn confirm_edit_review_comment_action( + &mut self, + action: &ConfirmEditReviewComment, + window: &mut Window, + cx: &mut Context, + ) { + self.confirm_edit_review_comment(action.id, window, cx); } - pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { - let ranges: Vec<_> = self - .selections - .disjoint_anchors() - .iter() - .map(|s| s.range()) - .collect(); - self.buffer - .update(cx, |buffer, cx| buffer.expand_diff_hunks(ranges, cx)) + /// Action handler for CancelEditReviewComment. + pub(super) fn cancel_edit_review_comment_action( + &mut self, + action: &CancelEditReviewComment, + window: &mut Window, + cx: &mut Context, + ) { + self.cancel_edit_review_comment(action.id, window, cx); } - pub fn copy_file_name_without_extension( + /// Handles the DeleteReviewComment action - removes a comment. + pub(super) fn delete_review_comment( &mut self, - _: &CopyFileNameWithoutExtension, - _: &mut Window, + action: &DeleteReviewComment, + window: &mut Window, cx: &mut Context, ) { - if let Some(file_stem) = self.active_buffer(cx).and_then(|buffer| { - let file = buffer.read(cx).file()?; - file.path().file_stem() - }) { - cx.write_to_clipboard(ClipboardItem::new_string(file_stem.to_string())); - } - } + // Get the hunk key before removing the comment + // Find the hunk key from the comment itself + let comment_id = action.id; + let hunk_key = self + .stored_review_comments + .iter() + .find_map(|(key, comments)| { + if comments.iter().any(|c| c.id == comment_id) { + Some(key.clone()) + } else { + None + } + }); - pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { - if let Some(file_name) = self.active_buffer(cx).and_then(|buffer| { - let file = buffer.read(cx).file()?; - Some(file.file_name(cx)) - }) { - cx.write_to_clipboard(ClipboardItem::new_string(file_name.to_string())); + // Also get it from the overlay for refresh purposes + let overlay_hunk_key = self + .diff_review_overlays + .first() + .map(|o| o.hunk_key.clone()); + + self.remove_review_comment(action.id, cx); + + // Refresh the overlay height after removing a comment + if let Some(hunk_key) = hunk_key.or(overlay_hunk_key) { + self.refresh_diff_review_overlay_height(&hunk_key, window, cx); } } - pub fn toggle_git_blame( + pub(super) fn copy_permalink_to_line( &mut self, - _: &::git::Blame, + _: &CopyPermalinkToLine, window: &mut Window, cx: &mut Context, ) { - self.show_git_blame_gutter = !self.show_git_blame_gutter; - - if self.show_git_blame_gutter && !self.has_blame_entries(cx) { - self.start_git_blame(true, window, cx); - } + let permalink_task = self.get_permalink_to_line(cx); + let workspace = self.workspace(); - cx.notify(); + cx.spawn_in(window, async move |_, cx| match permalink_task.await { + Ok(permalink) => { + cx.update(|_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string())); + }) + .ok(); + } + Err(err) => { + let message = format!("Failed to copy permalink: {err}"); + + anyhow::Result::<()>::Err(err).log_err(); + + if let Some(workspace) = workspace { + workspace + .update_in(cx, |workspace, _, cx| { + struct CopyPermalinkToLine; + + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + message, + ), + cx, + ) + }) + .ok(); + } + } + }) + .detach(); } - pub fn toggle_git_blame_inline( + pub(super) fn open_permalink_to_line( &mut self, - _: &ToggleGitBlameInline, + _: &OpenPermalinkToLine, window: &mut Window, cx: &mut Context, ) { - self.toggle_git_blame_inline_internal(true, window, cx); - cx.notify(); + let permalink_task = self.get_permalink_to_line(cx); + let workspace = self.workspace(); + + cx.spawn_in(window, async move |_, cx| match permalink_task.await { + Ok(permalink) => { + cx.update(|_, cx| { + cx.open_url(permalink.as_ref()); + }) + .ok(); + } + Err(err) => { + let message = format!("Failed to open permalink: {err}"); + + anyhow::Result::<()>::Err(err).log_err(); + + if let Some(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + struct OpenPermalinkToLine; + + workspace.show_toast( + Toast::new(NotificationId::unique::(), message), + cx, + ) + }); + } + } + }) + .detach(); } pub(super) fn toggle_staged_selected_diff_hunks( @@ -203,36 +1377,6 @@ impl Editor { self.do_stage_or_unstage_and_next(false, window, cx); } - pub(super) fn stage_or_unstage_diff_hunks( - &mut self, - stage: bool, - ranges: Vec>, - cx: &mut Context, - ) { - if self.delegate_stage_and_restore { - let snapshot = self.buffer.read(cx).snapshot(cx); - let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect(); - if !hunks.is_empty() { - cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks }); - } - return; - } - let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |this, cx| { - let snapshot = this.buffer.read(cx).snapshot(cx); - let chunk_by = this - .diff_hunks_in_ranges(&ranges, &snapshot) - .chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - this.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } - }) - }) - .detach_and_log_err(cx); - } - pub(super) fn do_stage_or_unstage( &self, stage: bool, @@ -295,17 +1439,6 @@ impl Editor { .has_expanded_diff_hunks_in_ranges(&ranges, cx) } - pub(super) fn toggle_diff_hunks_in_ranges( - &mut self, - ranges: Vec>, - cx: &mut Context, - ) { - self.buffer.update(cx, |buffer, cx| { - let expand = !buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx); - buffer.expand_or_collapse_diff_hunks(ranges, expand, cx); - }) - } - pub(super) fn toggle_single_diff_hunk(&mut self, range: Range, cx: &mut Context) { self.buffer.update(cx, |buffer, cx| { buffer.toggle_single_diff_hunk(range, cx); @@ -393,130 +1526,220 @@ impl Editor { } } - pub(super) fn target_file<'a>(&self, cx: &'a App) -> Option<&'a dyn language::LocalFile> { - self.active_buffer(cx)? - .read(cx) - .file() - .and_then(|f| f.as_local()) + pub(super) fn open_git_blame_commit( + &mut self, + _: &OpenGitBlameCommit, + window: &mut Window, + cx: &mut Context, + ) { + self.open_git_blame_commit_internal(window, cx); } - pub(super) fn reveal_in_finder( + pub(super) fn toggle_git_blame_inline_internal( &mut self, - _: &RevealInFileManager, - _window: &mut Window, + user_triggered: bool, + window: &mut Window, cx: &mut Context, ) { - if let Some(path) = self.target_file_abs_path(cx) { - if let Some(project) = self.project() { - project.update(cx, |project, cx| project.reveal_path(&path, cx)); - } else { - cx.reveal_path(&path); - } + if self.git_blame_inline_enabled { + self.git_blame_inline_enabled = false; + self.show_git_blame_inline = false; + self.show_git_blame_inline_delay_task.take(); + } else { + self.git_blame_inline_enabled = true; + self.start_git_blame_inline(user_triggered, window, cx); } + + cx.notify(); } - pub(super) fn copy_path( + pub(super) fn start_git_blame_inline( &mut self, - _: &zed_actions::workspace::CopyPath, - _window: &mut Window, + user_triggered: bool, + window: &mut Window, cx: &mut Context, ) { - if let Some(path) = self.target_file_abs_path(cx) - && let Some(path) = path.to_str() + self.start_git_blame(user_triggered, window, cx); + + if ProjectSettings::get_global(cx) + .git + .inline_blame_delay() + .is_some() { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + self.start_inline_blame_timer(window, cx); } else { - cx.propagate(); + self.show_git_blame_inline = true + } + } + + pub(super) fn render_git_blame_gutter(&self, cx: &App) -> bool { + !self.mode().is_minimap() && self.show_git_blame_gutter && self.has_blame_entries(cx) + } + + pub(super) fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool { + self.show_git_blame_inline + && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some()) + && !self.newest_selection_head_on_empty_line(cx) + && self.has_blame_entries(cx) + } + + pub(super) fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() { + self.show_git_blame_inline = false; + + self.show_git_blame_inline_delay_task = + Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(delay).await; + + this.update(cx, |this, cx| { + this.show_git_blame_inline = true; + cx.notify(); + }) + .log_err(); + })); } } - pub(super) fn copy_relative_path( + pub(super) fn show_blame_popover( &mut self, - _: &zed_actions::workspace::CopyRelativePath, - _window: &mut Window, + buffer: BufferId, + blame_entry: &BlameEntry, + position: gpui::Point, + ignore_timeout: bool, cx: &mut Context, ) { - if let Some(path) = self.active_buffer(cx).and_then(|buffer| { - let project = self.project()?.read(cx); - let path = buffer.read(cx).file()?.path(); - let path = path.display(project.path_style(cx)); - Some(path) - }) { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + if let Some(state) = &mut self.inline_blame_popover { + state.hide_task.take(); } else { - cx.propagate(); + let blame_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; + let blame_entry = blame_entry.clone(); + let show_task = cx.spawn(async move |editor, cx| { + if !ignore_timeout { + cx.background_executor() + .timer(std::time::Duration::from_millis(blame_popover_delay)) + .await; + } + editor + .update(cx, |editor, cx| { + editor.inline_blame_popover_show_task.take(); + let Some(blame) = editor.blame.as_ref() else { + return; + }; + let blame = blame.read(cx); + let details = blame.details_for_entry(buffer, &blame_entry); + let markdown = cx.new(|cx| { + Markdown::new( + details + .as_ref() + .map(|message| message.message.clone()) + .unwrap_or_default(), + None, + None, + cx, + ) + }); + editor.inline_blame_popover = Some(InlineBlamePopover { + position, + hide_task: None, + popover_bounds: None, + popover_state: InlineBlamePopoverState { + scroll_handle: ScrollHandle::new(), + commit_message: details, + markdown, + }, + keyboard_grace: ignore_timeout, + }); + cx.notify(); + }) + .ok(); + }); + self.inline_blame_popover_show_task = Some(show_task); } } - pub(super) fn go_to_active_debug_line( + pub(super) fn go_to_prev_hunk( &mut self, + _: &GoToPreviousHunk, window: &mut Window, cx: &mut Context, - ) -> bool { - maybe!({ - let breakpoint_store = self.breakpoint_store.as_ref()?; - - let (active_stack_frame, debug_line_pane_id) = { - let store = breakpoint_store.read(cx); - let active_stack_frame = store.active_position().cloned(); - let debug_line_pane_id = store.active_debug_line_pane_id(); - (active_stack_frame, debug_line_pane_id) - }; - - let Some(active_stack_frame) = active_stack_frame else { - self.clear_row_highlights::(); - return None; - }; - - if let Some(debug_line_pane_id) = debug_line_pane_id { - if let Some(workspace) = self - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade()) - { - let editor_pane_id = workspace - .read(cx) - .pane_for_item_id(cx.entity_id()) - .map(|pane| pane.entity_id()); - - if editor_pane_id.is_some_and(|id| id != debug_line_pane_id) { - self.clear_row_highlights::(); - return None; - } - } - } - - let position = active_stack_frame.position; - - let snapshot = self.buffer.read(cx).snapshot(cx); - let multibuffer_anchor = snapshot.anchor_in_excerpt(position)?; - - self.clear_row_highlights::(); - - self.go_to_line::( - multibuffer_anchor, - Some(cx.theme().colors().editor_debugger_active_line_background), - window, - cx, - ); + ) { + let snapshot = self.snapshot(window, cx); + let selection = self.selections.newest::(&snapshot.display_snapshot); + self.go_to_hunk_before_or_after_position( + &snapshot, + selection.head(), + Direction::Prev, + true, + window, + cx, + ); + } - cx.notify(); + /// Calculates the appropriate block height for the diff review overlay. + /// Height is in lines: 2 for input row, 1 for header when comments exist, + /// and 2 lines per comment when expanded. + pub(super) fn calculate_overlay_height( + &self, + hunk_key: &DiffHunkKey, + comments_expanded: bool, + snapshot: &MultiBufferSnapshot, + ) -> u32 { + let comment_count = self.hunk_comment_count(hunk_key, snapshot); + let base_height: u32 = 2; // Input row with avatar and buttons + + if comment_count == 0 { + base_height + } else if comments_expanded { + // Header (1 line) + 2 lines per comment + base_height + 1 + (comment_count as u32 * 2) + } else { + // Just header when collapsed + base_height + 1 + } + } - Some(()) + fn stage_or_unstage_diff_hunks( + &mut self, + stage: bool, + ranges: Vec>, + cx: &mut Context, + ) { + if self.delegate_stage_and_restore { + let snapshot = self.buffer.read(cx).snapshot(cx); + let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + if !hunks.is_empty() { + cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks }); + } + return; + } + let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); + cx.spawn(async move |this, cx| { + task.await?; + this.update(cx, |this, cx| { + let snapshot = this.buffer.read(cx).snapshot(cx); + let chunk_by = this + .diff_hunks_in_ranges(&ranges, &snapshot) + .chunk_by(|hunk| hunk.buffer_id); + for (buffer_id, hunks) in &chunk_by { + this.do_stage_or_unstage(stage, buffer_id, hunks, cx); + } + }) }) - .is_some() + .detach_and_log_err(cx); } - pub(super) fn open_git_blame_commit( + fn toggle_diff_hunks_in_ranges( &mut self, - _: &OpenGitBlameCommit, - window: &mut Window, - cx: &mut Context, + ranges: Vec>, + cx: &mut Context, ) { - self.open_git_blame_commit_internal(window, cx); + self.buffer.update(cx, |buffer, cx| { + let expand = !buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx); + buffer.expand_or_collapse_diff_hunks(ranges, expand, cx); + }) } - pub(super) fn start_git_blame( + fn start_git_blame( &mut self, user_triggered: bool, window: &mut Window, @@ -540,52 +1763,29 @@ impl Editor { } } - pub(super) fn toggle_git_blame_inline_internal( - &mut self, - user_triggered: bool, - window: &mut Window, - cx: &mut Context, - ) { - if self.git_blame_inline_enabled { - self.git_blame_inline_enabled = false; - self.show_git_blame_inline = false; - self.show_git_blame_inline_delay_task.take(); - } else { - self.git_blame_inline_enabled = true; - self.start_git_blame_inline(user_triggered, window, cx); - } - - cx.notify(); - } - - pub(super) fn start_git_blame_inline( + fn restore_hunks_in_ranges( &mut self, - user_triggered: bool, + ranges: Vec>, window: &mut Window, - cx: &mut Context, + cx: &mut Context, ) { - self.start_git_blame(user_triggered, window, cx); - - if ProjectSettings::get_global(cx) - .git - .inline_blame_delay() - .is_some() - { - self.start_inline_blame_timer(window, cx); - } else { - self.show_git_blame_inline = true + if self.delegate_stage_and_restore { + let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); + if !hunks.is_empty() { + cx.emit(EditorEvent::RestoreRequested { hunks }); + } + return; } - } - - pub(super) fn render_git_blame_gutter(&self, cx: &App) -> bool { - !self.mode().is_minimap() && self.show_git_blame_gutter && self.has_blame_entries(cx) - } - - pub(super) fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool { - self.show_git_blame_inline - && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some()) - && !self.newest_selection_head_on_empty_line(cx) - && self.has_blame_entries(cx) + let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); + self.transact(window, cx, |editor, window, cx| { + editor.restore_diff_hunks(hunks, cx); + let selections = editor + .selections + .all::(&editor.display_snapshot(cx)); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select(selections); + }); + }); } fn has_stageable_diff_hunks_in_ranges( @@ -597,6 +1797,40 @@ impl Editor { hunks.any(|hunk| hunk.status().has_secondary_hunk()) } + fn prepare_restore_change( + &self, + revert_changes: &mut HashMap, Rope)>>, + hunk: &MultiBufferDiffHunk, + cx: &mut App, + ) -> Option<()> { + if hunk.is_created_file() { + return None; + } + let multi_buffer = self.buffer.read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let diff_snapshot = multi_buffer_snapshot.diff_for_buffer_id(hunk.buffer_id)?; + let original_text = diff_snapshot + .base_text() + .as_rope() + .slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0); + let buffer = multi_buffer.buffer(hunk.buffer_id)?; + let buffer = buffer.read(cx); + let buffer_snapshot = buffer.snapshot(); + let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); + if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { + probe + .0 + .start + .cmp(&hunk.buffer_range.start, &buffer_snapshot) + .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot)) + }) { + buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text)); + Some(()) + } else { + None + } + } + fn save_buffers_for_ranges_if_needed( &mut self, ranges: &[Range], @@ -707,4 +1941,862 @@ impl Editor { snapshot.line_len(buffer_row) == 0 } + fn hunk_after_position( + &mut self, + snapshot: &EditorSnapshot, + position: Point, + wrap_around: bool, + ) -> Option { + let result = snapshot + .buffer_snapshot() + .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point()) + .find(|hunk| hunk.row_range.start.0 > position.row); + + if wrap_around { + result.or_else(|| { + snapshot + .buffer_snapshot() + .diff_hunks_in_range(Point::zero()..position) + .find(|hunk| hunk.row_range.end.0 < position.row) + }) + } else { + result + } + } + + fn hunk_before_position( + &mut self, + snapshot: &EditorSnapshot, + position: Point, + wrap_around: bool, + ) -> Option { + let result = snapshot.buffer_snapshot().diff_hunk_before(position); + + if wrap_around { + result.or_else(|| snapshot.buffer_snapshot().diff_hunk_before(Point::MAX)) + } else { + result + } + } + + /// Dismisses overlays that have no comments stored for their hunks. + /// Keeps overlays that have at least one comment. + fn dismiss_overlays_without_comments(&mut self, cx: &mut Context) { + let snapshot = self.buffer.read(cx).snapshot(cx); + + // First, compute which overlays have comments (to avoid borrow issues with retain) + let overlays_with_comments: Vec = self + .diff_review_overlays + .iter() + .map(|overlay| self.hunk_comment_count(&overlay.hunk_key, &snapshot) > 0) + .collect(); + + // Now collect block IDs to remove and retain overlays + let mut block_ids_to_remove = HashSet::default(); + let mut index = 0; + self.diff_review_overlays.retain(|overlay| { + let has_comments = overlays_with_comments[index]; + index += 1; + if !has_comments { + block_ids_to_remove.insert(overlay.block_id); + } + has_comments + }); + + if !block_ids_to_remove.is_empty() { + self.remove_blocks(block_ids_to_remove, None, cx); + cx.notify(); + } + } + + /// Refreshes the diff review overlay block to update its height and render function. + /// Uses resize_blocks and replace_blocks to avoid visual flicker from remove+insert. + fn refresh_diff_review_overlay_height( + &mut self, + hunk_key: &DiffHunkKey, + _window: &mut Window, + cx: &mut Context, + ) { + // Extract all needed data from overlay first to avoid borrow conflicts + let snapshot = self.buffer.read(cx).snapshot(cx); + let (comments_expanded, block_id, prompt_editor) = { + let Some(overlay) = self + .diff_review_overlays + .iter() + .find(|overlay| Self::hunk_keys_match(&overlay.hunk_key, hunk_key, &snapshot)) + else { + return; + }; + + ( + overlay.comments_expanded, + overlay.block_id, + overlay.prompt_editor.clone(), + ) + }; + + // Calculate new height + let snapshot = self.buffer.read(cx).snapshot(cx); + let new_height = self.calculate_overlay_height(hunk_key, comments_expanded, &snapshot); + + // Update the block height using resize_blocks (avoids flicker) + let mut heights = HashMap::default(); + heights.insert(block_id, new_height); + self.resize_blocks(heights, None, cx); + + // Update the render function using replace_blocks (avoids flicker) + let hunk_key_for_render = hunk_key.clone(); + let editor_handle = cx.entity().downgrade(); + let render: Arc AnyElement + Send + Sync> = + Arc::new(move |cx| { + Self::render_diff_review_overlay( + &prompt_editor, + &hunk_key_for_render, + &editor_handle, + cx, + ) + }); + + let mut renderers = HashMap::default(); + renderers.insert(block_id, render); + self.replace_blocks(renderers, None, cx); + } + + /// Compares two DiffHunkKeys for equality by resolving their anchors. + fn hunk_keys_match(a: &DiffHunkKey, b: &DiffHunkKey, snapshot: &MultiBufferSnapshot) -> bool { + a.file_path == b.file_path + && a.hunk_start_anchor.to_point(snapshot) == b.hunk_start_anchor.to_point(snapshot) + } + + fn render_diff_review_overlay( + prompt_editor: &Entity, + hunk_key: &DiffHunkKey, + editor_handle: &WeakEntity, + cx: &mut BlockContext, + ) -> AnyElement { + fn format_line_ranges(ranges: &[(u32, u32)]) -> Option { + if ranges.is_empty() { + return None; + } + let formatted: Vec = ranges + .iter() + .map(|(start, end)| { + let start_line = start + 1; + let end_line = end + 1; + if start_line == end_line { + format!("Line {start_line}") + } else { + format!("Lines {start_line}-{end_line}") + } + }) + .collect(); + // Don't show label for single line in single excerpt + if ranges.len() == 1 && ranges[0].0 == ranges[0].1 { + return None; + } + Some(formatted.join(" ⋯ ")) + } + + let theme = cx.theme(); + let colors = theme.colors(); + + let (comments, comments_expanded, inline_editors, user_avatar_uri, line_ranges) = + editor_handle + .upgrade() + .map(|editor| { + let editor = editor.read(cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let comments = editor.comments_for_hunk(hunk_key, &snapshot).to_vec(); + let (expanded, editors, avatar_uri, line_ranges) = editor + .diff_review_overlays + .iter() + .find(|overlay| { + Editor::hunk_keys_match(&overlay.hunk_key, hunk_key, &snapshot) + }) + .map(|o| { + let start_point = o.anchor_range.start.to_point(&snapshot); + let end_point = o.anchor_range.end.to_point(&snapshot); + // Get line ranges per excerpt to detect discontinuities + let buffer_ranges = + snapshot.range_to_buffer_ranges(start_point..end_point); + let ranges: Vec<(u32, u32)> = buffer_ranges + .iter() + .map(|(buffer_snapshot, range, _)| { + let start = buffer_snapshot.offset_to_point(range.start.0).row; + let end = buffer_snapshot.offset_to_point(range.end.0).row; + (start, end) + }) + .collect(); + ( + o.comments_expanded, + o.inline_edit_editors.clone(), + o.user_avatar_uri.clone(), + if ranges.is_empty() { + None + } else { + Some(ranges) + }, + ) + }) + .unwrap_or((true, HashMap::default(), None, None)); + (comments, expanded, editors, avatar_uri, line_ranges) + }) + .unwrap_or((Vec::new(), true, HashMap::default(), None, None)); + + let comment_count = comments.len(); + let avatar_size = px(20.); + let action_icon_size = IconSize::XSmall; + + v_flex() + .w_full() + .bg(colors.editor_background) + .border_b_1() + .border_color(colors.border) + .px_2() + .pb_2() + .gap_2() + // Line range indicator (only shown for multi-line selections or multiple excerpts) + .when_some(line_ranges, |el, ranges| { + let label = format_line_ranges(&ranges); + if let Some(label) = label { + el.child( + h_flex() + .w_full() + .px_2() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)), + ) + } else { + el + } + }) + // Top row: editable input with user's avatar + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .px_2() + .py_1p5() + .rounded_md() + .bg(colors.surface_background) + .child( + div() + .size(avatar_size) + .flex_shrink_0() + .rounded_full() + .overflow_hidden() + .child(if let Some(ref avatar_uri) = user_avatar_uri { + Avatar::new(avatar_uri.clone()) + .size(avatar_size) + .into_any_element() + } else { + Icon::new(IconName::Person) + .size(IconSize::Small) + .color(ui::Color::Muted) + .into_any_element() + }), + ) + .child( + div() + .flex_1() + .border_1() + .border_color(colors.border) + .rounded_md() + .bg(colors.editor_background) + .px_2() + .py_1() + .child(prompt_editor.clone()), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .child( + IconButton::new("diff-review-close", IconName::Close) + .icon_color(ui::Color::Muted) + .icon_size(action_icon_size) + .tooltip(Tooltip::text("Close")) + .on_click(|_, window, cx| { + window + .dispatch_action(Box::new(crate::actions::Cancel), cx); + }), + ) + .child( + IconButton::new("diff-review-add", IconName::Return) + .icon_color(ui::Color::Muted) + .icon_size(action_icon_size) + .tooltip(Tooltip::text("Add comment")) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(crate::actions::SubmitDiffReviewComment), + cx, + ); + }), + ), + ), + ) + // Expandable comments section (only shown when there are comments) + .when(comment_count > 0, |el| { + el.child(Self::render_comments_section( + comments, + comments_expanded, + inline_editors, + user_avatar_uri, + avatar_size, + action_icon_size, + colors, + )) + }) + .into_any_element() + } + + fn render_comments_section( + comments: Vec, + expanded: bool, + inline_editors: HashMap>, + user_avatar_uri: Option, + avatar_size: Pixels, + action_icon_size: IconSize, + colors: &theme::ThemeColors, + ) -> impl IntoElement { + let comment_count = comments.len(); + + v_flex() + .w_full() + .gap_1() + // Header with expand/collapse toggle + .child( + h_flex() + .id("review-comments-header") + .w_full() + .items_center() + .gap_1() + .px_2() + .py_1() + .cursor_pointer() + .rounded_md() + .hover(|style| style.bg(colors.ghost_element_hover)) + .on_click(|_, window: &mut Window, cx| { + window.dispatch_action( + Box::new(crate::actions::ToggleReviewCommentsExpanded), + cx, + ); + }) + .child( + Icon::new(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .size(IconSize::Small) + .color(ui::Color::Muted), + ) + .child( + Label::new(format!( + "{} Comment{}", + comment_count, + if comment_count == 1 { "" } else { "s" } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + // Comments list (when expanded) + .when(expanded, |el| { + el.children(comments.into_iter().map(|comment| { + let inline_editor = inline_editors.get(&comment.id).cloned(); + Self::render_comment_row( + comment, + inline_editor, + user_avatar_uri.clone(), + avatar_size, + action_icon_size, + colors, + ) + })) + }) + } + + fn render_comment_row( + comment: StoredReviewComment, + inline_editor: Option>, + user_avatar_uri: Option, + avatar_size: Pixels, + action_icon_size: IconSize, + colors: &theme::ThemeColors, + ) -> impl IntoElement { + let comment_id = comment.id; + let is_editing = inline_editor.is_some(); + + h_flex() + .w_full() + .items_center() + .gap_2() + .px_2() + .py_1p5() + .rounded_md() + .bg(colors.surface_background) + .child( + div() + .size(avatar_size) + .flex_shrink_0() + .rounded_full() + .overflow_hidden() + .child(if let Some(ref avatar_uri) = user_avatar_uri { + Avatar::new(avatar_uri.clone()) + .size(avatar_size) + .into_any_element() + } else { + Icon::new(IconName::Person) + .size(IconSize::Small) + .color(ui::Color::Muted) + .into_any_element() + }), + ) + .child(if let Some(editor) = inline_editor { + // Inline edit mode: show an editable text field + div() + .flex_1() + .border_1() + .border_color(colors.border) + .rounded_md() + .bg(colors.editor_background) + .px_2() + .py_1() + .child(editor) + .into_any_element() + } else { + // Display mode: show the comment text + div() + .flex_1() + .text_sm() + .text_color(colors.text) + .child(comment.comment) + .into_any_element() + }) + .child(if is_editing { + // Editing mode: show close and confirm buttons + h_flex() + .gap_1() + .child( + IconButton::new( + format!("diff-review-cancel-edit-{comment_id}"), + IconName::Close, + ) + .icon_color(ui::Color::Muted) + .icon_size(action_icon_size) + .tooltip(Tooltip::text("Cancel")) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(crate::actions::CancelEditReviewComment { + id: comment_id, + }), + cx, + ); + }), + ) + .child( + IconButton::new( + format!("diff-review-confirm-edit-{comment_id}"), + IconName::Return, + ) + .icon_color(ui::Color::Muted) + .icon_size(action_icon_size) + .tooltip(Tooltip::text("Confirm")) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(crate::actions::ConfirmEditReviewComment { + id: comment_id, + }), + cx, + ); + }), + ) + .into_any_element() + } else { + // Display mode: no action buttons for now (edit/delete not yet implemented) + gpui::Empty.into_any_element() + }) + } + + fn get_permalink_to_line(&self, cx: &mut Context) -> Task> { + let buffer_and_selection = maybe!({ + let selection = self.selections.newest::(&self.display_snapshot(cx)); + let selection_range = selection.range(); + + let multi_buffer = self.buffer().read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let buffer_ranges = multi_buffer_snapshot + .range_to_buffer_ranges(selection_range.start..selection_range.end); + + let (buffer_snapshot, range, _) = if selection.reversed { + buffer_ranges.first() + } else { + buffer_ranges.last() + }?; + + let buffer_range = range.to_point(buffer_snapshot); + let buffer = multi_buffer.buffer(buffer_snapshot.remote_id())?; + + let Some(buffer_diff) = multi_buffer.diff_for(buffer_snapshot.remote_id()) else { + return Some((buffer, buffer_range.start.row..buffer_range.end.row)); + }; + + let buffer_diff_snapshot = buffer_diff.read(cx).snapshot(cx); + let start = buffer_diff_snapshot + .buffer_point_to_base_text_point(buffer_range.start, &buffer_snapshot); + let end = buffer_diff_snapshot + .buffer_point_to_base_text_point(buffer_range.end, &buffer_snapshot); + + Some((buffer, start.row..end.row)) + }); + + let Some((buffer, selection)) = buffer_and_selection else { + return Task::ready(Err(anyhow!("failed to determine buffer and selection"))); + }; + + let Some(project) = self.project() else { + return Task::ready(Err(anyhow!("editor does not have project"))); + }; + + project.update(cx, |project, cx| { + project.get_permalink_to_line(&buffer, selection, cx) + }) + } +} + +#[cfg(test)] +impl Editor { + /// Returns the line range for the first diff review overlay, if one is active. + /// Returns (start_row, end_row) as physical line numbers in the underlying file. + pub(super) fn diff_review_line_range(&self, cx: &App) -> Option<(u32, u32)> { + let overlay = self.diff_review_overlays.first()?; + let snapshot = self.buffer.read(cx).snapshot(cx); + let start_point = overlay.anchor_range.start.to_point(&snapshot); + let end_point = overlay.anchor_range.end.to_point(&snapshot); + let start_row = snapshot + .point_to_buffer_point(start_point) + .map(|(_, p)| p.row) + .unwrap_or(start_point.row); + let end_row = snapshot + .point_to_buffer_point(end_point) + .map(|(_, p)| p.row) + .unwrap_or(end_point.row); + Some((start_row, end_row)) + } + + /// Takes all stored comments from all hunks, clearing the storage. + /// Returns a Vec of (hunk_key, comments) pairs. + pub(super) fn take_all_review_comments( + &mut self, + cx: &mut Context, + ) -> Vec<(DiffHunkKey, Vec)> { + // Dismiss all overlays when taking comments (e.g., when sending to agent) + self.dismiss_all_diff_review_overlays(cx); + let comments = std::mem::take(&mut self.stored_review_comments); + // Reset the ID counter since all comments have been taken + self.next_review_comment_id = 0; + cx.emit(EditorEvent::ReviewCommentsChanged { total_count: 0 }); + cx.notify(); + comments + } +} + +impl EditorSnapshot { + pub(super) fn display_diff_hunks_for_rows<'a>( + &'a self, + display_rows: Range, + folded_buffers: &'a HashSet, + ) -> impl 'a + Iterator { + let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(self); + let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(self); + + self.buffer_snapshot() + .diff_hunks_in_range(buffer_start..buffer_end) + .filter_map(|hunk| { + if folded_buffers.contains(&hunk.buffer_id) + || (hunk.row_range.is_empty() && self.buffer.all_diff_hunks_expanded()) + { + return None; + } + + let hunk_start_point = Point::new(hunk.row_range.start.0, 0); + let hunk_end_point = if hunk.row_range.end > hunk.row_range.start { + let last_row = MultiBufferRow(hunk.row_range.end.0 - 1); + let line_len = self.buffer_snapshot().line_len(last_row); + Point::new(last_row.0, line_len) + } else { + Point::new(hunk.row_range.end.0, 0) + }; + + let hunk_display_start = self.point_to_display_point(hunk_start_point, Bias::Left); + let hunk_display_end = self.point_to_display_point(hunk_end_point, Bias::Right); + + let display_hunk = if hunk_display_start.column() != 0 { + DisplayDiffHunk::Folded { + display_row: hunk_display_start.row(), + } + } else { + let mut end_row = hunk_display_end.row(); + if hunk.row_range.end > hunk.row_range.start || hunk_display_end.column() > 0 { + end_row.0 += 1; + } + let is_created_file = hunk.is_created_file(); + let multi_buffer_range = hunk.multi_buffer_range.clone(); + + DisplayDiffHunk::Unfolded { + status: hunk.status(), + diff_base_byte_range: hunk.diff_base_byte_range.start.0 + ..hunk.diff_base_byte_range.end.0, + word_diffs: hunk.word_diffs, + display_row_range: hunk_display_start.row()..end_row, + multi_buffer_range, + is_created_file, + } + }; + + Some(display_hunk) + }) + } + + fn hunks_for_ranges( + &self, + ranges: impl IntoIterator>, + ) -> Vec { + let mut hunks = Vec::new(); + let mut processed_buffer_rows: HashMap>> = + HashMap::default(); + for query_range in ranges { + let query_rows = + MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1); + for hunk in self.buffer_snapshot().diff_hunks_in_range( + Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0), + ) { + // Include deleted hunks that are adjacent to the query range, because + // otherwise they would be missed. + let mut intersects_range = hunk.row_range.overlaps(&query_rows); + if hunk.status().is_deleted() { + intersects_range |= hunk.row_range.start == query_rows.end; + intersects_range |= hunk.row_range.end == query_rows.start; + } + if intersects_range { + if !processed_buffer_rows + .entry(hunk.buffer_id) + .or_default() + .insert(hunk.buffer_range.start..hunk.buffer_range.end) + { + continue; + } + hunks.push(hunk); + } + } + } + + hunks + } +} + +pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) { + cx.set_global(GlobalBlameRenderer(Arc::new(renderer))); +} + +pub(super) fn render_diff_hunk_controls( + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, +) -> AnyElement { + h_flex() + .h(line_height) + .mr_1() + .gap_1() + .px_0p5() + .pb_1() + .border_x_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .rounded_b_lg() + .bg(cx.theme().colors().editor_background) + .gap_1() + .block_mouse_except_scroll() + .shadow_md() + .child(if status.has_secondary_hunk() { + Button::new(("stage", row as u64), "Stage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |_window, cx| { + Tooltip::for_action_in( + "Stage Hunk", + &::git::ToggleStaged, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, _window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + true, + vec![hunk_range.start..hunk_range.start], + cx, + ); + }); + } + }) + } else { + Button::new(("unstage", row as u64), "Unstage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |_window, cx| { + Tooltip::for_action_in( + "Unstage Hunk", + &::git::ToggleStaged, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, _window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + false, + vec![hunk_range.start..hunk_range.start], + cx, + ); + }); + } + }) + }) + .child( + Button::new(("restore", row as u64), "Restore") + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |_window, cx| { + Tooltip::for_action_in("Restore Hunk", &::git::Restore, &focus_handle, cx) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let point = hunk_range.start.to_point(&snapshot.buffer_snapshot()); + editor.restore_hunks_in_ranges(vec![point..point], window, cx); + }); + } + }) + .disabled(is_created_file), + ) + .when( + !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(), + |el| { + el.child( + IconButton::new(("next-hunk", row as u64), IconName::ArrowDown) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + // .disabled(!has_multiple_hunks) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |_window, cx| { + Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let position = + hunk_range.end.to_point(&snapshot.buffer_snapshot()); + editor.go_to_hunk_before_or_after_position( + &snapshot, + position, + Direction::Next, + true, + window, + cx, + ); + editor.expand_selected_diff_hunks(cx); + }); + } + }), + ) + .child( + IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + // .disabled(!has_multiple_hunks) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |_window, cx| { + Tooltip::for_action_in( + "Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let point = + hunk_range.start.to_point(&snapshot.buffer_snapshot()); + editor.go_to_hunk_before_or_after_position( + &snapshot, + point, + Direction::Prev, + true, + window, + cx, + ); + editor.expand_selected_diff_hunks(cx); + }); + } + }), + ) + }, + ) + .into_any_element() +} + +pub(super) fn update_uncommitted_diff_for_buffer( + editor: Entity, + project: &Entity, + buffers: impl IntoIterator>, + buffer: Entity, + cx: &mut App, +) -> Task<()> { + let mut tasks = Vec::new(); + project.update(cx, |project, cx| { + for buffer in buffers { + if project::File::from_dyn(buffer.read(cx).file()).is_some() { + tasks.push(project.open_uncommitted_diff(buffer.clone(), cx)) + } + } + }); + cx.spawn(async move |cx| { + let diffs = future::join_all(tasks).await; + if editor.read_with(cx, |editor, _cx| editor.temporary_diff_override) { + return; + } + + buffer.update(cx, |buffer, cx| { + for diff in diffs.into_iter().flatten() { + buffer.add_diff(diff, cx); + } + }); + }) }