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