From 8e5666762537053a8855c639e9e6249fe6a7691c Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 6 Feb 2026 10:46:23 -0500 Subject: [PATCH] git: Implement `OpenExcerpts` for the left side of the side-by-side diff (#48438) By opening the corresponding positions in the corresponding main buffers. Release Notes: - N/A --- crates/editor/src/editor.rs | 231 ++++++++++++++++++++++-------------- crates/editor/src/split.rs | 175 +++++++++++++++++++-------- 2 files changed, 266 insertions(+), 140 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 712d4c1d814a6eada0279e264266de287a17f8ed..ea5b86055464384c3dfc6c38c1a99bd1e32d8bb5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1175,6 +1175,7 @@ pub struct Editor { disable_expand_excerpt_buttons: bool, delegate_expand_excerpts: bool, delegate_stage_and_restore: bool, + delegate_open_excerpts: bool, show_line_numbers: Option, use_relative_line_numbers: Option, show_git_diff_gutter: Option, @@ -2408,6 +2409,7 @@ impl Editor { disable_expand_excerpt_buttons: !full_mode, delegate_expand_excerpts: false, delegate_stage_and_restore: false, + delegate_open_excerpts: false, show_git_diff_gutter: None, show_code_actions: None, show_runnables: None, @@ -21262,6 +21264,10 @@ impl Editor { self.delegate_stage_and_restore = delegate; } + pub fn set_delegate_open_excerpts(&mut self, delegate: bool) { + self.delegate_open_excerpts = delegate; + } + pub fn set_on_local_selections_changed( &mut self, callback: Option) + 'static>>, @@ -24251,11 +24257,6 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let Some(workspace) = self.workspace() else { - cx.propagate(); - return; - }; - if self.buffer.read(cx).is_singleton() { cx.propagate(); return; @@ -24347,6 +24348,25 @@ impl Editor { } } + if self.delegate_open_excerpts { + let selections_by_buffer: HashMap<_, _> = new_selections_by_buffer + .into_iter() + .map(|(buffer, value)| (buffer.read(cx).remote_id(), value)) + .collect(); + if !selections_by_buffer.is_empty() { + cx.emit(EditorEvent::OpenExcerptsRequested { + selections_by_buffer, + split, + }); + } + return; + } + + let Some(workspace) = self.workspace() else { + cx.propagate(); + return; + }; + new_selections_by_buffer .retain(|buffer, _| buffer.read(cx).file().is_none_or(|file| file.can_open())); @@ -24354,105 +24374,128 @@ impl Editor { return; } + Self::open_buffers_in_workspace( + workspace.downgrade(), + new_selections_by_buffer, + split, + window, + cx, + ); + } + + pub(crate) fn open_buffers_in_workspace( + workspace: WeakEntity, + new_selections_by_buffer: HashMap< + Entity, + (Vec>, Option), + >, + split: bool, + window: &mut Window, + cx: &mut App, + ) { // We defer the pane interaction because we ourselves are a workspace item // and activating a new item causes the pane to call a method on us reentrantly, // which panics if we're on the stack. window.defer(cx, move |window, cx| { - workspace.update(cx, |workspace, cx| { - let pane = if split { - workspace.adjacent_pane(window, cx) - } else { - workspace.active_pane().clone() - }; - - for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { - let buffer_read = buffer.read(cx); - let (has_file, is_project_file) = if let Some(file) = buffer_read.file() { - (true, project::File::from_dyn(Some(file)).is_some()) + workspace + .update(cx, |workspace, cx| { + let pane = if split { + workspace.adjacent_pane(window, cx) } else { - (false, false) + workspace.active_pane().clone() }; - // If project file is none workspace.open_project_item will fail to open the excerpt - // in a pre existing workspace item if one exists, because Buffer entity_id will be None - // so we check if there's a tab match in that case first - let editor = (!has_file || !is_project_file) - .then(|| { - // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, - // so `workspace.open_project_item` will never find them, always opening a new editor. - // Instead, we try to activate the existing editor in the pane first. - let (editor, pane_item_index, pane_item_id) = - pane.read(cx).items().enumerate().find_map(|(i, item)| { - let editor = item.downcast::()?; - let singleton_buffer = - editor.read(cx).buffer().read(cx).as_singleton()?; - if singleton_buffer == buffer { - Some((editor, i, item.item_id())) - } else { - None + for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { + let buffer_read = buffer.read(cx); + let (has_file, is_project_file) = if let Some(file) = buffer_read.file() { + (true, project::File::from_dyn(Some(file)).is_some()) + } else { + (false, false) + }; + + // If project file is none workspace.open_project_item will fail to open the excerpt + // in a pre existing workspace item if one exists, because Buffer entity_id will be None + // so we check if there's a tab match in that case first + let editor = (!has_file || !is_project_file) + .then(|| { + // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, + // so `workspace.open_project_item` will never find them, always opening a new editor. + // Instead, we try to activate the existing editor in the pane first. + let (editor, pane_item_index, pane_item_id) = + pane.read(cx).items().enumerate().find_map(|(i, item)| { + let editor = item.downcast::()?; + let singleton_buffer = + editor.read(cx).buffer().read(cx).as_singleton()?; + if singleton_buffer == buffer { + Some((editor, i, item.item_id())) + } else { + None + } + })?; + pane.update(cx, |pane, cx| { + pane.activate_item(pane_item_index, true, true, window, cx); + if !PreviewTabsSettings::get_global(cx) + .enable_preview_from_multibuffer + { + pane.unpreview_item_if_preview(pane_item_id); } - })?; - pane.update(cx, |pane, cx| { - pane.activate_item(pane_item_index, true, true, window, cx); - if !PreviewTabsSettings::get_global(cx) - .enable_preview_from_multibuffer - { - pane.unpreview_item_if_preview(pane_item_id); - } + }); + Some(editor) + }) + .flatten() + .unwrap_or_else(|| { + let keep_old_preview = PreviewTabsSettings::get_global(cx) + .enable_keep_preview_on_code_navigation; + let allow_new_preview = PreviewTabsSettings::get_global(cx) + .enable_preview_from_multibuffer; + workspace.open_project_item::( + pane.clone(), + buffer, + true, + true, + keep_old_preview, + allow_new_preview, + window, + cx, + ) }); - Some(editor) - }) - .flatten() - .unwrap_or_else(|| { - let keep_old_preview = PreviewTabsSettings::get_global(cx) - .enable_keep_preview_on_code_navigation; - let allow_new_preview = - PreviewTabsSettings::get_global(cx).enable_preview_from_multibuffer; - workspace.open_project_item::( - pane.clone(), - buffer, - true, - true, - keep_old_preview, - allow_new_preview, + + editor.update(cx, |editor, cx| { + if has_file && !is_project_file { + editor.set_read_only(true); + } + let autoscroll = match scroll_offset { + Some(scroll_offset) => { + Autoscroll::top_relative(scroll_offset as usize) + } + None => Autoscroll::newest(), + }; + let nav_history = editor.nav_history.take(); + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let Some((&excerpt_id, _, buffer_snapshot)) = + multibuffer_snapshot.as_singleton() + else { + return; + }; + editor.change_selections( + SelectionEffects::scroll(autoscroll), window, cx, - ) + |s| { + s.select_ranges(ranges.into_iter().map(|range| { + let range = buffer_snapshot.anchor_before(range.start) + ..buffer_snapshot.anchor_after(range.end); + multibuffer_snapshot + .anchor_range_in_excerpt(excerpt_id, range) + .unwrap() + })); + }, + ); + editor.nav_history = nav_history; }); - - editor.update(cx, |editor, cx| { - if has_file && !is_project_file { - editor.set_read_only(true); - } - let autoscroll = match scroll_offset { - Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), - None => Autoscroll::newest(), - }; - let nav_history = editor.nav_history.take(); - let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let Some((&excerpt_id, _, buffer_snapshot)) = - multibuffer_snapshot.as_singleton() - else { - return; - }; - editor.change_selections( - SelectionEffects::scroll(autoscroll), - window, - cx, - |s| { - s.select_ranges(ranges.into_iter().map(|range| { - let range = buffer_snapshot.anchor_before(range.start) - ..buffer_snapshot.anchor_after(range.end); - multibuffer_snapshot - .anchor_range_in_excerpt(excerpt_id, range) - .unwrap() - })); - }, - ); - editor.nav_history = nav_history; - }); - } - }) + } + }) + .ok(); }); } @@ -27565,6 +27608,10 @@ pub enum EditorEvent { stage: bool, hunks: Vec, }, + OpenExcerptsRequested { + selections_by_buffer: HashMap>, Option)>, + split: bool, + }, RestoreRequested { hunks: Vec, }, diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index c1ac7814164224c28435066d4dc01aa445de225e..b9f601760e86fc511e7ee769ea00ab7a1a33a2ce 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -10,12 +10,12 @@ use gpui::{Action, AppContext as _, Entity, EventEmitter, Focusable, Subscriptio use itertools::Itertools; use language::{Buffer, Capability}; use multi_buffer::{ - Anchor, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, MultiBufferDiffHunk, - MultiBufferPoint, MultiBufferSnapshot, PathKey, + Anchor, BufferOffset, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, + MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey, }; use project::Project; use rope::Point; -use text::{OffsetRangeExt as _, Patch, ToPoint as _}; +use text::{BufferId, OffsetRangeExt as _, Patch, ToPoint as _}; use ui::{ App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render, Styled as _, Window, div, @@ -69,6 +69,72 @@ pub(crate) fn convert_rhs_rows_to_lhs( ) } +fn translate_lhs_selections_to_rhs( + selections_by_buffer: &HashMap>, Option)>, + splittable: &SplittableEditor, + cx: &App, +) -> HashMap, (Vec>, Option)> { + let rhs_display_map = splittable.rhs_editor.read(cx).display_map.read(cx); + let Some(companion) = rhs_display_map.companion() else { + return HashMap::default(); + }; + let companion = companion.read(cx); + + let mut translated: HashMap, (Vec>, Option)> = + HashMap::default(); + + for (lhs_buffer_id, (ranges, scroll_offset)) in selections_by_buffer { + let Some(rhs_buffer_id) = companion.lhs_to_rhs_buffer(*lhs_buffer_id) else { + continue; + }; + + let Some(rhs_buffer) = splittable + .rhs_editor + .read(cx) + .buffer() + .read(cx) + .buffer(rhs_buffer_id) + else { + continue; + }; + + let Some(diff) = splittable + .rhs_editor + .read(cx) + .buffer() + .read(cx) + .diff_for(rhs_buffer_id) + else { + continue; + }; + + let diff_snapshot = diff.read(cx).snapshot(cx); + let rhs_buffer_snapshot = rhs_buffer.read(cx).snapshot(); + let base_text_buffer = diff.read(cx).base_text_buffer(); + let base_text_snapshot = base_text_buffer.read(cx).snapshot(); + + let translated_ranges: Vec> = ranges + .iter() + .map(|range| { + let start_point = base_text_snapshot.offset_to_point(range.start.0); + let end_point = base_text_snapshot.offset_to_point(range.end.0); + + let rhs_start = diff_snapshot + .base_text_point_to_buffer_point(start_point, &rhs_buffer_snapshot); + let rhs_end = + diff_snapshot.base_text_point_to_buffer_point(end_point, &rhs_buffer_snapshot); + + BufferOffset(rhs_buffer_snapshot.point_to_offset(rhs_start)) + ..BufferOffset(rhs_buffer_snapshot.point_to_offset(rhs_end)) + }) + .collect(); + + translated.insert(rhs_buffer, (translated_ranges, *scroll_offset)); + } + + translated +} + fn translate_lhs_hunks_to_rhs( lhs_hunks: &[MultiBufferDiffHunk], splittable: &SplittableEditor, @@ -425,6 +491,7 @@ impl SplittableEditor { editor.set_number_deleted_lines(true, cx); editor.set_delegate_expand_excerpts(true); editor.set_delegate_stage_and_restore(true); + editor.set_delegate_open_excerpts(true); editor.set_show_vertical_scrollbar(false, cx); editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); editor @@ -434,57 +501,69 @@ impl SplittableEditor { editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx); }); - let mut subscriptions = vec![ - cx.subscribe( - &lhs_editor, - |this, _, event: &EditorEvent, cx| match event { - EditorEvent::ExpandExcerptsRequested { - excerpt_ids, - lines, - direction, - } => { - if this.lhs.is_some() { - let rhs_display_map = this.rhs_editor.read(cx).display_map.read(cx); - let rhs_ids: Vec<_> = excerpt_ids - .iter() - .filter_map(|id| { - rhs_display_map.companion_excerpt_to_my_excerpt(*id, cx) - }) - .collect(); - this.expand_excerpts(rhs_ids.into_iter(), *lines, *direction, cx); + let mut subscriptions = vec![cx.subscribe_in( + &lhs_editor, + window, + |this, _, event: &EditorEvent, window, cx| match event { + EditorEvent::ExpandExcerptsRequested { + excerpt_ids, + lines, + direction, + } => { + if this.lhs.is_some() { + let rhs_display_map = this.rhs_editor.read(cx).display_map.read(cx); + let rhs_ids: Vec<_> = excerpt_ids + .iter() + .filter_map(|id| { + rhs_display_map.companion_excerpt_to_my_excerpt(*id, cx) + }) + .collect(); + this.expand_excerpts(rhs_ids.into_iter(), *lines, *direction, cx); + } + } + EditorEvent::StageOrUnstageRequested { stage, hunks } => { + if this.lhs.is_some() { + let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); + if !translated.is_empty() { + let stage = *stage; + this.rhs_editor.update(cx, |editor, cx| { + let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id); + for (buffer_id, hunks) in &chunk_by { + editor.do_stage_or_unstage(stage, buffer_id, hunks, cx); + } + }); } } - EditorEvent::StageOrUnstageRequested { stage, hunks } => { - if this.lhs.is_some() { - let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); - if !translated.is_empty() { - let stage = *stage; - this.rhs_editor.update(cx, |editor, cx| { - let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id); - for (buffer_id, hunks) in &chunk_by { - editor.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } - }); - } + } + EditorEvent::RestoreRequested { hunks } => { + if this.lhs.is_some() { + let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); + if !translated.is_empty() { + this.rhs_editor.update(cx, |editor, cx| { + editor.restore_diff_hunks(translated, cx); + }); } } - EditorEvent::RestoreRequested { hunks } => { - if this.lhs.is_some() { - let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); - if !translated.is_empty() { - this.rhs_editor.update(cx, |editor, cx| { - editor.restore_diff_hunks(translated, cx); - }); - } + } + EditorEvent::OpenExcerptsRequested { + selections_by_buffer, + split, + } => { + if this.lhs.is_some() { + let translated = + translate_lhs_selections_to_rhs(selections_by_buffer, this, cx); + if !translated.is_empty() { + let workspace = this.workspace.clone(); + let split = *split; + Editor::open_buffers_in_workspace( + workspace, translated, split, window, cx, + ); } } - _ => cx.emit(event.clone()), - }, - ), - cx.subscribe(&lhs_editor, |_, _, event: &SearchEvent, cx| { - cx.emit(event.clone()); - }), - ]; + } + _ => cx.emit(event.clone()), + }, + )]; let lhs_focus_handle = lhs_editor.read(cx).focus_handle(cx); subscriptions.push(