From eb9103e667895745c969b48d914372533a625533 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 5 Feb 2026 23:45:01 +0000 Subject: [PATCH] git: Side-by-side diff searching (#48539) Searching (and related vim stuff like `*`/`n`/`N`) now work in the LHS of a split diff. Also fixes the bug with indent guides being visible through the spacer checkerboard pattern. Release Notes: - N/A --- crates/editor/src/display_map.rs | 2 + crates/editor/src/element.rs | 20 +- crates/editor/src/split.rs | 359 ++++++++++++++++++++++++----- crates/git_ui/src/project_diff.rs | 5 +- crates/workspace/src/searchable.rs | 2 +- 5 files changed, 325 insertions(+), 63 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index f381d47d5955684d0519ab222300c91919e25340..28e23b263d0f78d8cbe216d90b63802d21e4aa81 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -227,6 +227,8 @@ pub struct DisplayMap { pub(crate) companion: Option<(WeakEntity, Entity)>, } +// test change + pub(crate) struct Companion { rhs_display_map_id: EntityId, rhs_folded_buffers: HashSet, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index ed48b9ab5ec333c53a0413790bec2747c3859bae..53b3c51b9f666083a196b87442ce34c65e7612c7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4008,11 +4008,21 @@ impl EditorElement { .id(block_id) .w_full() .h((*height as f32) * line_height) - .bg(checkerboard(cx.theme().colors().panel_background, { - let target_size = 16.0; - let scale = window.scale_factor(); - Self::checkerboard_size(f32::from(line_height) * scale, target_size * scale) - })) + // the checkerboard pattern is semi-transparent, so we render a + // solid background to prevent indent guides peeking through + .bg(cx.theme().colors().editor_background) + .child( + div() + .size_full() + .bg(checkerboard(cx.theme().colors().panel_background, { + let target_size = 16.0; + let scale = window.scale_factor(); + Self::checkerboard_size( + f32::from(line_height) * scale, + target_size * scale, + ) + })), + ) .into_any(), }; diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 193dd64a77da4b2e8552d275e749c832d569d1bc..f33636acf999904ccec9685e3238594dfe10de64 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -1,4 +1,7 @@ -use std::ops::{Bound, Range, RangeInclusive}; +use std::{ + ops::{Bound, Range, RangeInclusive}, + sync::Arc, +}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use collections::HashMap; @@ -22,7 +25,11 @@ use crate::{ display_map::CompanionExcerptPatch, split_editor_view::{SplitEditorState, SplitEditorView}, }; -use workspace::{ActivatePaneLeft, ActivatePaneRight, Item, Workspace}; +use workspace::{ + ActivatePaneLeft, ActivatePaneRight, Item, ToolbarItemLocation, Workspace, + item::{BreadcrumbText, ItemBufferKind, ItemEvent, SaveOptions, TabContentParams}, + searchable::{SearchEvent, SearchableItem, SearchableItemHandle}, +}; use crate::{ Autoscroll, DisplayMap, Editor, EditorEvent, RenderDiffHunkControlsFn, ToggleCodeActions, @@ -292,7 +299,7 @@ pub struct SplittableEditor { struct LhsEditor { multibuffer: Entity, editor: Entity, - has_latest_selection: bool, + was_last_focused: bool, _subscriptions: Vec, } @@ -327,7 +334,7 @@ impl SplittableEditor { pub fn last_selected_editor(&self) -> &Entity { if let Some(lhs) = &self.lhs - && lhs.has_latest_selection + && lhs.was_last_focused { &lhs.editor } else { @@ -349,8 +356,8 @@ impl SplittableEditor { editor }); // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs - let subscriptions = - vec![cx.subscribe( + let subscriptions = vec![ + cx.subscribe( &rhs_editor, |this, _, event: &EditorEvent, cx| match event { EditorEvent::ExpandExcerptsRequested { @@ -360,15 +367,13 @@ impl SplittableEditor { } => { this.expand_excerpts(excerpt_ids.iter().copied(), *lines, *direction, cx); } - EditorEvent::SelectionsChanged { .. } => { - if let Some(lhs) = &mut this.lhs { - lhs.has_latest_selection = false; - } - cx.emit(event.clone()); - } _ => cx.emit(event.clone()), }, - )]; + ), + cx.subscribe(&rhs_editor, |_, _, event: &SearchEvent, cx| { + cx.emit(event.clone()); + }), + ]; window.defer(cx, { let workspace = workspace.downgrade(); @@ -429,8 +434,8 @@ impl SplittableEditor { editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx); }); - let subscriptions = - vec![cx.subscribe( + let mut subscriptions = vec![ + cx.subscribe( &lhs_editor, |this, _, event: &EditorEvent, cx| match event { EditorEvent::ExpandExcerptsRequested { @@ -473,19 +478,42 @@ impl SplittableEditor { } } } - EditorEvent::SelectionsChanged { .. } => { - if let Some(lhs) = &mut this.lhs { - lhs.has_latest_selection = true; - } - cx.emit(event.clone()); - } _ => cx.emit(event.clone()), }, - )]; + ), + cx.subscribe(&lhs_editor, |_, _, event: &SearchEvent, cx| { + cx.emit(event.clone()); + }), + ]; + + let lhs_focus_handle = lhs_editor.read(cx).focus_handle(cx); + subscriptions.push( + cx.on_focus_in(&lhs_focus_handle, window, |this, _window, cx| { + if let Some(lhs) = &mut this.lhs { + if !lhs.was_last_focused { + lhs.was_last_focused = true; + cx.notify(); + } + } + }), + ); + + let rhs_focus_handle = self.rhs_editor.read(cx).focus_handle(cx); + subscriptions.push( + cx.on_focus_in(&rhs_focus_handle, window, |this, _window, cx| { + if let Some(lhs) = &mut this.lhs { + if lhs.was_last_focused { + lhs.was_last_focused = false; + cx.notify(); + } + } + }), + ); + let mut lhs = LhsEditor { editor: lhs_editor, multibuffer: lhs_multibuffer, - has_latest_selection: false, + was_last_focused: false, _subscriptions: subscriptions, }; let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); @@ -608,14 +636,12 @@ impl SplittableEditor { window: &mut Window, cx: &mut Context, ) { - if let Some(lhs) = &mut self.lhs { - if !lhs.has_latest_selection { + if let Some(lhs) = &self.lhs { + if !lhs.was_last_focused { lhs.editor.read(cx).focus_handle(cx).focus(window, cx); lhs.editor.update(cx, |editor, cx| { editor.request_autoscroll(Autoscroll::fit(), cx); }); - lhs.has_latest_selection = true; - cx.notify(); } else { cx.propagate(); } @@ -630,14 +656,12 @@ impl SplittableEditor { window: &mut Window, cx: &mut Context, ) { - if let Some(lhs) = &mut self.lhs { - if lhs.has_latest_selection { + if let Some(lhs) = &self.lhs { + if lhs.was_last_focused { self.rhs_editor.read(cx).focus_handle(cx).focus(window, cx); self.rhs_editor.update(cx, |editor, cx| { editor.request_autoscroll(Autoscroll::fit(), cx); }); - lhs.has_latest_selection = false; - cx.notify(); } else { cx.propagate(); } @@ -738,7 +762,7 @@ impl SplittableEditor { ) { // Only block breakpoint actions when the left (lhs) editor has focus if let Some(lhs) = &self.lhs { - if lhs.has_latest_selection { + if lhs.was_last_focused { cx.stop_propagation(); } else { cx.propagate(); @@ -756,7 +780,7 @@ impl SplittableEditor { ) { // Only block breakpoint actions when the left (lhs) editor has focus if let Some(lhs) = &self.lhs { - if lhs.has_latest_selection { + if lhs.was_last_focused { cx.stop_propagation(); } else { cx.propagate(); @@ -774,7 +798,7 @@ impl SplittableEditor { ) { // Only block breakpoint actions when the left (lhs) editor has focus if let Some(lhs) = &self.lhs { - if lhs.has_latest_selection { + if lhs.was_last_focused { cx.stop_propagation(); } else { cx.propagate(); @@ -792,7 +816,7 @@ impl SplittableEditor { ) { // Only block breakpoint actions when the left (lhs) editor has focus if let Some(lhs) = &self.lhs { - if lhs.has_latest_selection { + if lhs.was_last_focused { cx.stop_propagation(); } else { cx.propagate(); @@ -824,7 +848,7 @@ impl SplittableEditor { if let Some(lhs) = &self.lhs { cx.stop_propagation(); - let is_lhs_focused = lhs.has_latest_selection; + let is_lhs_focused = lhs.was_last_focused; let (focused_editor, other_editor) = if is_lhs_focused { (&lhs.editor, &self.rhs_editor) } else { @@ -878,23 +902,6 @@ impl SplittableEditor { cx.notify(); } - pub fn added_to_workspace( - &mut self, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - self.workspace = workspace.weak_handle(); - self.rhs_editor.update(cx, |rhs_editor, cx| { - rhs_editor.added_to_workspace(workspace, window, cx); - }); - if let Some(lhs) = &self.lhs { - lhs.editor.update(cx, |lhs_editor, cx| { - lhs_editor.added_to_workspace(workspace, window, cx); - }); - } - } - pub fn set_excerpts_for_path( &mut self, path: PathKey, @@ -1486,13 +1493,257 @@ impl SplittableEditor { } } +impl Item for SplittableEditor { + type Event = EditorEvent; + + fn tab_content_text(&self, detail: usize, cx: &App) -> ui::SharedString { + self.rhs_editor.read(cx).tab_content_text(detail, cx) + } + + fn tab_tooltip_text(&self, cx: &App) -> Option { + self.rhs_editor.read(cx).tab_tooltip_text(cx) + } + + fn tab_icon(&self, window: &Window, cx: &App) -> Option { + self.rhs_editor.read(cx).tab_icon(window, cx) + } + + fn tab_content(&self, params: TabContentParams, window: &Window, cx: &App) -> gpui::AnyElement { + self.rhs_editor.read(cx).tab_content(params, window, cx) + } + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.rhs_editor.read(cx).for_each_project_item(cx, f) + } + + fn buffer_kind(&self, cx: &App) -> ItemBufferKind { + self.rhs_editor.read(cx).buffer_kind(cx) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.rhs_editor.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.rhs_editor.read(cx).has_conflict(cx) + } + + fn has_deleted_file(&self, cx: &App) -> bool { + self.rhs_editor.read(cx).has_deleted_file(cx) + } + + fn capability(&self, cx: &App) -> language::Capability { + self.rhs_editor.read(cx).capability(cx) + } + + fn can_save(&self, cx: &App) -> bool { + self.rhs_editor.read(cx).can_save(cx) + } + + fn can_save_as(&self, cx: &App) -> bool { + self.rhs_editor.read(cx).can_save_as(cx) + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Task> { + self.rhs_editor + .update(cx, |editor, cx| editor.save(options, project, window, cx)) + } + + fn save_as( + &mut self, + project: Entity, + path: project::ProjectPath, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Task> { + self.rhs_editor + .update(cx, |editor, cx| editor.save_as(project, path, window, cx)) + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Task> { + self.rhs_editor + .update(cx, |editor, cx| editor.reload(project, window, cx)) + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.last_selected_editor() + .update(cx, |editor, cx| editor.navigate(data, window, cx)) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.last_selected_editor().update(cx, |editor, cx| { + editor.deactivated(window, cx); + }); + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace = workspace.weak_handle(); + self.rhs_editor.update(cx, |rhs_editor, cx| { + rhs_editor.added_to_workspace(workspace, window, cx); + }); + if let Some(lhs) = &self.lhs { + lhs.editor.update(cx, |lhs_editor, cx| { + lhs_editor.added_to_workspace(workspace, window, cx); + }); + } + } + + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { + Some(Box::new(handle.clone())) + } + + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + self.rhs_editor.read(cx).breadcrumb_location(cx) + } + + fn breadcrumbs(&self, cx: &App) -> Option> { + self.rhs_editor.read(cx).breadcrumbs(cx) + } + + fn pixel_position_of_cursor(&self, cx: &App) -> Option> { + self.last_selected_editor() + .read(cx) + .pixel_position_of_cursor(cx) + } +} + +impl SearchableItem for SplittableEditor { + type Match = Range; + + fn clear_matches(&mut self, window: &mut Window, cx: &mut Context) { + self.last_selected_editor().update(cx, |editor, cx| { + editor.clear_matches(window, cx); + }); + } + + fn update_matches( + &mut self, + matches: &[Self::Match], + active_match_index: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.last_selected_editor().update(cx, |editor, cx| { + editor.update_matches(matches, active_match_index, window, cx); + }); + } + + fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { + self.last_selected_editor() + .update(cx, |editor, cx| editor.query_suggestion(window, cx)) + } + + fn activate_match( + &mut self, + index: usize, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.last_selected_editor().update(cx, |editor, cx| { + editor.activate_match(index, matches, window, cx); + }); + } + + fn select_matches( + &mut self, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.last_selected_editor().update(cx, |editor, cx| { + editor.select_matches(matches, window, cx); + }); + } + + fn replace( + &mut self, + identifier: &Self::Match, + query: &project::search::SearchQuery, + window: &mut Window, + cx: &mut Context, + ) { + self.last_selected_editor().update(cx, |editor, cx| { + editor.replace(identifier, query, window, cx); + }); + } + + fn find_matches( + &mut self, + query: Arc, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Task> { + self.last_selected_editor() + .update(cx, |editor, cx| editor.find_matches(query, window, cx)) + } + + fn active_match_index( + &mut self, + direction: workspace::searchable::Direction, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) -> Option { + self.last_selected_editor().update(cx, |editor, cx| { + editor.active_match_index(direction, matches, window, cx) + }) + } +} + impl EventEmitter for SplittableEditor {} +impl EventEmitter for SplittableEditor {} impl Focusable for SplittableEditor { fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.rhs_editor.read(cx).focus_handle(cx) + self.last_selected_editor().read(cx).focus_handle(cx) } } +// impl Item for SplittableEditor { +// type Event = EditorEvent; + +// fn tab_content_text(&self, detail: usize, cx: &App) -> ui::SharedString { +// self.rhs_editor().tab_content_text(detail, cx) +// } + +// fn as_searchable(&self, _this: &Entity, cx: &App) -> Option> { +// Some(Box::new(self.last_selected_editor().clone())) +// } +// } + impl Render for SplittableEditor { fn render( &mut self, diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 80f6abb4a693aeb35f4dd0e81f1f9e236bd612ff..23e948aeaa85779ccf47d5f402b8de27c59d4e5b 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -874,9 +874,8 @@ impl Item for ProjectDiff { Some("Project Diff Opened") } - fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { - // TODO(split-diff) SplitEditor should be searchable - Some(Box::new(self.editor.read(cx).rhs_editor().clone())) + fn as_searchable(&self, _: &Entity, _cx: &App) -> Option> { + Some(Box::new(self.editor.clone())) } fn for_each_project_item( diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 097bd7abe16978bf1b7b5d99b146f600ff602546..06fc02d6d5368651d8bfa533a7f901e3cc977631 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -18,7 +18,7 @@ pub enum CollapseDirection { Expanded, } -#[derive(Clone, Debug)] +#[derive(Debug, Clone)] pub enum SearchEvent { MatchesInvalidated, ActiveMatchChanged,