1//! TextDiffView currently provides a UI for displaying differences between the clipboard and selected text.
2
3use anyhow::Result;
4use buffer_diff::{BufferDiff, BufferDiffSnapshot};
5use editor::{Editor, EditorEvent, MultiBuffer, ToPoint, actions::DiffClipboardWithSelectionData};
6use futures::{FutureExt, select_biased};
7use gpui::{
8 AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
9 FocusHandle, Focusable, IntoElement, Render, Task, Window,
10};
11use language::{self, Buffer, Point};
12use project::Project;
13use std::{
14 any::{Any, TypeId},
15 cmp,
16 ops::Range,
17 pin::pin,
18 rc::Rc,
19 sync::Arc,
20 time::Duration,
21};
22use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
23use util::paths::PathExt;
24
25use workspace::{
26 Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
27 item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
28 searchable::SearchableItemHandle,
29};
30
31pub struct TextDiffView {
32 diff_editor: Entity<Editor>,
33 title: SharedString,
34 path: Option<SharedString>,
35 buffer_changes_tx: watch::Sender<()>,
36 _recalculate_diff_task: Task<Result<()>>,
37}
38
39const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
40
41impl TextDiffView {
42 pub fn open(
43 diff_data: &DiffClipboardWithSelectionData,
44 workspace: &Workspace,
45 window: &mut Window,
46 cx: &mut App,
47 ) -> Option<Task<Result<Entity<Self>>>> {
48 let source_editor = diff_data.editor.clone();
49
50 let selection_data = source_editor.update(cx, |editor, cx| {
51 let multibuffer = editor.buffer().read(cx);
52 let source_buffer = multibuffer.as_singleton()?;
53 let selections = editor.selections.all::<Point>(&editor.display_snapshot(cx));
54 let buffer_snapshot = source_buffer.read(cx);
55 let first_selection = selections.first()?;
56 let max_point = buffer_snapshot.max_point();
57
58 if first_selection.is_empty() {
59 let full_range = Point::new(0, 0)..max_point;
60 return Some((source_buffer, full_range));
61 }
62
63 let start = first_selection.start;
64 let end = first_selection.end;
65 let expanded_start = Point::new(start.row, 0);
66
67 let expanded_end = if end.column > 0 {
68 let next_row = end.row + 1;
69 cmp::min(max_point, Point::new(next_row, 0))
70 } else {
71 end
72 };
73 Some((source_buffer, expanded_start..expanded_end))
74 });
75
76 let Some((source_buffer, expanded_selection_range)) = selection_data else {
77 log::warn!("There should always be at least one selection in Zed. This is a bug.");
78 return None;
79 };
80
81 source_editor.update(cx, |source_editor, cx| {
82 source_editor.change_selections(Default::default(), window, cx, |s| {
83 s.select_ranges(vec![
84 expanded_selection_range.start..expanded_selection_range.end,
85 ]);
86 })
87 });
88
89 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
90 let mut clipboard_text = diff_data.clipboard_text.clone();
91
92 if !clipboard_text.ends_with("\n") {
93 clipboard_text.push_str("\n");
94 }
95
96 let workspace = workspace.weak_handle();
97 let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
98 let clipboard_buffer = build_clipboard_buffer(
99 clipboard_text,
100 &source_buffer,
101 expanded_selection_range.clone(),
102 cx,
103 );
104
105 let task = window.spawn(cx, async move |cx| {
106 let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
107
108 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
109
110 workspace.update_in(cx, |workspace, window, cx| {
111 let diff_view = cx.new(|cx| {
112 TextDiffView::new(
113 clipboard_buffer,
114 source_editor,
115 source_buffer,
116 expanded_selection_range,
117 diff_buffer,
118 project,
119 window,
120 cx,
121 )
122 });
123
124 let pane = workspace.active_pane();
125 pane.update(cx, |pane, cx| {
126 pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
127 });
128
129 diff_view
130 })
131 });
132
133 Some(task)
134 }
135
136 pub fn new(
137 clipboard_buffer: Entity<Buffer>,
138 source_editor: Entity<Editor>,
139 source_buffer: Entity<Buffer>,
140 source_range: Range<Point>,
141 diff_buffer: Entity<BufferDiff>,
142 project: Entity<Project>,
143 window: &mut Window,
144 cx: &mut Context<Self>,
145 ) -> Self {
146 let multibuffer = cx.new(|cx| {
147 let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
148
149 multibuffer.push_excerpts(
150 source_buffer.clone(),
151 [editor::ExcerptRange::new(source_range)],
152 cx,
153 );
154
155 multibuffer.add_diff(diff_buffer.clone(), cx);
156 multibuffer
157 });
158 let diff_editor = cx.new(|cx| {
159 let mut editor = Editor::for_multibuffer(multibuffer, Some(project), window, cx);
160 editor.start_temporary_diff_override();
161 editor.disable_diagnostics(cx);
162 editor.set_expand_all_diff_hunks(cx);
163 editor.set_render_diff_hunk_controls(
164 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
165 cx,
166 );
167 editor
168 });
169
170 let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
171
172 cx.subscribe(&source_buffer, move |this, _, event, _| match event {
173 language::BufferEvent::Edited
174 | language::BufferEvent::LanguageChanged
175 | language::BufferEvent::Reparsed => {
176 this.buffer_changes_tx.send(()).ok();
177 }
178 _ => {}
179 })
180 .detach();
181
182 let editor = source_editor.read(cx);
183 let title = editor.buffer().read(cx).title(cx).to_string();
184 let selection_location_text = selection_location_text(editor, cx);
185 let selection_location_title = selection_location_text
186 .as_ref()
187 .map(|text| format!("{} @ {}", title, text))
188 .unwrap_or(title);
189
190 let path = editor
191 .buffer()
192 .read(cx)
193 .as_singleton()
194 .and_then(|b| {
195 b.read(cx)
196 .file()
197 .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
198 })
199 .unwrap_or("untitled".into());
200
201 let selection_location_path = selection_location_text
202 .map(|text| format!("{} @ {}", path, text))
203 .unwrap_or(path);
204
205 Self {
206 diff_editor,
207 title: format!("Clipboard ↔ {selection_location_title}").into(),
208 path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
209 buffer_changes_tx,
210 _recalculate_diff_task: cx.spawn(async move |_, cx| {
211 while buffer_changes_rx.recv().await.is_ok() {
212 loop {
213 let mut timer = cx
214 .background_executor()
215 .timer(RECALCULATE_DIFF_DEBOUNCE)
216 .fuse();
217 let mut recv = pin!(buffer_changes_rx.recv().fuse());
218 select_biased! {
219 _ = timer => break,
220 _ = recv => continue,
221 }
222 }
223
224 log::trace!("start recalculating");
225 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
226 log::trace!("finish recalculating");
227 }
228 Ok(())
229 }),
230 }
231 }
232}
233
234fn build_clipboard_buffer(
235 text: String,
236 source_buffer: &Entity<Buffer>,
237 replacement_range: Range<Point>,
238 cx: &mut App,
239) -> Entity<Buffer> {
240 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
241 cx.new(|cx| {
242 let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
243 let language = source_buffer.read(cx).language().cloned();
244 buffer.set_language(language, cx);
245
246 let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
247 let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
248 buffer.edit([(range_start..range_end, text)], None, cx);
249
250 buffer
251 })
252}
253
254async fn update_diff_buffer(
255 diff: &Entity<BufferDiff>,
256 source_buffer: &Entity<Buffer>,
257 clipboard_buffer: &Entity<Buffer>,
258 cx: &mut AsyncApp,
259) -> Result<()> {
260 let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
261
262 let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
263 let base_text = base_buffer_snapshot.text();
264
265 let diff_snapshot = cx
266 .update(|cx| {
267 BufferDiffSnapshot::new_with_base_buffer(
268 source_buffer_snapshot.text.clone(),
269 Some(Arc::new(base_text)),
270 base_buffer_snapshot,
271 cx,
272 )
273 })?
274 .await;
275
276 diff.update(cx, |diff, cx| {
277 diff.set_snapshot(diff_snapshot, &source_buffer_snapshot.text, cx);
278 })?;
279 Ok(())
280}
281
282impl EventEmitter<EditorEvent> for TextDiffView {}
283
284impl Focusable for TextDiffView {
285 fn focus_handle(&self, cx: &App) -> FocusHandle {
286 self.diff_editor.focus_handle(cx)
287 }
288}
289
290impl Item for TextDiffView {
291 type Event = EditorEvent;
292
293 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
294 Some(Icon::new(IconName::Diff).color(Color::Muted))
295 }
296
297 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
298 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
299 .color(if params.selected {
300 Color::Default
301 } else {
302 Color::Muted
303 })
304 .into_any_element()
305 }
306
307 fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
308 self.title.clone()
309 }
310
311 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
312 self.path.clone()
313 }
314
315 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
316 Editor::to_item_events(event, f)
317 }
318
319 fn telemetry_event_text(&self) -> Option<&'static str> {
320 Some("Selection Diff View Opened")
321 }
322
323 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
324 self.diff_editor
325 .update(cx, |editor, cx| editor.deactivated(window, cx));
326 }
327
328 fn act_as_type<'a>(
329 &'a self,
330 type_id: TypeId,
331 self_handle: &'a Entity<Self>,
332 _: &'a App,
333 ) -> Option<AnyView> {
334 if type_id == TypeId::of::<Self>() {
335 Some(self_handle.to_any())
336 } else if type_id == TypeId::of::<Editor>() {
337 Some(self.diff_editor.to_any())
338 } else {
339 None
340 }
341 }
342
343 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
344 Some(Box::new(self.diff_editor.clone()))
345 }
346
347 fn for_each_project_item(
348 &self,
349 cx: &App,
350 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
351 ) {
352 self.diff_editor.for_each_project_item(cx, f)
353 }
354
355 fn set_nav_history(
356 &mut self,
357 nav_history: ItemNavHistory,
358 _: &mut Window,
359 cx: &mut Context<Self>,
360 ) {
361 self.diff_editor.update(cx, |editor, _| {
362 editor.set_nav_history(Some(nav_history));
363 });
364 }
365
366 fn navigate(&mut self, data: Rc<dyn Any>, window: &mut Window, cx: &mut Context<Self>) -> bool {
367 self.diff_editor
368 .update(cx, |editor, cx| editor.navigate(data, window, cx))
369 }
370
371 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
372 ToolbarItemLocation::PrimaryLeft
373 }
374
375 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
376 self.diff_editor.breadcrumbs(theme, cx)
377 }
378
379 fn added_to_workspace(
380 &mut self,
381 workspace: &mut Workspace,
382 window: &mut Window,
383 cx: &mut Context<Self>,
384 ) {
385 self.diff_editor.update(cx, |editor, cx| {
386 editor.added_to_workspace(workspace, window, cx)
387 });
388 }
389
390 fn can_save(&self, cx: &App) -> bool {
391 // The editor handles the new buffer, so delegate to it
392 self.diff_editor.read(cx).can_save(cx)
393 }
394
395 fn save(
396 &mut self,
397 options: SaveOptions,
398 project: Entity<Project>,
399 window: &mut Window,
400 cx: &mut Context<Self>,
401 ) -> Task<Result<()>> {
402 // Delegate saving to the editor, which manages the new buffer
403 self.diff_editor
404 .update(cx, |editor, cx| editor.save(options, project, window, cx))
405 }
406}
407
408pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
409 let buffer = editor.buffer().read(cx);
410 let buffer_snapshot = buffer.snapshot(cx);
411 let first_selection = editor.selections.disjoint_anchors().first()?;
412
413 let selection_start = first_selection.start.to_point(&buffer_snapshot);
414 let selection_end = first_selection.end.to_point(&buffer_snapshot);
415
416 let start_row = selection_start.row;
417 let start_column = selection_start.column;
418 let end_row = selection_end.row;
419 let end_column = selection_end.column;
420
421 let range_text = if start_row == end_row {
422 format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
423 } else {
424 format!(
425 "L{}:{}-L{}:{}",
426 start_row + 1,
427 start_column + 1,
428 end_row + 1,
429 end_column + 1
430 )
431 };
432
433 Some(range_text)
434}
435
436impl Render for TextDiffView {
437 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
438 self.diff_editor.clone()
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use editor::test::editor_test_context::assert_state_with_diff;
446 use gpui::{TestAppContext, VisualContext};
447 use project::{FakeFs, Project};
448 use serde_json::json;
449 use settings::SettingsStore;
450 use unindent::unindent;
451 use util::{path, test::marked_text_ranges};
452
453 fn init_test(cx: &mut TestAppContext) {
454 cx.update(|cx| {
455 let settings_store = SettingsStore::test(cx);
456 cx.set_global(settings_store);
457 language::init(cx);
458 Project::init_settings(cx);
459 workspace::init_settings(cx);
460 editor::init_settings(cx);
461 theme::init(theme::LoadThemes::JustBase, cx);
462 });
463 }
464
465 #[gpui::test]
466 async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
467 cx: &mut TestAppContext,
468 ) {
469 base_test(
470 path!("/test"),
471 path!("/test/text.txt"),
472 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
473 "def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
474 &unindent(
475 "
476 - def process_incoming_inventory(items, warehouse_id):
477 + ˇdef process_outgoing_inventory(items, warehouse_id):
478 pass
479 ",
480 ),
481 "Clipboard ↔ text.txt @ L1:1-L3:1",
482 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
483 cx,
484 )
485 .await;
486 }
487
488 #[gpui::test]
489 async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
490 cx: &mut TestAppContext,
491 ) {
492 base_test(
493 path!("/test"),
494 path!("/test/text.txt"),
495 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
496 "«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
497 &unindent(
498 "
499 - def process_incoming_inventory(items, warehouse_id):
500 + ˇdef process_outgoing_inventory(items, warehouse_id):
501 pass
502 ",
503 ),
504 "Clipboard ↔ text.txt @ L1:1-L3:1",
505 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
506 cx,
507 )
508 .await;
509 }
510
511 #[gpui::test]
512 async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
513 base_test(
514 path!("/test"),
515 path!("/test/text.txt"),
516 "a",
517 "«bbˇ»",
518 &unindent(
519 "
520 - a
521 + ˇbb",
522 ),
523 "Clipboard ↔ text.txt @ L1:1-3",
524 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
525 cx,
526 )
527 .await;
528 }
529
530 #[gpui::test]
531 async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
532 base_test(
533 path!("/test"),
534 path!("/test/text.txt"),
535 " a",
536 "«bbˇ»",
537 &unindent(
538 "
539 - a
540 + ˇbb",
541 ),
542 "Clipboard ↔ text.txt @ L1:1-3",
543 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
544 cx,
545 )
546 .await;
547 }
548
549 #[gpui::test]
550 async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
551 base_test(
552 path!("/test"),
553 path!("/test/text.txt"),
554 "a",
555 " «bbˇ»",
556 &unindent(
557 "
558 - a
559 + ˇ bb",
560 ),
561 "Clipboard ↔ text.txt @ L1:1-7",
562 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
563 cx,
564 )
565 .await;
566 }
567
568 #[gpui::test]
569 async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
570 cx: &mut TestAppContext,
571 ) {
572 base_test(
573 path!("/test"),
574 path!("/test/text.txt"),
575 "a",
576 "« bbˇ»",
577 &unindent(
578 "
579 - a
580 + ˇ bb",
581 ),
582 "Clipboard ↔ text.txt @ L1:1-7",
583 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
584 cx,
585 )
586 .await;
587 }
588
589 #[gpui::test]
590 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
591 cx: &mut TestAppContext,
592 ) {
593 base_test(
594 path!("/test"),
595 path!("/test/text.txt"),
596 " a",
597 " «bbˇ»",
598 &unindent(
599 "
600 - a
601 + ˇ bb",
602 ),
603 "Clipboard ↔ text.txt @ L1:1-7",
604 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
605 cx,
606 )
607 .await;
608 }
609
610 #[gpui::test]
611 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
612 cx: &mut TestAppContext,
613 ) {
614 base_test(
615 path!("/test"),
616 path!("/test/text.txt"),
617 " a",
618 "« bbˇ»",
619 &unindent(
620 "
621 - a
622 + ˇ bb",
623 ),
624 "Clipboard ↔ text.txt @ L1:1-7",
625 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
626 cx,
627 )
628 .await;
629 }
630
631 #[gpui::test]
632 async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
633 cx: &mut TestAppContext,
634 ) {
635 base_test(
636 path!("/test"),
637 path!("/test/text.txt"),
638 "a",
639 "«bˇ»b",
640 &unindent(
641 "
642 - a
643 + ˇbb",
644 ),
645 "Clipboard ↔ text.txt @ L1:1-3",
646 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
647 cx,
648 )
649 .await;
650 }
651
652 async fn base_test(
653 project_root: &str,
654 file_path: &str,
655 clipboard_text: &str,
656 editor_text: &str,
657 expected_diff: &str,
658 expected_tab_title: &str,
659 expected_tab_tooltip: &str,
660 cx: &mut TestAppContext,
661 ) {
662 init_test(cx);
663
664 let file_name = std::path::Path::new(file_path)
665 .file_name()
666 .unwrap()
667 .to_str()
668 .unwrap();
669
670 let fs = FakeFs::new(cx.executor());
671 fs.insert_tree(
672 project_root,
673 json!({
674 file_name: editor_text
675 }),
676 )
677 .await;
678
679 let project = Project::test(fs, [project_root.as_ref()], cx).await;
680
681 let (workspace, cx) =
682 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
683
684 let buffer = project
685 .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
686 .await
687 .unwrap();
688
689 let editor = cx.new_window_entity(|window, cx| {
690 let mut editor = Editor::for_buffer(buffer, None, window, cx);
691 let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
692 editor.set_text(unmarked_text, window, cx);
693 editor.change_selections(Default::default(), window, cx, |s| {
694 s.select_ranges(selection_ranges)
695 });
696
697 editor
698 });
699
700 let diff_view = workspace
701 .update_in(cx, |workspace, window, cx| {
702 TextDiffView::open(
703 &DiffClipboardWithSelectionData {
704 clipboard_text: clipboard_text.to_string(),
705 editor,
706 },
707 workspace,
708 window,
709 cx,
710 )
711 })
712 .unwrap()
713 .await
714 .unwrap();
715
716 cx.executor().run_until_parked();
717
718 assert_state_with_diff(
719 &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
720 cx,
721 expected_diff,
722 );
723
724 diff_view.read_with(cx, |diff_view, cx| {
725 assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
726 assert_eq!(
727 diff_view.tab_tooltip_text(cx).unwrap(),
728 expected_tab_tooltip
729 );
730 });
731 }
732}