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 ops::Range,
16 pin::pin,
17 sync::Arc,
18 time::Duration,
19};
20use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
21use util::paths::PathExt;
22
23use workspace::{
24 Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
25 item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
26 searchable::SearchableItemHandle,
27};
28
29pub struct TextDiffView {
30 diff_editor: Entity<Editor>,
31 title: SharedString,
32 path: Option<SharedString>,
33 buffer_changes_tx: watch::Sender<()>,
34 _recalculate_diff_task: Task<Result<()>>,
35}
36
37const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
38
39impl TextDiffView {
40 pub fn open(
41 diff_data: &DiffClipboardWithSelectionData,
42 workspace: &Workspace,
43 window: &mut Window,
44 cx: &mut App,
45 ) -> Option<Task<Result<Entity<Self>>>> {
46 let source_editor = diff_data.editor.clone();
47
48 let source_editor_buffer_and_range = source_editor.update(cx, |editor, cx| {
49 let multibuffer = editor.buffer().read(cx);
50 let source_buffer = multibuffer.as_singleton()?.clone();
51 let selections = editor.selections.all::<Point>(cx);
52 let buffer_snapshot = source_buffer.read(cx);
53 let first_selection = selections.first()?;
54 let selection_range = if first_selection.is_empty() {
55 Point::new(0, 0)..buffer_snapshot.max_point()
56 } else {
57 first_selection.start..first_selection.end
58 };
59
60 Some((source_buffer, selection_range))
61 });
62
63 let Some((source_buffer, selected_range)) = source_editor_buffer_and_range else {
64 log::warn!("There should always be at least one selection in Zed. This is a bug.");
65 return None;
66 };
67
68 let clipboard_text = diff_data.clipboard_text.clone();
69
70 let workspace = workspace.weak_handle();
71
72 let diff_buffer = cx.new(|cx| {
73 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
74 let diff = BufferDiff::new(&source_buffer_snapshot.text, cx);
75 diff
76 });
77
78 let clipboard_buffer =
79 build_clipboard_buffer(clipboard_text, &source_buffer, selected_range.clone(), cx);
80
81 let task = window.spawn(cx, async move |cx| {
82 let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
83
84 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
85
86 workspace.update_in(cx, |workspace, window, cx| {
87 let diff_view = cx.new(|cx| {
88 TextDiffView::new(
89 clipboard_buffer,
90 source_editor,
91 source_buffer,
92 selected_range,
93 diff_buffer,
94 project,
95 window,
96 cx,
97 )
98 });
99
100 let pane = workspace.active_pane();
101 pane.update(cx, |pane, cx| {
102 pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
103 });
104
105 diff_view
106 })
107 });
108
109 Some(task)
110 }
111
112 pub fn new(
113 clipboard_buffer: Entity<Buffer>,
114 source_editor: Entity<Editor>,
115 source_buffer: Entity<Buffer>,
116 source_range: Range<Point>,
117 diff_buffer: Entity<BufferDiff>,
118 project: Entity<Project>,
119 window: &mut Window,
120 cx: &mut Context<Self>,
121 ) -> Self {
122 let multibuffer = cx.new(|cx| {
123 let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
124
125 multibuffer.push_excerpts(
126 source_buffer.clone(),
127 [editor::ExcerptRange::new(source_range)],
128 cx,
129 );
130
131 multibuffer.add_diff(diff_buffer.clone(), cx);
132 multibuffer
133 });
134 let diff_editor = cx.new(|cx| {
135 let mut editor = Editor::for_multibuffer(multibuffer, Some(project), window, cx);
136 editor.start_temporary_diff_override();
137 editor.disable_diagnostics(cx);
138 editor.set_expand_all_diff_hunks(cx);
139 editor.set_render_diff_hunk_controls(
140 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
141 cx,
142 );
143 editor
144 });
145
146 let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
147
148 cx.subscribe(&source_buffer, move |this, _, event, _| match event {
149 language::BufferEvent::Edited
150 | language::BufferEvent::LanguageChanged
151 | language::BufferEvent::Reparsed => {
152 this.buffer_changes_tx.send(()).ok();
153 }
154 _ => {}
155 })
156 .detach();
157
158 let editor = source_editor.read(cx);
159 let title = editor.buffer().read(cx).title(cx).to_string();
160 let selection_location_text = selection_location_text(editor, cx);
161 let selection_location_title = selection_location_text
162 .as_ref()
163 .map(|text| format!("{} @ {}", title, text))
164 .unwrap_or(title);
165
166 let path = editor
167 .buffer()
168 .read(cx)
169 .as_singleton()
170 .and_then(|b| {
171 b.read(cx)
172 .file()
173 .map(|f| f.full_path(cx).compact().to_string_lossy().to_string())
174 })
175 .unwrap_or("untitled".into());
176
177 let selection_location_path = selection_location_text
178 .map(|text| format!("{} @ {}", path, text))
179 .unwrap_or(path);
180
181 Self {
182 diff_editor,
183 title: format!("Clipboard β {selection_location_title}").into(),
184 path: Some(format!("Clipboard β {selection_location_path}").into()),
185 buffer_changes_tx,
186 _recalculate_diff_task: cx.spawn(async move |_, cx| {
187 while let Ok(_) = buffer_changes_rx.recv().await {
188 loop {
189 let mut timer = cx
190 .background_executor()
191 .timer(RECALCULATE_DIFF_DEBOUNCE)
192 .fuse();
193 let mut recv = pin!(buffer_changes_rx.recv().fuse());
194 select_biased! {
195 _ = timer => break,
196 _ = recv => continue,
197 }
198 }
199
200 log::trace!("start recalculating");
201 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
202 log::trace!("finish recalculating");
203 }
204 Ok(())
205 }),
206 }
207 }
208}
209
210fn build_clipboard_buffer(
211 clipboard_text: String,
212 source_buffer: &Entity<Buffer>,
213 selected_range: Range<Point>,
214 cx: &mut App,
215) -> Entity<Buffer> {
216 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
217 cx.new(|cx| {
218 let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
219 let language = source_buffer.read(cx).language().cloned();
220 buffer.set_language(language, cx);
221
222 let range_start = source_buffer_snapshot.point_to_offset(selected_range.start);
223 let range_end = source_buffer_snapshot.point_to_offset(selected_range.end);
224 buffer.edit([(range_start..range_end, clipboard_text)], None, cx);
225
226 buffer
227 })
228}
229
230async fn update_diff_buffer(
231 diff: &Entity<BufferDiff>,
232 source_buffer: &Entity<Buffer>,
233 clipboard_buffer: &Entity<Buffer>,
234 cx: &mut AsyncApp,
235) -> Result<()> {
236 let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
237
238 let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
239 let base_text = base_buffer_snapshot.text().to_string();
240
241 let diff_snapshot = cx
242 .update(|cx| {
243 BufferDiffSnapshot::new_with_base_buffer(
244 source_buffer_snapshot.text.clone(),
245 Some(Arc::new(base_text)),
246 base_buffer_snapshot,
247 cx,
248 )
249 })?
250 .await;
251
252 diff.update(cx, |diff, cx| {
253 diff.set_snapshot(diff_snapshot, &source_buffer_snapshot.text, cx);
254 })?;
255 Ok(())
256}
257
258impl EventEmitter<EditorEvent> for TextDiffView {}
259
260impl Focusable for TextDiffView {
261 fn focus_handle(&self, cx: &App) -> FocusHandle {
262 self.diff_editor.focus_handle(cx)
263 }
264}
265
266impl Item for TextDiffView {
267 type Event = EditorEvent;
268
269 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
270 Some(Icon::new(IconName::Diff).color(Color::Muted))
271 }
272
273 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
274 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
275 .color(if params.selected {
276 Color::Default
277 } else {
278 Color::Muted
279 })
280 .into_any_element()
281 }
282
283 fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
284 self.title.clone()
285 }
286
287 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
288 self.path.clone()
289 }
290
291 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
292 Editor::to_item_events(event, f)
293 }
294
295 fn telemetry_event_text(&self) -> Option<&'static str> {
296 Some("Diff View Opened")
297 }
298
299 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
300 self.diff_editor
301 .update(cx, |editor, cx| editor.deactivated(window, cx));
302 }
303
304 fn is_singleton(&self, _: &App) -> bool {
305 false
306 }
307
308 fn act_as_type<'a>(
309 &'a self,
310 type_id: TypeId,
311 self_handle: &'a Entity<Self>,
312 _: &'a App,
313 ) -> Option<AnyView> {
314 if type_id == TypeId::of::<Self>() {
315 Some(self_handle.to_any())
316 } else if type_id == TypeId::of::<Editor>() {
317 Some(self.diff_editor.to_any())
318 } else {
319 None
320 }
321 }
322
323 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
324 Some(Box::new(self.diff_editor.clone()))
325 }
326
327 fn for_each_project_item(
328 &self,
329 cx: &App,
330 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
331 ) {
332 self.diff_editor.for_each_project_item(cx, f)
333 }
334
335 fn set_nav_history(
336 &mut self,
337 nav_history: ItemNavHistory,
338 _: &mut Window,
339 cx: &mut Context<Self>,
340 ) {
341 self.diff_editor.update(cx, |editor, _| {
342 editor.set_nav_history(Some(nav_history));
343 });
344 }
345
346 fn navigate(
347 &mut self,
348 data: Box<dyn Any>,
349 window: &mut Window,
350 cx: &mut Context<Self>,
351 ) -> bool {
352 self.diff_editor
353 .update(cx, |editor, cx| editor.navigate(data, window, cx))
354 }
355
356 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
357 ToolbarItemLocation::PrimaryLeft
358 }
359
360 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
361 self.diff_editor.breadcrumbs(theme, cx)
362 }
363
364 fn added_to_workspace(
365 &mut self,
366 workspace: &mut Workspace,
367 window: &mut Window,
368 cx: &mut Context<Self>,
369 ) {
370 self.diff_editor.update(cx, |editor, cx| {
371 editor.added_to_workspace(workspace, window, cx)
372 });
373 }
374
375 fn can_save(&self, cx: &App) -> bool {
376 // The editor handles the new buffer, so delegate to it
377 self.diff_editor.read(cx).can_save(cx)
378 }
379
380 fn save(
381 &mut self,
382 options: SaveOptions,
383 project: Entity<Project>,
384 window: &mut Window,
385 cx: &mut Context<Self>,
386 ) -> Task<Result<()>> {
387 // Delegate saving to the editor, which manages the new buffer
388 self.diff_editor
389 .update(cx, |editor, cx| editor.save(options, project, window, cx))
390 }
391}
392
393pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
394 let buffer = editor.buffer().read(cx);
395 let buffer_snapshot = buffer.snapshot(cx);
396 let first_selection = editor.selections.disjoint.first()?;
397
398 let (start_row, start_column, end_row, end_column) =
399 if first_selection.start == first_selection.end {
400 let max_point = buffer_snapshot.max_point();
401 (0, 0, max_point.row, max_point.column)
402 } else {
403 let selection_start = first_selection.start.to_point(&buffer_snapshot);
404 let selection_end = first_selection.end.to_point(&buffer_snapshot);
405
406 (
407 selection_start.row,
408 selection_start.column,
409 selection_end.row,
410 selection_end.column,
411 )
412 };
413
414 let range_text = if start_row == end_row {
415 format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
416 } else {
417 format!(
418 "L{}:{}-L{}:{}",
419 start_row + 1,
420 start_column + 1,
421 end_row + 1,
422 end_column + 1
423 )
424 };
425
426 Some(range_text)
427}
428
429impl Render for TextDiffView {
430 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
431 self.diff_editor.clone()
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 use editor::{actions, test::editor_test_context::assert_state_with_diff};
440 use gpui::{TestAppContext, VisualContext};
441 use project::{FakeFs, Project};
442 use serde_json::json;
443 use settings::{Settings, SettingsStore};
444 use unindent::unindent;
445 use util::path;
446
447 fn init_test(cx: &mut TestAppContext) {
448 cx.update(|cx| {
449 let settings_store = SettingsStore::test(cx);
450 cx.set_global(settings_store);
451 language::init(cx);
452 Project::init_settings(cx);
453 workspace::init_settings(cx);
454 editor::init_settings(cx);
455 theme::ThemeSettings::register(cx)
456 });
457 }
458
459 #[gpui::test]
460 async fn test_diffing_clipboard_against_specific_selection(cx: &mut TestAppContext) {
461 base_test(true, cx).await;
462 }
463
464 #[gpui::test]
465 async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer(
466 cx: &mut TestAppContext,
467 ) {
468 base_test(false, cx).await;
469 }
470
471 async fn base_test(select_all_text: bool, cx: &mut TestAppContext) {
472 init_test(cx);
473
474 let fs = FakeFs::new(cx.executor());
475 fs.insert_tree(
476 path!("/test"),
477 json!({
478 "a": {
479 "b": {
480 "text.txt": "new line 1\nline 2\nnew line 3\nline 4"
481 }
482 }
483 }),
484 )
485 .await;
486
487 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
488
489 let (workspace, mut cx) =
490 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
491
492 let buffer = project
493 .update(cx, |project, cx| {
494 project.open_local_buffer(path!("/test/a/b/text.txt"), cx)
495 })
496 .await
497 .unwrap();
498
499 let editor = cx.new_window_entity(|window, cx| {
500 let mut editor = Editor::for_buffer(buffer, None, window, cx);
501 editor.set_text("new line 1\nline 2\nnew line 3\nline 4\n", window, cx);
502
503 if select_all_text {
504 editor.select_all(&actions::SelectAll, window, cx);
505 }
506
507 editor
508 });
509
510 let diff_view = workspace
511 .update_in(cx, |workspace, window, cx| {
512 TextDiffView::open(
513 &DiffClipboardWithSelectionData {
514 clipboard_text: "old line 1\nline 2\nold line 3\nline 4\n".to_string(),
515 editor,
516 },
517 workspace,
518 window,
519 cx,
520 )
521 })
522 .unwrap()
523 .await
524 .unwrap();
525
526 cx.executor().run_until_parked();
527
528 assert_state_with_diff(
529 &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
530 &mut cx,
531 &unindent(
532 "
533 - old line 1
534 + Λnew line 1
535 line 2
536 - old line 3
537 + new line 3
538 line 4
539 ",
540 ),
541 );
542
543 diff_view.read_with(cx, |diff_view, cx| {
544 assert_eq!(
545 diff_view.tab_content_text(0, cx),
546 "Clipboard β text.txt @ L1:1-L5:1"
547 );
548 assert_eq!(
549 diff_view.tab_tooltip_text(cx).unwrap(),
550 format!("Clipboard β {}", path!("test/a/b/text.txt @ L1:1-L5:1"))
551 );
552 });
553 }
554}