Closes https://github.com/zed-industries/zed/discussions/42583 Adds more tooltip entries and `editor::BlameRevision`, `editor::BlamePreviousRevision` actions to use. Started to highlight gutter blame entries that belong to currently annotated commit. https://github.com/user-attachments/assets/ba754e0b-6431-407c-8d79-2f8b0324fde1 Release Notes: - Supported blaming parent revisions
3298 lines
114 KiB
Rust
3298 lines
114 KiB
Rust
pub(super) mod blame;
|
|
|
|
use super::*;
|
|
use ::git::{
|
|
Oid, Restore, blame::BlameEntry, commit::ParsedCommitMessage, repository::RepoPath,
|
|
status::FileStatus,
|
|
};
|
|
use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind};
|
|
use project::git_store::Repository;
|
|
|
|
#[derive(Clone)]
|
|
pub struct ResolvedDiffHunk {
|
|
pub buffer_range: Range<text::Anchor>,
|
|
pub diff_base_byte_range: Range<usize>,
|
|
pub status: DiffHunkStatus,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ResolvedDiffHunks {
|
|
pub diff: Entity<BufferDiff>,
|
|
pub buffer_id: BufferId,
|
|
pub buffer: Option<Entity<Buffer>>,
|
|
pub hunks: Vec<ResolvedDiffHunk>,
|
|
}
|
|
|
|
pub trait DiffHunkDelegate {
|
|
fn toggle(
|
|
&self,
|
|
hunks: Vec<ResolvedDiffHunks>,
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
);
|
|
|
|
fn stage_or_unstage(
|
|
&self,
|
|
stage: bool,
|
|
hunks: Vec<ResolvedDiffHunks>,
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
);
|
|
|
|
fn restore(
|
|
&self,
|
|
hunks: Vec<ResolvedDiffHunks>,
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
if hunks.is_empty() || editor.read_only(cx) {
|
|
return;
|
|
}
|
|
self.stage_or_unstage(false, hunks.clone(), editor, window, cx);
|
|
editor.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,
|
|
|selections_state| {
|
|
selections_state.select(selections);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
fn render_hunk_controls(
|
|
&self,
|
|
row: u32,
|
|
status: &DiffHunkStatus,
|
|
hunk_range: Range<Anchor>,
|
|
is_created_file: bool,
|
|
line_height: Pixels,
|
|
editor: &Entity<Editor>,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement;
|
|
|
|
fn render_hunk_as_staged(&self, status: &DiffHunkStatus, _cx: &App) -> bool {
|
|
!status.has_secondary_hunk()
|
|
}
|
|
}
|
|
|
|
pub struct UncommittedDiffHunkDelegate;
|
|
|
|
impl DiffHunkDelegate for UncommittedDiffHunkDelegate {
|
|
fn toggle(
|
|
&self,
|
|
hunks: Vec<ResolvedDiffHunks>,
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
let stage = hunks
|
|
.iter()
|
|
.flat_map(|hunks| hunks.hunks.iter())
|
|
.any(|hunk| hunk.status.has_secondary_hunk());
|
|
self.stage_or_unstage(stage, hunks, editor, window, cx);
|
|
}
|
|
|
|
fn stage_or_unstage(
|
|
&self,
|
|
stage: bool,
|
|
hunks: Vec<ResolvedDiffHunks>,
|
|
editor: &mut Editor,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
let Some(project) = editor.project() else {
|
|
return;
|
|
};
|
|
for hunks in hunks {
|
|
let Some(buffer) = hunks.buffer else {
|
|
continue;
|
|
};
|
|
|
|
let ranges = hunks
|
|
.hunks
|
|
.into_iter()
|
|
.map(|hunk| hunk.buffer_range)
|
|
.collect::<Vec<_>>();
|
|
if ranges.is_empty() {
|
|
continue;
|
|
}
|
|
let secondary_diff = hunks.diff.read(cx).secondary_diff();
|
|
project
|
|
.update(cx, |project, cx| {
|
|
if stage {
|
|
let Some(secondary_diff) = secondary_diff else {
|
|
return Err(anyhow::anyhow!("diff has no unstaged secondary"));
|
|
};
|
|
project.stage_hunks(buffer, secondary_diff, ranges, cx)
|
|
} else {
|
|
project.unstage_uncommitted_hunks(buffer, hunks.diff, ranges, cx)
|
|
}
|
|
})
|
|
.log_err();
|
|
}
|
|
}
|
|
|
|
fn render_hunk_controls(
|
|
&self,
|
|
row: u32,
|
|
status: &DiffHunkStatus,
|
|
hunk_range: Range<Anchor>,
|
|
is_created_file: bool,
|
|
line_height: Pixels,
|
|
editor: &Entity<Editor>,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
render_diff_hunk_controls(
|
|
row,
|
|
status,
|
|
hunk_range,
|
|
is_created_file,
|
|
line_height,
|
|
editor,
|
|
window,
|
|
cx,
|
|
)
|
|
}
|
|
}
|
|
|
|
pub struct RestoreOnlyDiffHunkDelegate;
|
|
|
|
impl DiffHunkDelegate for RestoreOnlyDiffHunkDelegate {
|
|
fn toggle(
|
|
&self,
|
|
_hunks: Vec<ResolvedDiffHunks>,
|
|
_editor: &mut Editor,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Editor>,
|
|
) {
|
|
}
|
|
|
|
fn stage_or_unstage(
|
|
&self,
|
|
_stage: bool,
|
|
_hunks: Vec<ResolvedDiffHunks>,
|
|
_editor: &mut Editor,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Editor>,
|
|
) {
|
|
}
|
|
|
|
fn restore(
|
|
&self,
|
|
_hunks: Vec<ResolvedDiffHunks>,
|
|
_editor: &mut Editor,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Editor>,
|
|
) {
|
|
}
|
|
|
|
fn render_hunk_controls(
|
|
&self,
|
|
_row: u32,
|
|
_status: &DiffHunkStatus,
|
|
_hunk_range: Range<Anchor>,
|
|
_is_created_file: bool,
|
|
_line_height: Pixels,
|
|
_editor: &Entity<Editor>,
|
|
_window: &mut Window,
|
|
_cx: &mut App,
|
|
) -> AnyElement {
|
|
gpui::Empty.into_any_element()
|
|
}
|
|
}
|
|
|
|
pub struct RestoreOnlyUnstagedDiffHunkDelegate;
|
|
|
|
impl DiffHunkDelegate for RestoreOnlyUnstagedDiffHunkDelegate {
|
|
fn toggle(
|
|
&self,
|
|
_hunks: Vec<ResolvedDiffHunks>,
|
|
_editor: &mut Editor,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Editor>,
|
|
) {
|
|
}
|
|
|
|
fn stage_or_unstage(
|
|
&self,
|
|
_stage: bool,
|
|
_hunks: Vec<ResolvedDiffHunks>,
|
|
_editor: &mut Editor,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Editor>,
|
|
) {
|
|
}
|
|
|
|
fn render_hunk_controls(
|
|
&self,
|
|
_row: u32,
|
|
_status: &DiffHunkStatus,
|
|
_hunk_range: Range<Anchor>,
|
|
_is_created_file: bool,
|
|
_line_height: Pixels,
|
|
_editor: &Entity<Editor>,
|
|
_window: &mut Window,
|
|
_cx: &mut App,
|
|
) -> AnyElement {
|
|
gpui::Empty.into_any_element()
|
|
}
|
|
|
|
fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[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>(
|
|
&'a self,
|
|
ranges: &'a [Range<Anchor>],
|
|
buffer: &'a MultiBufferSnapshot,
|
|
) -> impl 'a + Iterator<Item = MultiBufferDiffHunk> {
|
|
ranges.iter().flat_map(move |range| {
|
|
let end_excerpt = buffer.excerpt_containing(range.end..range.end);
|
|
let range = range.to_point(buffer);
|
|
let mut peek_end = range.end;
|
|
if range.end.row < buffer.max_row().0 {
|
|
peek_end = Point::new(range.end.row + 1, 0);
|
|
}
|
|
buffer
|
|
.diff_hunks_in_range(range.start..peek_end)
|
|
.filter(move |hunk| {
|
|
if let Some((_, excerpt_range)) = &end_excerpt
|
|
&& let Some(end_anchor) =
|
|
buffer.anchor_in_excerpt(excerpt_range.context.end)
|
|
&& let Some(hunk_end_anchor) =
|
|
buffer.anchor_in_excerpt(hunk.excerpt_range.context.end)
|
|
&& hunk_end_anchor.cmp(&end_anchor, buffer).is_gt()
|
|
{
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
fn resolve_diff_hunks(
|
|
&self,
|
|
hunks: Vec<MultiBufferDiffHunk>,
|
|
cx: &App,
|
|
) -> Vec<ResolvedDiffHunks> {
|
|
let multibuffer = self.buffer().read(cx);
|
|
let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id);
|
|
let mut resolved = Vec::new();
|
|
|
|
for (source_buffer_id, hunks) in &chunk_by {
|
|
let Some(diff) = multibuffer.diff_for(source_buffer_id) else {
|
|
continue;
|
|
};
|
|
let diff_snapshot = diff.read(cx).snapshot(cx);
|
|
let main_buffer_id = diff_snapshot.buffer_id();
|
|
let buffer = multibuffer.buffer(main_buffer_id).or_else(|| {
|
|
self.project
|
|
.as_ref()
|
|
.and_then(|project| project.read(cx).buffer_for_id(main_buffer_id, cx))
|
|
});
|
|
let mut resolved_hunks = Vec::new();
|
|
|
|
for hunk in hunks {
|
|
if hunk.buffer_id == main_buffer_id {
|
|
resolved_hunks.push(ResolvedDiffHunk {
|
|
buffer_range: hunk.buffer_range,
|
|
diff_base_byte_range: hunk.diff_base_byte_range.start.0
|
|
..hunk.diff_base_byte_range.end.0,
|
|
status: hunk.status,
|
|
});
|
|
} else {
|
|
let diff_base_byte_range =
|
|
hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0;
|
|
let Some(hunk) = diff_snapshot
|
|
.hunks_intersecting_base_text_range(
|
|
diff_base_byte_range.clone(),
|
|
diff_snapshot.buffer_snapshot(),
|
|
)
|
|
.find(|hunk| hunk.diff_base_byte_range == diff_base_byte_range)
|
|
else {
|
|
continue;
|
|
};
|
|
let kind = if hunk.buffer_range.start == hunk.buffer_range.end {
|
|
DiffHunkStatusKind::Deleted
|
|
} else if hunk.diff_base_byte_range.is_empty() {
|
|
DiffHunkStatusKind::Added
|
|
} else {
|
|
DiffHunkStatusKind::Modified
|
|
};
|
|
resolved_hunks.push(ResolvedDiffHunk {
|
|
buffer_range: hunk.buffer_range,
|
|
diff_base_byte_range: hunk.diff_base_byte_range,
|
|
status: DiffHunkStatus {
|
|
kind,
|
|
secondary: hunk.secondary_status,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if !resolved_hunks.is_empty() {
|
|
resolved.push(ResolvedDiffHunks {
|
|
diff,
|
|
buffer_id: main_buffer_id,
|
|
buffer,
|
|
hunks: resolved_hunks,
|
|
});
|
|
}
|
|
}
|
|
|
|
resolved
|
|
}
|
|
|
|
pub fn diff_hunk_delegate(&self) -> Arc<dyn DiffHunkDelegate> {
|
|
self.diff_hunk_delegate
|
|
.clone()
|
|
.unwrap_or_else(|| Arc::new(UncommittedDiffHunkDelegate))
|
|
}
|
|
|
|
pub fn set_diff_hunk_delegate(
|
|
&mut self,
|
|
delegate: Option<Arc<dyn DiffHunkDelegate>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let had_delegate = self.diff_hunk_delegate.is_some();
|
|
let has_delegate = delegate.is_some();
|
|
self.diff_hunk_delegate = delegate;
|
|
|
|
if !had_delegate && has_delegate {
|
|
self.load_diff_task.take();
|
|
} else if had_delegate && !has_delegate {
|
|
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(
|
|
self.update_uncommitted_diff_for_buffer(
|
|
&project,
|
|
self.buffer.read(cx).all_buffers(),
|
|
cx,
|
|
)
|
|
.shared(),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 active_git_blame_entry(&self, cx: &mut App) -> Option<BlameEntry> {
|
|
if !self.show_git_blame_inline
|
|
|| self.newest_selection_head_on_empty_line(cx)
|
|
|| !self.has_blame_entries(cx)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let blame = self.blame.as_ref()?;
|
|
let snapshot = self.display_snapshot(cx);
|
|
let cursor = self.selections.newest::<Point>(&snapshot).head();
|
|
let (buffer, point) = snapshot.buffer_snapshot().point_to_buffer_point(cursor)?;
|
|
|
|
blame
|
|
.update(cx, |blame, cx| {
|
|
blame
|
|
.blame_for_rows(
|
|
&[RowInfo {
|
|
buffer_id: Some(buffer.remote_id()),
|
|
buffer_row: Some(point.row),
|
|
..Default::default()
|
|
}],
|
|
cx,
|
|
)
|
|
.next()
|
|
})
|
|
.flatten()
|
|
.map(|(_, entry)| entry)
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// 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 just_started = self.blame.is_none();
|
|
if just_started {
|
|
self.start_git_blame(true, window, cx);
|
|
}
|
|
let Some(blame) = self.blame.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
if just_started && !blame.read(cx).has_generated_entries() {
|
|
let subscription = cx.observe_in(blame, window, |editor, blame, window, cx| {
|
|
if blame.read(cx).has_generated_entries() {
|
|
editor.pending_blame_hover_observation.take();
|
|
editor.show_blame_hover_popover(window, cx);
|
|
}
|
|
});
|
|
self.pending_blame_hover_observation = Some(subscription);
|
|
return;
|
|
}
|
|
|
|
self.show_blame_hover_popover(window, cx);
|
|
}
|
|
|
|
fn show_blame_hover_popover(&mut self, 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;
|
|
};
|
|
|
|
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 fn restore_diff_hunks(&mut self, hunks: Vec<ResolvedDiffHunks>, cx: &mut Context<Self>) {
|
|
let mut revert_changes = Vec::new();
|
|
for hunks in hunks {
|
|
let Some(buffer) = hunks.buffer else {
|
|
continue;
|
|
};
|
|
let diff_snapshot = hunks.diff.read(cx).snapshot(cx);
|
|
let changes = hunks
|
|
.hunks
|
|
.into_iter()
|
|
.filter_map(|hunk| {
|
|
if hunk.diff_base_byte_range == (0..0)
|
|
&& hunk.buffer_range.start.is_min()
|
|
&& hunk.buffer_range.end.is_max()
|
|
{
|
|
return None;
|
|
}
|
|
let original_text = diff_snapshot
|
|
.base_text()
|
|
.as_rope()
|
|
.slice(hunk.diff_base_byte_range.start..hunk.diff_base_byte_range.end);
|
|
Some((hunk.buffer_range, original_text))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !changes.is_empty() {
|
|
revert_changes.push((buffer, changes));
|
|
}
|
|
}
|
|
|
|
for (buffer, changes) in revert_changes {
|
|
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 fn toggle_all_diff_hunks(
|
|
&mut self,
|
|
_: &ToggleAllDiffHunks,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.has_any_expanded_diff_hunks(cx) {
|
|
self.collapse_all_diff_hunks(&CollapseAllDiffHunks, window, cx);
|
|
} else {
|
|
self.expand_all_diff_hunks(&ExpandAllDiffHunks, window, 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,
|
|
_: &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(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))
|
|
{
|
|
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(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
|
|
}
|
|
});
|
|
|
|
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(super) 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(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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// Handles the DeleteReviewComment action - removes a comment.
|
|
pub(super) 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);
|
|
}
|
|
}
|
|
|
|
pub(super) 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(super) 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(super) fn toggle_staged_selected_diff_hunks(
|
|
&mut self,
|
|
_: &::git::ToggleStaged,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let ranges: Vec<_> = self
|
|
.selections
|
|
.disjoint_anchors()
|
|
.iter()
|
|
.map(|s| s.range())
|
|
.collect();
|
|
let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
task.await?;
|
|
this.update_in(cx, |this, window, cx| {
|
|
let snapshot = this.buffer.read(cx).snapshot(cx);
|
|
let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect();
|
|
this.apply_toggle(hunks, window, cx);
|
|
})
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
pub(super) fn stage_and_next(
|
|
&mut self,
|
|
_: &::git::StageAndNext,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.do_stage_or_unstage_and_next(true, window, cx);
|
|
}
|
|
|
|
pub(super) fn unstage_and_next(
|
|
&mut self,
|
|
_: &::git::UnstageAndNext,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.do_stage_or_unstage_and_next(false, window, cx);
|
|
}
|
|
|
|
pub fn apply_toggle(
|
|
&mut self,
|
|
hunks: Vec<MultiBufferDiffHunk>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut hunks = self.resolve_diff_hunks(hunks, cx);
|
|
if self.diff_hunk_delegate.is_none() {
|
|
hunks.retain(|hunks| hunks.diff.read(cx).is_stageable());
|
|
}
|
|
if hunks.is_empty() {
|
|
return;
|
|
}
|
|
let delegate = self.diff_hunk_delegate();
|
|
delegate.toggle(hunks, self, window, cx);
|
|
}
|
|
|
|
pub fn apply_stage_or_unstage(
|
|
&mut self,
|
|
stage: bool,
|
|
hunks: Vec<MultiBufferDiffHunk>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut hunks = self.resolve_diff_hunks(hunks, cx);
|
|
if self.diff_hunk_delegate.is_none() {
|
|
hunks.retain(|hunks| hunks.diff.read(cx).is_stageable());
|
|
}
|
|
if hunks.is_empty() {
|
|
return;
|
|
}
|
|
let delegate = self.diff_hunk_delegate();
|
|
delegate.stage_or_unstage(stage, hunks, self, window, cx);
|
|
}
|
|
|
|
pub fn apply_restore(
|
|
&mut self,
|
|
hunks: Vec<MultiBufferDiffHunk>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut hunks = self.resolve_diff_hunks(hunks, cx);
|
|
if self.diff_hunk_delegate.is_none() {
|
|
hunks.retain(|hunks| hunks.diff.read(cx).is_stageable());
|
|
}
|
|
if hunks.is_empty() {
|
|
return;
|
|
}
|
|
let delegate = self.diff_hunk_delegate();
|
|
delegate.restore(hunks, self, window, cx);
|
|
}
|
|
|
|
pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut Context<Self>) -> bool {
|
|
self.buffer.update(cx, |buffer, cx| {
|
|
let ranges = vec![Anchor::Min..Anchor::Max];
|
|
if !buffer.all_diff_hunks_expanded()
|
|
&& buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx)
|
|
{
|
|
buffer.collapse_diff_hunks(ranges, cx);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
}
|
|
|
|
pub(super) fn has_any_expanded_diff_hunks(&self, cx: &App) -> bool {
|
|
if self.buffer.read(cx).all_diff_hunks_expanded() {
|
|
return true;
|
|
}
|
|
let ranges = vec![Anchor::Min..Anchor::Max];
|
|
self.buffer
|
|
.read(cx)
|
|
.has_expanded_diff_hunks_in_ranges(&ranges, cx)
|
|
}
|
|
|
|
pub(super) fn toggle_single_diff_hunk(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
|
|
self.buffer.update(cx, |buffer, cx| {
|
|
buffer.toggle_single_diff_hunk(range, cx);
|
|
})
|
|
}
|
|
|
|
pub(super) fn apply_all_diff_hunks(
|
|
&mut self,
|
|
_: &ApplyAllDiffHunks,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.read_only(cx) {
|
|
return;
|
|
}
|
|
|
|
let buffers = self.buffer.read(cx).all_buffers();
|
|
for branch_buffer in buffers {
|
|
branch_buffer.update(cx, |branch_buffer, cx| {
|
|
branch_buffer.merge_into_base(Vec::new(), cx);
|
|
});
|
|
}
|
|
|
|
if let Some(project) = self.project.clone() {
|
|
self.save(
|
|
SaveOptions {
|
|
format: true,
|
|
force_format: false,
|
|
autosave: false,
|
|
},
|
|
project,
|
|
window,
|
|
cx,
|
|
)
|
|
.detach_and_log_err(cx);
|
|
}
|
|
}
|
|
|
|
pub(super) fn apply_selected_diff_hunks(
|
|
&mut self,
|
|
_: &ApplyDiffHunk,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.read_only(cx) {
|
|
return;
|
|
}
|
|
let snapshot = self.snapshot(window, cx);
|
|
let hunks = snapshot.hunks_for_ranges(
|
|
self.selections
|
|
.all(&snapshot.display_snapshot)
|
|
.into_iter()
|
|
.map(|selection| selection.range()),
|
|
);
|
|
let mut ranges_by_buffer = HashMap::default();
|
|
self.transact(window, cx, |editor, _window, cx| {
|
|
for hunk in hunks {
|
|
if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) {
|
|
ranges_by_buffer
|
|
.entry(buffer.clone())
|
|
.or_insert_with(Vec::new)
|
|
.push(hunk.buffer_range.to_offset(buffer.read(cx)));
|
|
}
|
|
}
|
|
|
|
for (buffer, ranges) in ranges_by_buffer {
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.merge_into_base(ranges, cx);
|
|
});
|
|
}
|
|
});
|
|
|
|
if let Some(project) = self.project.clone() {
|
|
self.save(
|
|
SaveOptions {
|
|
format: true,
|
|
force_format: false,
|
|
autosave: false,
|
|
},
|
|
project,
|
|
window,
|
|
cx,
|
|
)
|
|
.detach_and_log_err(cx);
|
|
}
|
|
}
|
|
|
|
pub(super) fn open_git_blame_commit(
|
|
&mut self,
|
|
_: &OpenGitBlameCommit,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.open_git_blame_commit_internal(window, cx);
|
|
}
|
|
|
|
pub(super) fn toggle_git_blame_inline_internal(
|
|
&mut self,
|
|
user_triggered: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.git_blame_inline_enabled {
|
|
self.git_blame_inline_enabled = false;
|
|
self.show_git_blame_inline = false;
|
|
self.show_git_blame_inline_delay_task.take();
|
|
} else {
|
|
self.git_blame_inline_enabled = true;
|
|
self.start_git_blame_inline(user_triggered, window, cx);
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
pub(super) fn start_git_blame_inline(
|
|
&mut self,
|
|
user_triggered: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.start_git_blame(user_triggered, window, cx);
|
|
|
|
if ProjectSettings::get_global(cx)
|
|
.git
|
|
.inline_blame_delay()
|
|
.is_some()
|
|
{
|
|
self.start_inline_blame_timer(window, cx);
|
|
} else {
|
|
self.show_git_blame_inline = true
|
|
}
|
|
}
|
|
|
|
pub(super) fn render_git_blame_gutter(&self, cx: &App) -> bool {
|
|
!self.mode().is_minimap() && self.show_git_blame_gutter && self.has_blame_entries(cx)
|
|
}
|
|
|
|
pub(super) fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool {
|
|
ProjectSettings::get_global(cx).git.inline_blame.location
|
|
== project::project_settings::InlineBlameLocation::Inline
|
|
&& self.show_git_blame_inline
|
|
&& (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some())
|
|
&& !self.newest_selection_head_on_empty_line(cx)
|
|
&& self.has_blame_entries(cx)
|
|
}
|
|
|
|
pub(super) fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context<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(super) 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(super) 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,
|
|
);
|
|
}
|
|
|
|
/// Calculates the appropriate block height for the diff review overlay.
|
|
/// Height is in lines: 2 for input row, 1 for header when comments exist,
|
|
/// and 2 lines per comment when expanded.
|
|
pub(super) fn calculate_overlay_height(
|
|
&self,
|
|
hunk_key: &DiffHunkKey,
|
|
comments_expanded: bool,
|
|
snapshot: &MultiBufferSnapshot,
|
|
) -> u32 {
|
|
let comment_count = self.hunk_comment_count(hunk_key, snapshot);
|
|
let base_height: u32 = 2; // Input row with avatar and buttons
|
|
|
|
if comment_count == 0 {
|
|
base_height
|
|
} else if comments_expanded {
|
|
// Header (1 line) + 2 lines per comment
|
|
base_height + 1 + (comment_count as u32 * 2)
|
|
} else {
|
|
// Just header when collapsed
|
|
base_height + 1
|
|
}
|
|
}
|
|
|
|
pub fn stage_or_unstage_diff_hunks(
|
|
&mut self,
|
|
stage: bool,
|
|
ranges: Vec<Range<Anchor>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
task.await?;
|
|
this.update_in(cx, |this, window, cx| {
|
|
let snapshot = this.buffer.read(cx).snapshot(cx);
|
|
let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect();
|
|
this.apply_stage_or_unstage(stage, hunks, window, cx);
|
|
})
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
pub fn restore_diff_hunks_in_ranges(
|
|
&mut self,
|
|
ranges: Vec<Range<Anchor>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let snapshot = self.buffer.read(cx).snapshot(cx);
|
|
let hunks = self.diff_hunks_in_ranges(&ranges, &snapshot).collect();
|
|
self.apply_restore(hunks, window, cx);
|
|
}
|
|
|
|
fn toggle_diff_hunks_in_ranges(
|
|
&mut self,
|
|
ranges: Vec<Range<Anchor>>,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
self.buffer.update(cx, |buffer, cx| {
|
|
let expand = !buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx);
|
|
buffer.expand_or_collapse_diff_hunks(ranges, expand, cx);
|
|
})
|
|
}
|
|
|
|
pub fn set_blame(
|
|
&mut self,
|
|
blame: Entity<GitBlame>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.blame_subscription = Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify()));
|
|
self.blame = Some(blame);
|
|
self.show_git_blame_gutter = true;
|
|
cx.notify();
|
|
}
|
|
|
|
fn start_git_blame(
|
|
&mut self,
|
|
user_triggered: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self
|
|
.blame
|
|
.as_ref()
|
|
.is_some_and(|blame| blame.read(cx).is_static())
|
|
{
|
|
return;
|
|
}
|
|
if let Some(project) = self.project() {
|
|
if let Some(buffer) = self.buffer().read(cx).as_singleton()
|
|
&& buffer.read(cx).file().is_none_or(|file| {
|
|
matches!(file.disk_state(), language::DiskState::Historic { .. })
|
|
})
|
|
{
|
|
return;
|
|
}
|
|
|
|
let focused = self.focus_handle(cx).contains_focused(window, cx);
|
|
|
|
let project = project.clone();
|
|
let blame = cx
|
|
.new(|cx| GitBlame::new(self.buffer.clone(), project, user_triggered, focused, cx));
|
|
self.blame_subscription =
|
|
Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify()));
|
|
self.blame = Some(blame);
|
|
}
|
|
}
|
|
|
|
fn restore_hunks_in_ranges(
|
|
&mut self,
|
|
ranges: Vec<Range<Point>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges);
|
|
self.apply_restore(hunks, window, cx);
|
|
}
|
|
|
|
fn save_buffers_for_ranges_if_needed(
|
|
&mut self,
|
|
ranges: &[Range<Anchor>],
|
|
cx: &mut Context<Editor>,
|
|
) -> Task<Result<()>> {
|
|
let multibuffer = self.buffer.read(cx);
|
|
let snapshot = multibuffer.read(cx);
|
|
let buffer_ids: HashSet<_> = ranges
|
|
.iter()
|
|
.flat_map(|range| snapshot.buffer_ids_for_range(range.clone()))
|
|
.collect();
|
|
drop(snapshot);
|
|
|
|
let mut buffers = HashSet::default();
|
|
for buffer_id in buffer_ids {
|
|
if let Some(buffer_entity) = multibuffer.buffer(buffer_id) {
|
|
let buffer = buffer_entity.read(cx);
|
|
if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty()
|
|
{
|
|
buffers.insert(buffer_entity);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(project) = &self.project {
|
|
project.update(cx, |project, cx| project.save_buffers(buffers, cx))
|
|
} else {
|
|
Task::ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
fn do_stage_or_unstage_and_next(
|
|
&mut self,
|
|
stage: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
|
|
|
|
if ranges.iter().any(|range| range.start != range.end) {
|
|
self.stage_or_unstage_diff_hunks(stage, ranges, window, cx);
|
|
return;
|
|
}
|
|
|
|
self.stage_or_unstage_diff_hunks(stage, ranges, 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,
|
|
);
|
|
}
|
|
|
|
fn open_git_blame_commit_internal(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<()> {
|
|
let (blame_entry, repo) = self.blame_entry_at_cursor(window, cx)?;
|
|
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
|
|
let workspace = self.workspace()?.downgrade();
|
|
renderer.open_blame_commit(blame_entry, repo, workspace, window, cx);
|
|
None
|
|
}
|
|
|
|
fn blame_entry_at_cursor(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<(BlameEntry, Entity<Repository>)> {
|
|
let blame = self.blame.clone()?;
|
|
let snapshot = self.snapshot(window, cx);
|
|
let cursor = self
|
|
.selections
|
|
.newest::<Point>(&snapshot.display_snapshot)
|
|
.head();
|
|
let (buffer, point) = snapshot.buffer_snapshot().point_to_buffer_point(cursor)?;
|
|
let (_, blame_entry) = blame
|
|
.update(cx, |blame, cx| {
|
|
blame
|
|
.blame_for_rows(
|
|
&[RowInfo {
|
|
buffer_id: Some(buffer.remote_id()),
|
|
buffer_row: Some(point.row),
|
|
..Default::default()
|
|
}],
|
|
cx,
|
|
)
|
|
.next()
|
|
})
|
|
.flatten()?;
|
|
let repository = blame.read(cx).repository(cx, buffer.remote_id())?;
|
|
Some((blame_entry, repository))
|
|
}
|
|
|
|
pub(super) fn blame_revision(
|
|
&mut self,
|
|
_: &BlameRevision,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some((blame_entry, repository)) = self.blame_entry_at_cursor(window, cx) else {
|
|
self.show_blame_revision_toast("No blame entry for this line", cx);
|
|
return;
|
|
};
|
|
if blame_entry.sha.is_zero() {
|
|
self.show_blame_revision_toast("Cannot blame revision: the line is not committed", cx);
|
|
return;
|
|
}
|
|
if self
|
|
.blame
|
|
.as_ref()
|
|
.is_some_and(|blame| blame.read(cx).highlighted_sha() == Some(blame_entry.sha))
|
|
{
|
|
self.show_blame_revision_toast("Already blaming at this revision", cx);
|
|
return;
|
|
}
|
|
let Some(path) = RepoPath::new(&blame_entry.filename).log_err() else {
|
|
return;
|
|
};
|
|
self.open_blame_revision(path, blame_entry.sha, repository, window, cx);
|
|
}
|
|
|
|
pub(super) fn blame_previous_revision(
|
|
&mut self,
|
|
_: &BlamePreviousRevision,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some((blame_entry, repository)) = self.blame_entry_at_cursor(window, cx) else {
|
|
self.show_blame_revision_toast("No blame entry for this line", cx);
|
|
return;
|
|
};
|
|
let Some((revision, filename)) = blame_entry.previous_sha_and_filename() else {
|
|
self.show_blame_revision_toast("No previous revision for this line", cx);
|
|
return;
|
|
};
|
|
let Some(path) = RepoPath::new(filename).log_err() else {
|
|
return;
|
|
};
|
|
self.open_blame_revision(path, revision, repository, window, cx);
|
|
}
|
|
|
|
fn open_blame_revision(
|
|
&mut self,
|
|
path: RepoPath,
|
|
revision: Oid,
|
|
repository: Entity<Repository>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(workspace) = self.workspace() else {
|
|
return;
|
|
};
|
|
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
|
|
renderer.open_blame_revision(
|
|
path,
|
|
revision,
|
|
repository,
|
|
workspace.downgrade(),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn show_blame_revision_toast(&self, message: &str, cx: &mut Context<Self>) {
|
|
struct BlameRevisionToast;
|
|
if let Some(workspace) = self.workspace() {
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.show_toast(
|
|
Toast::new(
|
|
NotificationId::unique::<BlameRevisionToast>(),
|
|
message.to_owned(),
|
|
),
|
|
cx,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn has_blame_entries(&self, cx: &App) -> bool {
|
|
self.blame()
|
|
.is_some_and(|blame| blame.read(cx).has_generated_entries())
|
|
}
|
|
|
|
fn newest_selection_head_on_empty_line(&self, cx: &App) -> bool {
|
|
let cursor_anchor = self.selections.newest_anchor().head();
|
|
|
|
let snapshot = self.buffer.read(cx).snapshot(cx);
|
|
let buffer_row = MultiBufferRow(cursor_anchor.to_point(&snapshot).row);
|
|
|
|
snapshot.line_len(buffer_row) == 0
|
|
}
|
|
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 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
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// Compares two DiffHunkKeys for equality by resolving their anchors.
|
|
fn hunk_keys_match(a: &DiffHunkKey, b: &DiffHunkKey, snapshot: &MultiBufferSnapshot) -> bool {
|
|
a.file_path == b.file_path
|
|
&& a.hunk_start_anchor.to_point(snapshot) == b.hunk_start_anchor.to_point(snapshot)
|
|
}
|
|
|
|
fn render_diff_review_overlay(
|
|
prompt_editor: &Entity<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()
|
|
})
|
|
}
|
|
|
|
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())?;
|
|
|
|
let Some(buffer_diff) = multi_buffer.diff_for(buffer_snapshot.remote_id()) else {
|
|
return Some((buffer, buffer_range.start.row..buffer_range.end.row));
|
|
};
|
|
|
|
let buffer_diff_snapshot = buffer_diff.read(cx).snapshot(cx);
|
|
let start = buffer_diff_snapshot
|
|
.buffer_point_to_base_text_point(buffer_range.start, &buffer_snapshot);
|
|
let end = buffer_diff_snapshot
|
|
.buffer_point_to_base_text_point(buffer_range.end, &buffer_snapshot);
|
|
|
|
Some((buffer, start.row..end.row))
|
|
});
|
|
|
|
let Some((buffer, selection)) = buffer_and_selection else {
|
|
return Task::ready(Err(anyhow!("failed to determine buffer and selection")));
|
|
};
|
|
|
|
let Some(project) = self.project() else {
|
|
return Task::ready(Err(anyhow!("editor does not have project")));
|
|
};
|
|
|
|
project.update(cx, |project, cx| {
|
|
project.get_permalink_to_line(&buffer, selection, cx)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Editor {
|
|
/// Returns the line range for the first diff review overlay, if one is active.
|
|
/// Returns (start_row, end_row) as physical line numbers in the underlying file.
|
|
pub(super) fn diff_review_line_range(&self, cx: &App) -> Option<(u32, u32)> {
|
|
let overlay = self.diff_review_overlays.first()?;
|
|
let snapshot = self.buffer.read(cx).snapshot(cx);
|
|
let start_point = overlay.anchor_range.start.to_point(&snapshot);
|
|
let end_point = overlay.anchor_range.end.to_point(&snapshot);
|
|
let start_row = snapshot
|
|
.point_to_buffer_point(start_point)
|
|
.map(|(_, p)| p.row)
|
|
.unwrap_or(start_point.row);
|
|
let end_row = snapshot
|
|
.point_to_buffer_point(end_point)
|
|
.map(|(_, p)| p.row)
|
|
.unwrap_or(end_point.row);
|
|
Some((start_row, end_row))
|
|
}
|
|
|
|
/// Takes all stored comments from all hunks, clearing the storage.
|
|
/// Returns a Vec of (hunk_key, comments) pairs.
|
|
pub(super) fn take_all_review_comments(
|
|
&mut self,
|
|
cx: &mut Context<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
|
|
}
|
|
}
|
|
|
|
impl EditorSnapshot {
|
|
pub(super) fn display_diff_hunks_for_rows<'a>(
|
|
&'a self,
|
|
display_rows: Range<DisplayRow>,
|
|
folded_buffers: &'a HashSet<BufferId>,
|
|
) -> impl 'a + Iterator<Item = DisplayDiffHunk> {
|
|
let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(self);
|
|
let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(self);
|
|
|
|
self.buffer_snapshot()
|
|
.diff_hunks_in_range(buffer_start..buffer_end)
|
|
.filter_map(|hunk| {
|
|
if folded_buffers.contains(&hunk.buffer_id)
|
|
|| (hunk.row_range.is_empty() && self.buffer.all_diff_hunks_expanded())
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let hunk_start_point = Point::new(hunk.row_range.start.0, 0);
|
|
let hunk_end_point = if hunk.row_range.end > hunk.row_range.start {
|
|
let last_row = MultiBufferRow(hunk.row_range.end.0 - 1);
|
|
let line_len = self.buffer_snapshot().line_len(last_row);
|
|
Point::new(last_row.0, line_len)
|
|
} else {
|
|
Point::new(hunk.row_range.end.0, 0)
|
|
};
|
|
|
|
let hunk_display_start = self.point_to_display_point(hunk_start_point, Bias::Left);
|
|
let hunk_display_end = self.point_to_display_point(hunk_end_point, Bias::Right);
|
|
|
|
let display_hunk = if hunk_display_start.column() != 0 {
|
|
DisplayDiffHunk::Folded {
|
|
display_row: hunk_display_start.row(),
|
|
}
|
|
} else {
|
|
let mut end_row = hunk_display_end.row();
|
|
if hunk.row_range.end > hunk.row_range.start || hunk_display_end.column() > 0 {
|
|
end_row.0 += 1;
|
|
}
|
|
let is_created_file = hunk.is_created_file();
|
|
let multi_buffer_range = hunk.multi_buffer_range.clone();
|
|
|
|
DisplayDiffHunk::Unfolded {
|
|
status: hunk.status(),
|
|
diff_base_byte_range: hunk.diff_base_byte_range.start.0
|
|
..hunk.diff_base_byte_range.end.0,
|
|
word_diffs: hunk.word_diffs,
|
|
display_row_range: hunk_display_start.row()..end_row,
|
|
multi_buffer_range,
|
|
is_created_file,
|
|
}
|
|
};
|
|
|
|
Some(display_hunk)
|
|
})
|
|
}
|
|
|
|
fn hunks_for_ranges(
|
|
&self,
|
|
ranges: impl IntoIterator<Item = Range<Point>>,
|
|
) -> Vec<MultiBufferDiffHunk> {
|
|
let mut hunks = Vec::new();
|
|
let mut processed_buffer_rows: HashMap<BufferId, HashSet<Range<text::Anchor>>> =
|
|
HashMap::default();
|
|
for query_range in ranges {
|
|
let query_rows =
|
|
MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1);
|
|
for hunk in self.buffer_snapshot().diff_hunks_in_range(
|
|
Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0),
|
|
) {
|
|
// Include deleted hunks that are adjacent to the query range, because
|
|
// otherwise they would be missed.
|
|
let mut intersects_range = hunk.row_range.overlaps(&query_rows);
|
|
if hunk.status().is_deleted() {
|
|
intersects_range |= hunk.row_range.start == query_rows.end;
|
|
intersects_range |= hunk.row_range.end == query_rows.start;
|
|
}
|
|
if intersects_range {
|
|
if !processed_buffer_rows
|
|
.entry(hunk.buffer_id)
|
|
.or_default()
|
|
.insert(hunk.buffer_range.start..hunk.buffer_range.end)
|
|
{
|
|
continue;
|
|
}
|
|
hunks.push(hunk);
|
|
}
|
|
}
|
|
}
|
|
|
|
hunks
|
|
}
|
|
}
|
|
|
|
pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) {
|
|
cx.set_global(GlobalBlameRenderer(Arc::new(renderer)));
|
|
}
|
|
|
|
pub fn render_diff_hunk_controls(
|
|
row: u32,
|
|
status: &DiffHunkStatus,
|
|
hunk_range: Range<Anchor>,
|
|
is_created_file: bool,
|
|
line_height: Pixels,
|
|
editor: &Entity<Editor>,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let stageable = hunk_range
|
|
.start
|
|
.buffer_id()
|
|
.and_then(|buffer_id| editor.read(cx).buffer().read(cx).diff_for(buffer_id))
|
|
.is_some_and(|diff| diff.read(cx).is_stageable());
|
|
let show_stage_restore = stageable
|
|
&& ProjectSettings::get_global(cx)
|
|
.git
|
|
.show_stage_restore_buttons;
|
|
|
|
h_flex()
|
|
.h(line_height)
|
|
.mr_1()
|
|
.gap_1()
|
|
.px_0p5()
|
|
.pb_1()
|
|
.border_x_1()
|
|
.border_b_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.rounded_b_lg()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.gap_1()
|
|
.block_mouse_except_scroll()
|
|
.shadow_md()
|
|
.when(show_stage_restore, |el| {
|
|
el.child(if status.has_secondary_hunk() {
|
|
Button::new(("stage", row as u64), "Stage")
|
|
.alpha(if status.is_pending() { 0.66 } else { 1.0 })
|
|
.tooltip({
|
|
let focus_handle = editor.focus_handle(cx);
|
|
move |_window, cx| {
|
|
Tooltip::for_action_in(
|
|
"Stage Hunk",
|
|
&::git::ToggleStaged,
|
|
&focus_handle,
|
|
cx,
|
|
)
|
|
}
|
|
})
|
|
.on_click({
|
|
let editor = editor.clone();
|
|
move |_event, window, cx| {
|
|
editor.update(cx, |editor, cx| {
|
|
editor.stage_or_unstage_diff_hunks(
|
|
true,
|
|
vec![hunk_range.start..hunk_range.start],
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
}
|
|
})
|
|
} else {
|
|
Button::new(("unstage", row as u64), "Unstage")
|
|
.alpha(if status.is_pending() { 0.66 } else { 1.0 })
|
|
.tooltip({
|
|
let focus_handle = editor.focus_handle(cx);
|
|
move |_window, cx| {
|
|
Tooltip::for_action_in(
|
|
"Unstage Hunk",
|
|
&::git::ToggleStaged,
|
|
&focus_handle,
|
|
cx,
|
|
)
|
|
}
|
|
})
|
|
.on_click({
|
|
let editor = editor.clone();
|
|
move |_event, window, cx| {
|
|
editor.update(cx, |editor, cx| {
|
|
editor.stage_or_unstage_diff_hunks(
|
|
false,
|
|
vec![hunk_range.start..hunk_range.start],
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
}
|
|
})
|
|
})
|
|
})
|
|
.when(show_stage_restore, |el| {
|
|
el.child(
|
|
Button::new(("restore", row as u64), "Restore")
|
|
.tooltip({
|
|
let focus_handle = editor.focus_handle(cx);
|
|
move |_window, cx| {
|
|
Tooltip::for_action_in(
|
|
"Restore Hunk",
|
|
&::git::Restore,
|
|
&focus_handle,
|
|
cx,
|
|
)
|
|
}
|
|
})
|
|
.on_click({
|
|
let editor = editor.clone();
|
|
move |_event, window, cx| {
|
|
editor.update(cx, |editor, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let point = hunk_range.start.to_point(&snapshot.buffer_snapshot());
|
|
editor.restore_hunks_in_ranges(vec![point..point], window, cx);
|
|
});
|
|
}
|
|
})
|
|
.disabled(is_created_file),
|
|
)
|
|
})
|
|
.when(
|
|
!editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
|
|
|el| {
|
|
el.child(
|
|
IconButton::new(("next-hunk", row as u64), IconName::ArrowDown)
|
|
.shape(IconButtonShape::Square)
|
|
.icon_size(IconSize::Small)
|
|
// .disabled(!has_multiple_hunks)
|
|
.tooltip({
|
|
let focus_handle = editor.focus_handle(cx);
|
|
move |_window, cx| {
|
|
Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx)
|
|
}
|
|
})
|
|
.on_click({
|
|
let editor = editor.clone();
|
|
move |_event, window, cx| {
|
|
editor.update(cx, |editor, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let position =
|
|
hunk_range.end.to_point(&snapshot.buffer_snapshot());
|
|
editor.go_to_hunk_before_or_after_position(
|
|
&snapshot,
|
|
position,
|
|
Direction::Next,
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
editor.expand_selected_diff_hunks(cx);
|
|
});
|
|
}
|
|
}),
|
|
)
|
|
.child(
|
|
IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp)
|
|
.shape(IconButtonShape::Square)
|
|
.icon_size(IconSize::Small)
|
|
// .disabled(!has_multiple_hunks)
|
|
.tooltip({
|
|
let focus_handle = editor.focus_handle(cx);
|
|
move |_window, cx| {
|
|
Tooltip::for_action_in(
|
|
"Previous Hunk",
|
|
&GoToPreviousHunk,
|
|
&focus_handle,
|
|
cx,
|
|
)
|
|
}
|
|
})
|
|
.on_click({
|
|
let editor = editor.clone();
|
|
move |_event, window, cx| {
|
|
editor.update(cx, |editor, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let point =
|
|
hunk_range.start.to_point(&snapshot.buffer_snapshot());
|
|
editor.go_to_hunk_before_or_after_position(
|
|
&snapshot,
|
|
point,
|
|
Direction::Prev,
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
editor.expand_selected_diff_hunks(cx);
|
|
});
|
|
}
|
|
}),
|
|
)
|
|
},
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
impl Editor {
|
|
pub(super) fn update_uncommitted_diff_for_buffer(
|
|
&mut self,
|
|
project: &Entity<Project>,
|
|
buffers: impl IntoIterator<Item = Entity<Buffer>>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<()> {
|
|
let mut tasks = Vec::new();
|
|
project.update(cx, |project, cx| {
|
|
let git_store = project.git_store().clone();
|
|
git_store.update(cx, |git_store, cx| {
|
|
for buffer in buffers {
|
|
if project::File::from_dyn(buffer.read(cx).file()).is_some() {
|
|
tasks.push(git_store.open_display_diff(buffer, cx));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
let editor = cx.entity();
|
|
let buffer = self.buffer.clone();
|
|
cx.spawn(async move |_, cx| {
|
|
let diffs = future::join_all(tasks).await;
|
|
if editor.read_with(cx, |editor, _cx| editor.diff_hunk_delegate.is_some()) {
|
|
return;
|
|
}
|
|
|
|
buffer.update(cx, |buffer, cx| {
|
|
for diff in diffs.into_iter().flatten() {
|
|
buffer.add_diff(diff, cx);
|
|
}
|
|
});
|
|
})
|
|
}
|
|
}
|