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