Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:30:15.864Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

text_diff_view.rs

949 lines · 31.0 KB · rust
1//! TextDiffView currently provides a UI for displaying differences between the clipboard and selected text.
2
3use anyhow::Result;
4use buffer_diff::BufferDiff;
5use editor::{
6    Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate,
7    SplittableEditor, ToPoint, actions::DiffClipboardWithSelectionData,
8};
9use futures::{FutureExt, select_biased};
10use gpui::{
11    AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
12    Focusable, IntoElement, Render, Task, Window,
13};
14use language::{self, Buffer, Capability, OffsetRangeExt, Point};
15use project::{Project, ProjectPath};
16use settings::Settings;
17use std::{
18    any::{Any, TypeId},
19    cmp,
20    ops::Range,
21    pin::pin,
22    sync::Arc,
23    time::Duration,
24};
25use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
26use util::paths::PathExt;
27
28use workspace::{
29    Item, ItemNavHistory, Workspace,
30    item::{ItemEvent, SaveOptions, TabContentParams},
31    searchable::SearchableItemHandle,
32};
33
34pub struct TextDiffView {
35    diff_editor: Entity<SplittableEditor>,
36    title: SharedString,
37    path: Option<SharedString>,
38    buffer_changes_tx: watch::Sender<()>,
39    _recalculate_diff_task: Task<Result<()>>,
40}
41
42const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
43
44impl TextDiffView {
45    pub fn open(
46        diff_data: &DiffClipboardWithSelectionData,
47        workspace: &Workspace,
48        window: &mut Window,
49        cx: &mut App,
50    ) -> Option<Task<Result<Entity<Self>>>> {
51        let source_editor = diff_data.editor.clone();
52
53        let selection_data = source_editor.update(cx, |editor, cx| {
54            let multibuffer = editor.buffer();
55            let multibuffer_snapshot = multibuffer.read(cx).snapshot(cx);
56            let first_selection = editor.selections.newest_anchor();
57
58            let (source_buffer, buffer_range) = multibuffer_snapshot
59                .anchor_range_to_buffer_anchor_range(first_selection.range())?;
60            let max_point = source_buffer.max_point();
61            let buffer_range = buffer_range.to_point(source_buffer);
62            let source_buffer = multibuffer.read(cx).buffer(source_buffer.remote_id())?;
63
64            if buffer_range.is_empty() {
65                let full_range = Point::new(0, 0)..max_point;
66                return Some((source_buffer, full_range));
67            }
68
69            let expanded_start = Point::new(buffer_range.start.row, 0);
70            let expanded_end = if buffer_range.end.column > 0 {
71                let next_row = buffer_range.end.row + 1;
72                cmp::min(max_point, Point::new(next_row, 0))
73            } else {
74                buffer_range.end
75            };
76            Some((source_buffer, expanded_start..expanded_end))
77        });
78
79        let Some((source_buffer, expanded_selection_range)) = selection_data else {
80            log::warn!("There should always be at least one selection in Omega. This is a bug.");
81            return None;
82        };
83
84        source_editor.update(cx, |source_editor, cx| {
85            let multibuffer = source_editor.buffer();
86            let mb_range = {
87                let mb = multibuffer.read(cx);
88                let start_anchor =
89                    mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.start, cx);
90                let end_anchor =
91                    mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.end, cx);
92                start_anchor.zip(end_anchor).map(|(s, e)| {
93                    let snapshot = mb.snapshot(cx);
94                    s.to_point(&snapshot)..e.to_point(&snapshot)
95                })
96            };
97
98            if let Some(range) = mb_range {
99                source_editor.change_selections(Default::default(), window, cx, |s| {
100                    s.select_ranges(vec![range]);
101                });
102            }
103        });
104
105        let source_buffer_snapshot = source_buffer.read(cx).snapshot();
106        let mut clipboard_text = diff_data.clipboard_text.clone();
107
108        if !clipboard_text.ends_with("\n") {
109            clipboard_text.push_str("\n");
110        }
111
112        let workspace = workspace.weak_handle();
113        let clipboard_buffer = build_clipboard_buffer(
114            clipboard_text,
115            &source_buffer,
116            expanded_selection_range.clone(),
117            cx,
118        );
119        let diff_buffer = cx.new(|cx| {
120            BufferDiff::new_with_base_text_buffer(
121                &source_buffer_snapshot.text,
122                clipboard_buffer.clone(),
123                cx,
124            )
125        });
126
127        let task = window.spawn(cx, async move |cx| {
128            update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await;
129
130            workspace.update_in(cx, |workspace, window, cx| {
131                let project = workspace.project().clone();
132                let workspace_entity = cx.entity();
133                let diff_view = cx.new(|cx| {
134                    TextDiffView::new(
135                        clipboard_buffer,
136                        source_editor,
137                        source_buffer,
138                        expanded_selection_range,
139                        diff_buffer,
140                        project,
141                        workspace_entity,
142                        window,
143                        cx,
144                    )
145                });
146
147                let pane = workspace.active_pane();
148                pane.update(cx, |pane, cx| {
149                    pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
150                });
151
152                diff_view
153            })
154        });
155
156        Some(task)
157    }
158
159    pub fn new(
160        clipboard_buffer: Entity<Buffer>,
161        source_editor: Entity<Editor>,
162        source_buffer: Entity<Buffer>,
163        source_range: Range<Point>,
164        diff_buffer: Entity<BufferDiff>,
165        project: Entity<Project>,
166        workspace: Entity<Workspace>,
167        window: &mut Window,
168        cx: &mut Context<Self>,
169    ) -> Self {
170        let multibuffer = cx.new(|cx| {
171            let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
172
173            multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
174
175            multibuffer.add_diff(diff_buffer.clone(), cx);
176            multibuffer
177        });
178        let diff_editor = cx.new(|cx| {
179            let splittable = SplittableEditor::new(
180                EditorSettings::get_global(cx).diff_view_style,
181                multibuffer,
182                project,
183                workspace,
184                window,
185                cx,
186            );
187            splittable
188                .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx);
189            splittable
190        });
191
192        let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
193
194        cx.subscribe(&source_buffer, move |this, _, event, _| match event {
195            language::BufferEvent::Edited { .. }
196            | language::BufferEvent::LanguageChanged(_)
197            | language::BufferEvent::Reparsed => {
198                this.buffer_changes_tx.send(()).ok();
199            }
200            _ => {}
201        })
202        .detach();
203
204        let editor = source_editor.read(cx);
205        let title = editor.buffer().read(cx).title(cx).to_string();
206        let selection_location_text = selection_location_text(editor, cx);
207        let selection_location_title = selection_location_text
208            .as_ref()
209            .map(|text| format!("{} @ {}", title, text))
210            .unwrap_or(title);
211
212        let path = editor
213            .buffer()
214            .read(cx)
215            .as_singleton()
216            .and_then(|b| {
217                b.read(cx)
218                    .file()
219                    .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
220            })
221            .unwrap_or(MultiBuffer::DEFAULT_TITLE.into());
222
223        let selection_location_path = selection_location_text
224            .map(|text| format!("{} @ {}", path, text))
225            .unwrap_or(path);
226
227        Self {
228            diff_editor,
229            title: format!("Clipboard ↔ {selection_location_title}").into(),
230            path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
231            buffer_changes_tx,
232            _recalculate_diff_task: cx.spawn(async move |_, cx| {
233                while buffer_changes_rx.recv().await.is_ok() {
234                    loop {
235                        let mut timer = cx
236                            .background_executor()
237                            .timer(RECALCULATE_DIFF_DEBOUNCE)
238                            .fuse();
239                        let mut recv = pin!(buffer_changes_rx.recv().fuse());
240                        select_biased! {
241                            _ = timer => break,
242                            _ = recv => continue,
243                        }
244                    }
245
246                    log::trace!("start recalculating");
247                    update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await;
248                    log::trace!("finish recalculating");
249                }
250                Ok(())
251            }),
252        }
253    }
254}
255
256fn build_clipboard_buffer(
257    text: String,
258    source_buffer: &Entity<Buffer>,
259    replacement_range: Range<Point>,
260    cx: &mut App,
261) -> Entity<Buffer> {
262    let source_buffer_snapshot = source_buffer.read(cx).snapshot();
263    cx.new(|cx| {
264        let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
265        let language = source_buffer.read(cx).language().cloned();
266        if let Some(language_registry) = source_buffer.read(cx).language_registry() {
267            buffer.set_language_registry(language_registry);
268        }
269        buffer.set_language(language, cx);
270
271        let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
272        let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
273        buffer.edit([(range_start..range_end, text)], None, cx);
274
275        buffer.set_capability(Capability::ReadOnly, cx);
276
277        buffer
278    })
279}
280
281async fn update_diff_buffer(
282    diff: &Entity<BufferDiff>,
283    source_buffer: &Entity<Buffer>,
284    clipboard_buffer: &Entity<Buffer>,
285    cx: &mut AsyncApp,
286) {
287    let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot());
288    let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot());
289    let base_text = Arc::<str>::from(base_buffer_snapshot.text());
290
291    let update = diff
292        .update(cx, |diff, cx| {
293            diff.update_diff(
294                source_buffer_snapshot.text.clone(),
295                &base_buffer_snapshot,
296                Some(base_text.clone()),
297                cx,
298            )
299        })
300        .await;
301
302    diff.update(cx, |diff, cx| diff.set_snapshot(update, cx));
303}
304
305impl EventEmitter<EditorEvent> for TextDiffView {}
306
307impl Focusable for TextDiffView {
308    fn focus_handle(&self, cx: &App) -> FocusHandle {
309        self.diff_editor.focus_handle(cx)
310    }
311}
312
313impl Item for TextDiffView {
314    type Event = EditorEvent;
315
316    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
317        Some(Icon::new(IconName::Diff).color(Color::Muted))
318    }
319
320    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
321        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
322            .color(if params.selected {
323                Color::Default
324            } else {
325                Color::Muted
326            })
327            .into_any_element()
328    }
329
330    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
331        self.title.clone()
332    }
333
334    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
335        self.path.clone()
336    }
337
338    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
339        Editor::to_item_events(event, f)
340    }
341
342    fn telemetry_event_text(&self) -> Option<&'static str> {
343        Some("Selection Diff View Opened")
344    }
345
346    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
347        self.diff_editor
348            .update(cx, |editor, cx| editor.deactivated(window, cx));
349    }
350
351    fn act_as_type<'a>(
352        &'a self,
353        type_id: TypeId,
354        self_handle: &'a Entity<Self>,
355        cx: &'a App,
356    ) -> Option<gpui::AnyEntity> {
357        if type_id == TypeId::of::<Self>() {
358            Some(self_handle.clone().into())
359        } else if type_id == TypeId::of::<SplittableEditor>() {
360            Some(self.diff_editor.clone().into())
361        } else if type_id == TypeId::of::<Editor>() {
362            Some(self.diff_editor.read(cx).rhs_editor().clone().into())
363        } else {
364            None
365        }
366    }
367
368    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
369        Some(Box::new(self.diff_editor.clone()))
370    }
371
372    fn for_each_project_item(
373        &self,
374        cx: &App,
375        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
376    ) {
377        self.diff_editor.read(cx).for_each_project_item(cx, f)
378    }
379
380    fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
381        self.diff_editor.read(cx).active_project_path(cx)
382    }
383
384    fn set_nav_history(
385        &mut self,
386        nav_history: ItemNavHistory,
387        _: &mut Window,
388        cx: &mut Context<Self>,
389    ) {
390        let rhs = self.diff_editor.read(cx).rhs_editor().clone();
391        rhs.update(cx, |editor, _| {
392            editor.set_nav_history(Some(nav_history));
393        });
394    }
395
396    fn navigate(
397        &mut self,
398        data: Arc<dyn Any + Send>,
399        window: &mut Window,
400        cx: &mut Context<Self>,
401    ) -> bool {
402        self.diff_editor
403            .update(cx, |editor, cx| editor.navigate(data, window, cx))
404    }
405
406    fn added_to_workspace(
407        &mut self,
408        workspace: &mut Workspace,
409        window: &mut Window,
410        cx: &mut Context<Self>,
411    ) {
412        self.diff_editor.update(cx, |editor, cx| {
413            editor.added_to_workspace(workspace, window, cx)
414        });
415    }
416
417    fn can_save(&self, cx: &App) -> bool {
418        // The editor handles the new buffer, so delegate to it
419        self.diff_editor.read(cx).can_save(cx)
420    }
421
422    fn save(
423        &mut self,
424        options: SaveOptions,
425        project: Entity<Project>,
426        window: &mut Window,
427        cx: &mut Context<Self>,
428    ) -> Task<Result<()>> {
429        // Delegate saving to the editor, which manages the new buffer
430        self.diff_editor
431            .update(cx, |editor, cx| editor.save(options, project, window, cx))
432    }
433}
434
435pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
436    let buffer = editor.buffer().read(cx);
437    let buffer_snapshot = buffer.snapshot(cx);
438    let first_selection = editor.selections.disjoint_anchors().first()?;
439
440    let selection_start = first_selection.start.to_point(&buffer_snapshot);
441    let selection_end = first_selection.end.to_point(&buffer_snapshot);
442
443    let start_row = selection_start.row;
444    let start_column = selection_start.column;
445    let end_row = selection_end.row;
446    let end_column = selection_end.column;
447
448    let range_text = if start_row == end_row {
449        format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
450    } else {
451        format!(
452            "L{}:{}-L{}:{}",
453            start_row + 1,
454            start_column + 1,
455            end_row + 1,
456            end_column + 1
457        )
458    };
459
460    Some(range_text)
461}
462
463impl Render for TextDiffView {
464    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
465        self.diff_editor.clone()
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
473    use gpui::{BorrowAppContext, TestAppContext, VisualContext};
474    use language::Point;
475    use project::{FakeFs, Project};
476    use serde_json::json;
477    use settings::{DiffViewStyle, SettingsStore};
478    use unindent::unindent;
479    use util::{path, test::marked_text_ranges};
480    use workspace::MultiWorkspace;
481
482    fn init_test(cx: &mut TestAppContext) {
483        cx.update(|cx| {
484            let settings_store = SettingsStore::test(cx);
485            cx.set_global(settings_store);
486            cx.update_global::<SettingsStore, _>(|store, cx| {
487                store.update_user_settings(cx, |settings| {
488                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
489                });
490            });
491            theme_settings::init(theme::LoadThemes::JustBase, cx);
492        });
493    }
494
495    #[gpui::test]
496    async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
497        cx: &mut TestAppContext,
498    ) {
499        base_test(
500            path!("/test"),
501            path!("/test/text.txt"),
502            "def process_incoming_inventory(items, warehouse_id):\n    pass\n",
503            "def process_outgoing_inventory(items, warehouse_id):\n    passˇ\n",
504            &unindent(
505                "
506                - def process_incoming_inventory(items, warehouse_id):
507                + ˇdef process_outgoing_inventory(items, warehouse_id):
508                      pass
509                ",
510            ),
511            "Clipboard ↔ text.txt @ L1:1-L3:1",
512            &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
513            cx,
514        )
515        .await;
516    }
517
518    #[gpui::test]
519    async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
520        cx: &mut TestAppContext,
521    ) {
522        base_test(
523            path!("/test"),
524            path!("/test/text.txt"),
525            "def process_incoming_inventory(items, warehouse_id):\n    pass\n",
526            "«def process_outgoing_inventory(items, warehouse_id):\n    passˇ»\n",
527            &unindent(
528                "
529                - def process_incoming_inventory(items, warehouse_id):
530                + ˇdef process_outgoing_inventory(items, warehouse_id):
531                      pass
532                ",
533            ),
534            "Clipboard ↔ text.txt @ L1:1-L3:1",
535            &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
536            cx,
537        )
538        .await;
539    }
540
541    #[gpui::test]
542    async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
543        base_test(
544            path!("/test"),
545            path!("/test/text.txt"),
546            "a",
547            "«bbˇ»",
548            &unindent(
549                "
550                - a
551                + ˇbb",
552            ),
553            "Clipboard ↔ text.txt @ L1:1-3",
554            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
555            cx,
556        )
557        .await;
558    }
559
560    #[gpui::test]
561    async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
562        base_test(
563            path!("/test"),
564            path!("/test/text.txt"),
565            "    a",
566            "«bbˇ»",
567            &unindent(
568                "
569                -     a
570                + ˇbb",
571            ),
572            "Clipboard ↔ text.txt @ L1:1-3",
573            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
574            cx,
575        )
576        .await;
577    }
578
579    #[gpui::test]
580    async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
581        base_test(
582            path!("/test"),
583            path!("/test/text.txt"),
584            "a",
585            "    «bbˇ»",
586            &unindent(
587                "
588                - a
589                + ˇ    bb",
590            ),
591            "Clipboard ↔ text.txt @ L1:1-7",
592            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
593            cx,
594        )
595        .await;
596    }
597
598    #[gpui::test]
599    async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
600        cx: &mut TestAppContext,
601    ) {
602        base_test(
603            path!("/test"),
604            path!("/test/text.txt"),
605            "a",
606            "«    bbˇ»",
607            &unindent(
608                "
609                - a
610                + ˇ    bb",
611            ),
612            "Clipboard ↔ text.txt @ L1:1-7",
613            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
614            cx,
615        )
616        .await;
617    }
618
619    #[gpui::test]
620    async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
621        cx: &mut TestAppContext,
622    ) {
623        base_test(
624            path!("/test"),
625            path!("/test/text.txt"),
626            "    a",
627            "    «bbˇ»",
628            &unindent(
629                "
630                -     a
631                + ˇ    bb",
632            ),
633            "Clipboard ↔ text.txt @ L1:1-7",
634            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
635            cx,
636        )
637        .await;
638    }
639
640    #[gpui::test]
641    async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
642        cx: &mut TestAppContext,
643    ) {
644        base_test(
645            path!("/test"),
646            path!("/test/text.txt"),
647            "    a",
648            "«    bbˇ»",
649            &unindent(
650                "
651                -     a
652                + ˇ    bb",
653            ),
654            "Clipboard ↔ text.txt @ L1:1-7",
655            &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
656            cx,
657        )
658        .await;
659    }
660
661    #[gpui::test]
662    async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
663        cx: &mut TestAppContext,
664    ) {
665        base_test(
666            path!("/test"),
667            path!("/test/text.txt"),
668            "a",
669            "«bˇ»b",
670            &unindent(
671                "
672                - a
673                + ˇbb",
674            ),
675            "Clipboard ↔ text.txt @ L1:1-3",
676            &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
677            cx,
678        )
679        .await;
680    }
681
682    #[gpui::test]
683    async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
684        init_test(cx);
685
686        let fs = FakeFs::new(cx.executor());
687        fs.insert_tree(
688            path!("/project"),
689            json!({
690                "a.txt": "alpha\nbeta\ngamma",
691                "b.txt": "one\ntwo\nthree"
692            }),
693        )
694        .await;
695
696        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
697
698        let buffer_a = project
699            .update(cx, |project, cx| {
700                project.open_local_buffer(path!("/project/a.txt"), cx)
701            })
702            .await
703            .unwrap();
704        let buffer_b = project
705            .update(cx, |project, cx| {
706                project.open_local_buffer(path!("/project/b.txt"), cx)
707            })
708            .await
709            .unwrap();
710
711        let (multi_workspace, cx) =
712            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
713        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
714
715        let editor = cx.new_window_entity(|window, cx| {
716            let multibuffer = cx.new(|cx| {
717                let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
718                mb.set_excerpts_for_path(
719                    PathKey::sorted(0),
720                    buffer_a.clone(),
721                    [Point::new(0, 0)..Point::new(2, 5)],
722                    0,
723                    cx,
724                );
725                mb.set_excerpts_for_path(
726                    PathKey::sorted(1),
727                    buffer_b.clone(),
728                    [Point::new(0, 0)..Point::new(2, 5)],
729                    0,
730                    cx,
731                );
732                mb
733            });
734
735            let mut editor =
736                Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
737            // Select "beta" inside the first excerpt
738            editor.change_selections(Default::default(), window, cx, |s| {
739                s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
740            });
741            editor
742        });
743
744        let diff_view = workspace
745            .update_in(cx, |workspace, window, cx| {
746                TextDiffView::open(
747                    &DiffClipboardWithSelectionData {
748                        clipboard_text: "REPLACED".to_string(),
749                        editor,
750                    },
751                    workspace,
752                    window,
753                    cx,
754                )
755            })
756            .unwrap()
757            .await
758            .unwrap();
759
760        cx.executor().run_until_parked();
761
762        diff_view.read_with(cx, |diff_view, _cx| {
763            assert!(
764                diff_view.title.contains("Clipboard"),
765                "diff view should have opened with a clipboard diff title, got: {}",
766                diff_view.title
767            );
768        });
769    }
770
771    #[gpui::test]
772    async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
773        init_test(cx);
774
775        let fs = FakeFs::new(cx.executor());
776        fs.insert_tree(
777            path!("/project"),
778            json!({
779                "a.txt": "alpha\nbeta\ngamma",
780                "b.txt": "one\ntwo\nthree"
781            }),
782        )
783        .await;
784
785        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
786
787        let buffer_a = project
788            .update(cx, |project, cx| {
789                project.open_local_buffer(path!("/project/a.txt"), cx)
790            })
791            .await
792            .unwrap();
793        let buffer_b = project
794            .update(cx, |project, cx| {
795                project.open_local_buffer(path!("/project/b.txt"), cx)
796            })
797            .await
798            .unwrap();
799
800        let (multi_workspace, cx) =
801            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
802        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
803
804        let editor = cx.new_window_entity(|window, cx| {
805            let multibuffer = cx.new(|cx| {
806                let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
807                mb.set_excerpts_for_path(
808                    PathKey::sorted(0),
809                    buffer_a.clone(),
810                    [Point::new(0, 0)..Point::new(2, 5)],
811                    0,
812                    cx,
813                );
814                mb.set_excerpts_for_path(
815                    PathKey::sorted(1),
816                    buffer_b.clone(),
817                    [Point::new(0, 0)..Point::new(2, 5)],
818                    0,
819                    cx,
820                );
821                mb
822            });
823
824            let mut editor =
825                Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
826            // Cursor inside the first excerpt (no selection)
827            editor.change_selections(Default::default(), window, cx, |s| {
828                s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
829            });
830            editor
831        });
832
833        let diff_view = workspace
834            .update_in(cx, |workspace, window, cx| {
835                TextDiffView::open(
836                    &DiffClipboardWithSelectionData {
837                        clipboard_text: "REPLACED".to_string(),
838                        editor,
839                    },
840                    workspace,
841                    window,
842                    cx,
843                )
844            })
845            .unwrap()
846            .await
847            .unwrap();
848
849        cx.executor().run_until_parked();
850
851        // Empty selection should diff the full underlying buffer
852        diff_view.read_with(cx, |diff_view, _cx| {
853            assert!(
854                diff_view.title.contains("Clipboard"),
855                "diff view should have opened with a clipboard diff title, got: {}",
856                diff_view.title
857            );
858        });
859    }
860
861    async fn base_test(
862        project_root: &str,
863        file_path: &str,
864        clipboard_text: &str,
865        editor_text: &str,
866        expected_diff: &str,
867        expected_tab_title: &str,
868        expected_tab_tooltip: &str,
869        cx: &mut TestAppContext,
870    ) {
871        init_test(cx);
872
873        let file_name = std::path::Path::new(file_path)
874            .file_name()
875            .unwrap()
876            .to_str()
877            .unwrap();
878
879        let fs = FakeFs::new(cx.executor());
880        fs.insert_tree(
881            project_root,
882            json!({
883                file_name: editor_text
884            }),
885        )
886        .await;
887
888        let project = Project::test(fs, [project_root.as_ref()], cx).await;
889
890        let (multi_workspace, cx) =
891            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
892        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
893
894        let buffer = project
895            .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
896            .await
897            .unwrap();
898
899        let editor = cx.new_window_entity(|window, cx| {
900            let mut editor = Editor::for_buffer(buffer, None, window, cx);
901            let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
902            editor.set_text(unmarked_text, window, cx);
903            editor.change_selections(Default::default(), window, cx, |s| {
904                s.select_ranges(
905                    selection_ranges
906                        .into_iter()
907                        .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
908                )
909            });
910
911            editor
912        });
913
914        let diff_view = workspace
915            .update_in(cx, |workspace, window, cx| {
916                TextDiffView::open(
917                    &DiffClipboardWithSelectionData {
918                        clipboard_text: clipboard_text.to_string(),
919                        editor,
920                    },
921                    workspace,
922                    window,
923                    cx,
924                )
925            })
926            .unwrap()
927            .await
928            .unwrap();
929
930        cx.executor().run_until_parked();
931
932        assert_state_with_diff(
933            &diff_view.read_with(cx, |diff_view, cx| {
934                diff_view.diff_editor.read(cx).rhs_editor().clone()
935            }),
936            cx,
937            expected_diff,
938        );
939
940        diff_view.read_with(cx, |diff_view, cx| {
941            assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
942            assert_eq!(
943                diff_view.tab_tooltip_text(cx).unwrap(),
944                expected_tab_tooltip
945            );
946        });
947    }
948}
949
Served at tenant.openagents/omega Member data and write actions are omitted.