@@ -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<Anchor>,
- bool,
- Pixels,
- &Entity<Editor>,
- &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<usize>,
- display_row_range: Range<DisplayRow>,
- multi_buffer_range: Range<Anchor>,
- status: DiffHunkStatus,
- word_diffs: Vec<Range<MultiBufferOffset>>,
- },
-}
-
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<ParsedCommitMessage>,
- markdown: Entity<Markdown>,
-}
-
-struct InlineBlamePopover {
- position: gpui::Point<Pixels>,
- hide_task: Option<Task<()>>,
- popover_bounds: Option<Bounds<Pixels>>,
- 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<DisplayRow> {
- 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<util::rel_path::RelPath>,
- /// 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<Anchor>,
- /// 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<Anchor>) -> 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<Anchor>,
- /// The block ID for the overlay.
- pub block_id: CustomBlockId,
- /// The editor entity for the review input.
- pub prompt_editor: Entity<Editor>,
- /// 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<usize, Entity<Editor>>,
- /// Subscriptions for inline edit editors' action handlers.
- /// Key: comment ID, Value: Subscription keeping the Newline action handler alive.
- pub inline_edit_subscriptions: HashMap<usize, Subscription>,
- /// The current user's avatar URI for display in comment rows.
- pub user_avatar_uri: Option<SharedUri>,
- /// Subscription to keep the action handler alive.
- _subscription: Subscription,
-}
-
enum CodeActionsForSelection {
None,
Fetching(Shared<Task<Option<ActionFetchReady>>>),
@@ -2913,6 +2784,61 @@ impl Editor {
self.last_bounds.as_ref()
}
+ pub fn working_directory(&self, cx: &App) -> Option<PathBuf> {
+ 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<Self>) -> Option<PathBuf> {
+ 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<ProjectPath> {
+ 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>,
+ ) {
+ 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<Self>) {
- 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<Self>) {
- let snapshot = self.snapshot(window, cx);
- let cursor = self
- .selections
- .newest::<Point>(&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<Pixels>,
- ignore_timeout: bool,
- cx: &mut Context<Self>,
- ) {
- 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<Self>) -> 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<Self>) -> 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<Self>,
- ) {
- 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::<MultiBufferOffset>(&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::<Vec<_>>();
-
- self.restore_hunks_in_ranges(ranges, window, cx);
- }
-
- pub fn git_restore(&mut self, _: &Restore, window: &mut Window, cx: &mut Context<Self>) {
- 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<Self>,
- ) {
- 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::<Point>(&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<Range<Point>>,
- window: &mut Window,
- cx: &mut Context<Editor>,
- ) {
- 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::<MultiBufferOffset>(&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<MultiBufferDiffHunk>, 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::<Vec<_>>();
- 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<FileStatus> {
- 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<BufferId, Vec<(Range<text::Anchor>, 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<Self>) -> bool {
+ maybe!({
+ let breakpoint_store = self.breakpoint_store.as_ref()?;
- pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context<Self>) {
- 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::<ActiveDebugLine>();
+ 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::<ActiveDebugLine>();
+ 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::<ActiveDebugLine>();
+
+ self.go_to_line::<ActiveDebugLine>(
+ 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>) {
+ self.manipulate_immutable_lines(window, cx, |lines| lines.reverse())
+ }
pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
- let snapshot = self.snapshot(window, cx);
- let selection = self.selections.newest::<Point>(&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<Editor>,
- ) {
- 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<MultiBufferDiffHunk> {
- 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<Self>,
- ) {
- let snapshot = self.snapshot(window, cx);
- let selection = self.selections.newest::<Point>(&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<MultiBufferRow> {
- 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>,
- ) {
- 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>,
- ) {
- 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<Self>,
- ) {
- 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<Self>) {
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<Self>) {
+ 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<Self>,
- ) -> 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<Self>,
) {
- 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<Self>,
) {
- 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<Self>) {
- 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<Self>,
+ ) {
+ 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>) {
- self.diff_review_drag_state = None;
- cx.notify();
+ pub fn copy_file_name_without_extension(
+ &mut self,
+ _: &CopyFileNameWithoutExtension,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Self>) {
+ 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<DisplayRow>,
- window: &mut Window,
+ _: &CopyFileLocation,
+ _: &mut Window,
cx: &mut Context<Self>,
) {
- let Range { start, end } = display_range.sorted();
+ let selection = self.selections.newest::<Point>(&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<dyn language::File>| 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>,
+ ) {
+ self.insert_uuid(UuidVersion::V4, window, cx);
+ }
- cx.notify();
+ pub fn insert_uuid_v7(
+ &mut self,
+ _: &InsertUuidV7,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.insert_uuid(UuidVersion::V7, window, cx);
}
- /// Dismisses all diff review overlays.
- pub fn dismiss_all_diff_review_overlays(&mut self, cx: &mut Context<Self>) {
- 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<Self>) {
- 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<bool> = 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<Self>,
- ) {
- // 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<dyn Fn(&mut BlockContext) -> 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>,
- ) {
- 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<Self>) {
- // 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<Editor>> {
- 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<Self>) {
- 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<Anchor>,
- cx: &mut Context<Self>,
- ) -> 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<Self>) -> 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<Self>,
- ) -> 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<Self>) {
- 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<Self>,
- ) -> Vec<(DiffHunkKey, Vec<StoredReviewComment>)> {
- // 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<Self>) {
- 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<Self>,
- ) {
- // 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<Self>,
- ) {
- 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<Self>,
- ) {
- // 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<Self>,
- ) {
- // 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>,
- ) {
- 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>,
- ) {
- 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<Self>,
- ) {
- // 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<Editor>,
- hunk_key: &DiffHunkKey,
- editor_handle: &WeakEntity<Editor>,
- cx: &mut BlockContext,
- ) -> AnyElement {
- fn format_line_ranges(ranges: &[(u32, u32)]) -> Option<String> {
- if ranges.is_empty() {
- return None;
- }
- let formatted: Vec<String> = 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<StoredReviewComment>,
- expanded: bool,
- inline_editors: HashMap<usize, Entity<Editor>>,
- user_avatar_uri: Option<SharedUri>,
- 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<Entity<Editor>>,
- user_avatar_uri: Option<SharedUri>,
- 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<Self>) {
- 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<Self>) -> Task<Result<url::Url>> {
- let buffer_and_selection = maybe!({
- let selection = self.selections.newest::<Point>(&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<Self>,
- ) {
- 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::<CopyPermalinkToLine>(),
- message,
- ),
- cx,
- )
- })
- .ok();
- }
- }
- })
- .detach();
- }
-
- pub fn copy_file_location(
- &mut self,
- _: &CopyFileLocation,
- _: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let selection = self.selections.newest::<Point>(&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<Self>,
- ) {
- 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::<OpenPermalinkToLine>(), message),
- cx,
- )
- });
- }
- }
- })
- .detach();
- }
-
- pub fn insert_uuid_v4(
- &mut self,
- _: &InsertUuidV4,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.insert_uuid(UuidVersion::V4, window, cx);
- }
-
- pub fn insert_uuid_v7(
- &mut self,
- _: &InsertUuidV7,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.insert_uuid(UuidVersion::V7, window, cx);
- }
-
- fn insert_uuid(&mut self, version: UuidVersion, window: &mut Window, cx: &mut Context<Self>) {
- if self.read_only(cx) {
+ fn insert_uuid(&mut self, version: UuidVersion, window: &mut Window, cx: &mut Context<Self>) {
+ if self.read_only(cx) {
return;
}
self.transact(window, cx, |this, window, cx| {
@@ -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<Anchor>,
+ bool,
+ Pixels,
+ &Entity<Editor>,
+ &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<usize>,
+ display_row_range: Range<DisplayRow>,
+ multi_buffer_range: Range<Anchor>,
+ status: DiffHunkStatus,
+ word_diffs: Vec<Range<MultiBufferOffset>>,
+ },
+}
+
+#[derive(Clone)]
+pub(super) struct InlineBlamePopoverState {
+ pub(super) scroll_handle: ScrollHandle,
+ pub(super) commit_message: Option<ParsedCommitMessage>,
+ pub(super) markdown: Entity<Markdown>,
+}
+
+pub(super) struct InlineBlamePopover {
+ pub(super) position: gpui::Point<Pixels>,
+ pub(super) hide_task: Option<Task<()>>,
+ pub(super) popover_bounds: Option<Bounds<Pixels>>,
+ 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<util::rel_path::RelPath>,
+ /// 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<Anchor>,
+ /// 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<Anchor>,
+ /// The block ID for the overlay.
+ pub(super) block_id: CustomBlockId,
+ /// The editor entity for the review input.
+ pub(super) prompt_editor: Entity<Editor>,
+ /// 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<usize, Entity<Editor>>,
+ /// Subscriptions for inline edit editors' action handlers.
+ /// Key: comment ID, Value: Subscription keeping the Newline action handler alive.
+ pub(super) inline_edit_subscriptions: HashMap<usize, Subscription>,
+ /// The current user's avatar URI for display in comment rows.
+ pub(super) user_avatar_uri: Option<SharedUri>,
+ /// Subscription to keep the action handler alive.
+ _subscription: Subscription,
+}
+
+impl DiffReviewDragState {
+ pub(super) fn row_range(
+ &self,
+ snapshot: &DisplaySnapshot,
+ ) -> std::ops::RangeInclusive<DisplayRow> {
+ 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<Anchor>) -> 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>,
+ ) {
+ 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<GitBlame>> {
+ 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<Self>) {
+ 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>,
+ ) {
+ 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>,
+ ) {
+ 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>) {
+ 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<Self>) -> 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<Self>) {
+ 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<FileStatus> {
+ 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<Editor>,
+ ) {
+ 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>,
+ ) {
+ 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<DisplayRow>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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<dyn language::File>| 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<Self>) {
+ // 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<Editor>> {
+ 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<Self>) {
+ 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<Anchor>,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>,
+ ) {
+ let snapshot = self.snapshot(window, cx);
+ let cursor = self
+ .selections
+ .newest::<Point>(&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<Self>,
+ ) {
+ 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::<MultiBufferOffset>(&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::<Vec<_>>();
+
+ 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<Self>,
+ ) {
+ 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::<Point>(&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<MultiBufferDiffHunk>, 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::<Vec<_>>();
+ 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<Self>,
+ ) {
+ let snapshot = self.snapshot(window, cx);
+ let selection = self.selections.newest::<Point>(&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>,
+ ) {
+ 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<Self>,
+ ) {
+ 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<Self>,
+ ) -> 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<Self>,
+ ) {
+ 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<Self>,
+ ) {
+ 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<Self>) {
+ 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>) {
+ 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<Self>) {
+ 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>,
+ ) {
+ 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<Self>) -> 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<Self>,
+ ) -> 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<Self>,
+ ) {
+ 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<Self>) {
+ 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>,
) {
- 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<PathBuf> {
- 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<Self>,
+ ) {
+ 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<Self>) -> Option<PathBuf> {
- 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<Self>,
+ ) {
+ // 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<ProjectPath> {
- 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>,
) {
- 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<GitBlame>> {
- 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>,
+ ) {
+ self.confirm_edit_review_comment(action.id, window, cx);
}
- pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context<Self>) {
- 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>,
+ ) {
+ 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<Self>,
) {
- 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<Self>) {
- 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>,
) {
- 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::<CopyPermalinkToLine>(),
+ 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>,
) {
- 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::<OpenPermalinkToLine>(), message),
+ cx,
+ )
+ });
+ }
+ }
+ })
+ .detach();
}
pub(super) fn toggle_staged_selected_diff_hunks(