project_diff.rs
1use std::{
2 any::{Any, TypeId},
3 cmp::Ordering,
4 collections::HashSet,
5 ops::Range,
6 time::Duration,
7};
8
9use anyhow::{anyhow, Context as _};
10use collections::{BTreeMap, HashMap};
11use feature_flags::FeatureFlagAppExt;
12use git::{
13 diff::{BufferDiff, DiffHunk},
14 repository::GitFileStatus,
15};
16use gpui::{
17 actions, AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView,
18 InteractiveElement, Model, Render, Subscription, Task, View, WeakView,
19};
20use language::{Buffer, BufferRow};
21use multi_buffer::{ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer};
22use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
23use text::{OffsetRangeExt, ToPoint};
24use theme::ActiveTheme;
25use ui::prelude::*;
26use util::{paths::compare_paths, ResultExt};
27use workspace::{
28 item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
29 ItemNavHistory, ToolbarItemLocation, Workspace,
30};
31
32use crate::{Editor, EditorEvent, DEFAULT_MULTIBUFFER_CONTEXT};
33
34actions!(project_diff, [Deploy]);
35
36pub fn init(cx: &mut AppContext) {
37 cx.observe_new_views(ProjectDiffEditor::register).detach();
38}
39
40const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
41
42struct ProjectDiffEditor {
43 buffer_changes: BTreeMap<WorktreeId, HashMap<ProjectEntryId, Changes>>,
44 entry_order: HashMap<WorktreeId, Vec<(ProjectPath, ProjectEntryId)>>,
45 excerpts: Model<MultiBuffer>,
46 editor: View<Editor>,
47
48 project: Model<Project>,
49 workspace: WeakView<Workspace>,
50 focus_handle: FocusHandle,
51 worktree_rescans: HashMap<WorktreeId, Task<()>>,
52 _subscriptions: Vec<Subscription>,
53}
54
55#[derive(Debug)]
56struct Changes {
57 _status: GitFileStatus,
58 buffer: Model<Buffer>,
59 hunks: Vec<DiffHunk>,
60}
61
62impl ProjectDiffEditor {
63 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
64 workspace.register_action(Self::deploy);
65 }
66
67 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
68 if !cx.is_staff() {
69 return;
70 }
71
72 if let Some(existing) = workspace.item_of_type::<Self>(cx) {
73 workspace.activate_item(&existing, true, true, cx);
74 } else {
75 let workspace_handle = cx.view().downgrade();
76 let project_diff =
77 cx.new_view(|cx| Self::new(workspace.project().clone(), workspace_handle, cx));
78 workspace.add_item_to_active_pane(Box::new(project_diff), None, true, cx);
79 }
80 }
81
82 fn new(
83 project: Model<Project>,
84 workspace: WeakView<Workspace>,
85 cx: &mut ViewContext<Self>,
86 ) -> Self {
87 // TODO diff change subscriptions. For that, needed:
88 // * `-20/+50` stats retrieval: some background process that reacts on file changes
89 let focus_handle = cx.focus_handle();
90 let changed_entries_subscription =
91 cx.subscribe(&project, |project_diff_editor, _, e, cx| {
92 let mut worktree_to_rescan = None;
93 match e {
94 project::Event::WorktreeAdded(id) => {
95 worktree_to_rescan = Some(*id);
96 // project_diff_editor
97 // .buffer_changes
98 // .insert(*id, HashMap::default());
99 }
100 project::Event::WorktreeRemoved(id) => {
101 project_diff_editor.buffer_changes.remove(id);
102 }
103 project::Event::WorktreeUpdatedEntries(id, _updated_entries) => {
104 // TODO cannot invalidate buffer entries without invalidating the corresponding excerpts and order entries.
105 worktree_to_rescan = Some(*id);
106 // let entry_changes =
107 // project_diff_editor.buffer_changes.entry(*id).or_default();
108 // for (_, entry_id, change) in updated_entries.iter() {
109 // let changes = entry_changes.entry(*entry_id);
110 // match change {
111 // project::PathChange::Removed => {
112 // if let hash_map::Entry::Occupied(entry) = changes {
113 // entry.remove();
114 // }
115 // }
116 // // TODO understand the invalidation case better: now, we do that but still rescan the entire worktree
117 // // What if we already have the buffer loaded inside the diff multi buffer and it was edited there? We should not do anything.
118 // _ => match changes {
119 // hash_map::Entry::Occupied(mut o) => o.get_mut().invalidate(),
120 // hash_map::Entry::Vacant(v) => {
121 // v.insert(None);
122 // }
123 // },
124 // }
125 // }
126 }
127 project::Event::WorktreeUpdatedGitRepositories(id) => {
128 worktree_to_rescan = Some(*id);
129 // project_diff_editor.buffer_changes.clear();
130 }
131 project::Event::DeletedEntry(id, _entry_id) => {
132 worktree_to_rescan = Some(*id);
133 // if let Some(entries) = project_diff_editor.buffer_changes.get_mut(id) {
134 // entries.remove(entry_id);
135 // }
136 }
137 project::Event::Closed => {
138 project_diff_editor.buffer_changes.clear();
139 }
140 _ => {}
141 }
142
143 if let Some(worktree_to_rescan) = worktree_to_rescan {
144 project_diff_editor.schedule_worktree_rescan(worktree_to_rescan, cx);
145 }
146 });
147
148 let excerpts = cx.new_model(|cx| MultiBuffer::new(project.read(cx).capability()));
149
150 let editor = cx.new_view(|cx| {
151 let mut diff_display_editor =
152 Editor::for_multibuffer(excerpts.clone(), Some(project.clone()), true, cx);
153 diff_display_editor.set_expand_all_diff_hunks();
154 diff_display_editor
155 });
156
157 let mut new_self = Self {
158 project,
159 workspace,
160 buffer_changes: BTreeMap::default(),
161 entry_order: HashMap::default(),
162 worktree_rescans: HashMap::default(),
163 focus_handle,
164 editor,
165 excerpts,
166 _subscriptions: vec![changed_entries_subscription],
167 };
168 new_self.schedule_rescan_all(cx);
169 new_self
170 }
171
172 fn schedule_rescan_all(&mut self, cx: &mut ViewContext<Self>) {
173 let mut current_worktrees = HashSet::<WorktreeId>::default();
174 for worktree in self.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
175 let worktree_id = worktree.read(cx).id();
176 current_worktrees.insert(worktree_id);
177 self.schedule_worktree_rescan(worktree_id, cx);
178 }
179
180 self.worktree_rescans
181 .retain(|worktree_id, _| current_worktrees.contains(worktree_id));
182 self.buffer_changes
183 .retain(|worktree_id, _| current_worktrees.contains(worktree_id));
184 self.entry_order
185 .retain(|worktree_id, _| current_worktrees.contains(worktree_id));
186 }
187
188 fn schedule_worktree_rescan(&mut self, id: WorktreeId, cx: &mut ViewContext<Self>) {
189 let project = self.project.clone();
190 self.worktree_rescans.insert(
191 id,
192 cx.spawn(|project_diff_editor, mut cx| async move {
193 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
194 let open_tasks = project
195 .update(&mut cx, |project, cx| {
196 let worktree = project.worktree_for_id(id, cx)?;
197 let snapshot = worktree.read(cx).snapshot();
198 let applicable_entries = snapshot
199 .repositories()
200 .flat_map(|entry| {
201 entry.status().map(|git_entry| {
202 (git_entry.status, entry.join(git_entry.repo_path))
203 })
204 })
205 .filter_map(|(status, path)| {
206 let id = snapshot.entry_for_path(&path)?.id;
207 Some((
208 status,
209 id,
210 ProjectPath {
211 worktree_id: snapshot.id(),
212 path: path.into(),
213 },
214 ))
215 })
216 .collect::<Vec<_>>();
217 Some(
218 applicable_entries
219 .into_iter()
220 .map(|(status, entry_id, entry_path)| {
221 let open_task = project.open_path(entry_path.clone(), cx);
222 (status, entry_id, entry_path, open_task)
223 })
224 .collect::<Vec<_>>(),
225 )
226 })
227 .ok()
228 .flatten()
229 .unwrap_or_default();
230
231 let Some((buffers, mut new_entries, change_sets)) = cx
232 .spawn(|mut cx| async move {
233 let mut new_entries = Vec::new();
234 let mut buffers = HashMap::<
235 ProjectEntryId,
236 (
237 GitFileStatus,
238 text::BufferSnapshot,
239 Model<Buffer>,
240 BufferDiff,
241 ),
242 >::default();
243 let mut change_sets = Vec::new();
244 for (status, entry_id, entry_path, open_task) in open_tasks {
245 let Some(buffer) = open_task
246 .await
247 .and_then(|(_, opened_model)| {
248 opened_model
249 .downcast::<Buffer>()
250 .map_err(|_| anyhow!("Unexpected non-buffer"))
251 })
252 .with_context(|| {
253 format!("loading {:?} for git diff", entry_path.path)
254 })
255 .log_err()
256 else {
257 continue;
258 };
259
260 let Some(change_set) = project
261 .update(&mut cx, |project, cx| {
262 project.open_unstaged_changes(buffer.clone(), cx)
263 })?
264 .await
265 .log_err()
266 else {
267 continue;
268 };
269
270 cx.update(|cx| {
271 buffers.insert(
272 entry_id,
273 (
274 status,
275 buffer.read(cx).text_snapshot(),
276 buffer,
277 change_set.read(cx).diff_to_buffer.clone(),
278 ),
279 );
280 })?;
281 change_sets.push(change_set);
282 new_entries.push((entry_path, entry_id));
283 }
284
285 anyhow::Ok((buffers, new_entries, change_sets))
286 })
287 .await
288 .log_err()
289 else {
290 return;
291 };
292
293 let (new_changes, new_entry_order) = cx
294 .background_executor()
295 .spawn(async move {
296 let mut new_changes = HashMap::<ProjectEntryId, Changes>::default();
297 for (entry_id, (status, buffer_snapshot, buffer, buffer_diff)) in buffers {
298 new_changes.insert(
299 entry_id,
300 Changes {
301 _status: status,
302 buffer,
303 hunks: buffer_diff
304 .hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot)
305 .collect::<Vec<_>>(),
306 },
307 );
308 }
309
310 new_entries.sort_by(|(project_path_a, _), (project_path_b, _)| {
311 compare_paths(
312 (project_path_a.path.as_ref(), true),
313 (project_path_b.path.as_ref(), true),
314 )
315 });
316 (new_changes, new_entries)
317 })
318 .await;
319
320 project_diff_editor
321 .update(&mut cx, |project_diff_editor, cx| {
322 project_diff_editor.update_excerpts(id, new_changes, new_entry_order, cx);
323 project_diff_editor.editor.update(cx, |editor, cx| {
324 for change_set in change_sets {
325 editor.diff_map.add_change_set(change_set, cx)
326 }
327 });
328 })
329 .ok();
330 }),
331 );
332 }
333
334 fn update_excerpts(
335 &mut self,
336 worktree_id: WorktreeId,
337 new_changes: HashMap<ProjectEntryId, Changes>,
338 new_entry_order: Vec<(ProjectPath, ProjectEntryId)>,
339 cx: &mut ViewContext<ProjectDiffEditor>,
340 ) {
341 if let Some(current_order) = self.entry_order.get(&worktree_id) {
342 let current_entries = self.buffer_changes.entry(worktree_id).or_default();
343 let mut new_order_entries = new_entry_order.iter().fuse().peekable();
344 let mut excerpts_to_remove = Vec::new();
345 let mut new_excerpt_hunks = BTreeMap::<
346 ExcerptId,
347 Vec<(ProjectPath, Model<Buffer>, Vec<Range<text::Anchor>>)>,
348 >::new();
349 let mut excerpt_to_expand =
350 HashMap::<(u32, ExpandExcerptDirection), Vec<ExcerptId>>::default();
351 let mut latest_excerpt_id = ExcerptId::min();
352
353 for (current_path, current_entry_id) in current_order {
354 let current_changes = match current_entries.get(current_entry_id) {
355 Some(current_changes) => {
356 if current_changes.hunks.is_empty() {
357 continue;
358 }
359 current_changes
360 }
361 None => continue,
362 };
363 let buffer_excerpts = self
364 .excerpts
365 .read(cx)
366 .excerpts_for_buffer(¤t_changes.buffer, cx);
367 let last_current_excerpt_id =
368 buffer_excerpts.last().map(|(excerpt_id, _)| *excerpt_id);
369 let mut current_excerpts = buffer_excerpts.into_iter().fuse().peekable();
370 loop {
371 match new_order_entries.peek() {
372 Some((new_path, new_entry)) => {
373 match compare_paths(
374 (current_path.path.as_ref(), true),
375 (new_path.path.as_ref(), true),
376 ) {
377 Ordering::Less => {
378 excerpts_to_remove
379 .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
380 break;
381 }
382 Ordering::Greater => {
383 if let Some(new_changes) = new_changes.get(new_entry) {
384 if !new_changes.hunks.is_empty() {
385 let hunks = new_excerpt_hunks
386 .entry(latest_excerpt_id)
387 .or_default();
388 match hunks.binary_search_by(|(probe, ..)| {
389 compare_paths(
390 (new_path.path.as_ref(), true),
391 (probe.path.as_ref(), true),
392 )
393 }) {
394 Ok(i) => hunks[i].2.extend(
395 new_changes
396 .hunks
397 .iter()
398 .map(|hunk| hunk.buffer_range.clone()),
399 ),
400 Err(i) => hunks.insert(
401 i,
402 (
403 new_path.clone(),
404 new_changes.buffer.clone(),
405 new_changes
406 .hunks
407 .iter()
408 .map(|hunk| hunk.buffer_range.clone())
409 .collect(),
410 ),
411 ),
412 }
413 }
414 };
415 let _ = new_order_entries.next();
416 }
417 Ordering::Equal => {
418 match new_changes.get(new_entry) {
419 Some(new_changes) => {
420 let buffer_snapshot =
421 new_changes.buffer.read(cx).snapshot();
422 let mut current_hunks =
423 current_changes.hunks.iter().fuse().peekable();
424 let mut new_hunks_unchanged =
425 Vec::with_capacity(new_changes.hunks.len());
426 let mut new_hunks_with_updates =
427 Vec::with_capacity(new_changes.hunks.len());
428 'new_changes: for new_hunk in &new_changes.hunks {
429 loop {
430 match current_hunks.peek() {
431 Some(current_hunk) => {
432 match (
433 current_hunk
434 .buffer_range
435 .start
436 .cmp(
437 &new_hunk
438 .buffer_range
439 .start,
440 &buffer_snapshot,
441 ),
442 current_hunk.buffer_range.end.cmp(
443 &new_hunk.buffer_range.end,
444 &buffer_snapshot,
445 ),
446 ) {
447 (
448 Ordering::Equal,
449 Ordering::Equal,
450 ) => {
451 new_hunks_unchanged
452 .push(new_hunk);
453 let _ = current_hunks.next();
454 continue 'new_changes;
455 }
456 (Ordering::Equal, _)
457 | (_, Ordering::Equal) => {
458 new_hunks_with_updates
459 .push(new_hunk);
460 continue 'new_changes;
461 }
462 (
463 Ordering::Less,
464 Ordering::Greater,
465 )
466 | (
467 Ordering::Greater,
468 Ordering::Less,
469 ) => {
470 new_hunks_with_updates
471 .push(new_hunk);
472 continue 'new_changes;
473 }
474 (
475 Ordering::Less,
476 Ordering::Less,
477 ) => {
478 if current_hunk
479 .buffer_range
480 .start
481 .cmp(
482 &new_hunk
483 .buffer_range
484 .end,
485 &buffer_snapshot,
486 )
487 .is_le()
488 {
489 new_hunks_with_updates
490 .push(new_hunk);
491 continue 'new_changes;
492 } else {
493 let _ =
494 current_hunks.next();
495 }
496 }
497 (
498 Ordering::Greater,
499 Ordering::Greater,
500 ) => {
501 if current_hunk
502 .buffer_range
503 .end
504 .cmp(
505 &new_hunk
506 .buffer_range
507 .start,
508 &buffer_snapshot,
509 )
510 .is_ge()
511 {
512 new_hunks_with_updates
513 .push(new_hunk);
514 continue 'new_changes;
515 } else {
516 let _ =
517 current_hunks.next();
518 }
519 }
520 }
521 }
522 None => {
523 new_hunks_with_updates.push(new_hunk);
524 continue 'new_changes;
525 }
526 }
527 }
528 }
529
530 let mut excerpts_with_new_changes =
531 HashSet::<ExcerptId>::default();
532 'new_hunks: for new_hunk in new_hunks_with_updates {
533 loop {
534 match current_excerpts.peek() {
535 Some((
536 current_excerpt_id,
537 current_excerpt_range,
538 )) => {
539 match (
540 current_excerpt_range
541 .context
542 .start
543 .cmp(
544 &new_hunk
545 .buffer_range
546 .start,
547 &buffer_snapshot,
548 ),
549 current_excerpt_range
550 .context
551 .end
552 .cmp(
553 &new_hunk.buffer_range.end,
554 &buffer_snapshot,
555 ),
556 ) {
557 (
558 Ordering::Less
559 | Ordering::Equal,
560 Ordering::Greater
561 | Ordering::Equal,
562 ) => {
563 excerpts_with_new_changes
564 .insert(
565 *current_excerpt_id,
566 );
567 continue 'new_hunks;
568 }
569 (
570 Ordering::Greater
571 | Ordering::Equal,
572 Ordering::Less
573 | Ordering::Equal,
574 ) => {
575 let expand_up = current_excerpt_range
576 .context
577 .start
578 .to_point(&buffer_snapshot)
579 .row
580 .saturating_sub(
581 new_hunk
582 .buffer_range
583 .start
584 .to_point(&buffer_snapshot)
585 .row,
586 );
587 let expand_down = new_hunk
588 .buffer_range
589 .end
590 .to_point(&buffer_snapshot)
591 .row
592 .saturating_sub(
593 current_excerpt_range
594 .context
595 .end
596 .to_point(
597 &buffer_snapshot,
598 )
599 .row,
600 );
601 excerpt_to_expand.entry((expand_up.max(expand_down).max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::UpAndDown)).or_default().push(*current_excerpt_id);
602 excerpts_with_new_changes
603 .insert(
604 *current_excerpt_id,
605 );
606 continue 'new_hunks;
607 }
608 (
609 Ordering::Less,
610 Ordering::Less,
611 ) => {
612 if current_excerpt_range
613 .context
614 .start
615 .cmp(
616 &new_hunk
617 .buffer_range
618 .end,
619 &buffer_snapshot,
620 )
621 .is_le()
622 {
623 let expand_up = current_excerpt_range
624 .context
625 .start
626 .to_point(&buffer_snapshot)
627 .row
628 .saturating_sub(
629 new_hunk.buffer_range
630 .start
631 .to_point(
632 &buffer_snapshot,
633 )
634 .row,
635 );
636 excerpt_to_expand.entry((expand_up.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Up)).or_default().push(*current_excerpt_id);
637 excerpts_with_new_changes
638 .insert(
639 *current_excerpt_id,
640 );
641 continue 'new_hunks;
642 } else {
643 if !new_changes
644 .hunks
645 .is_empty()
646 {
647 let hunks = new_excerpt_hunks
648 .entry(latest_excerpt_id)
649 .or_default();
650 match hunks.binary_search_by(|(probe, ..)| {
651 compare_paths(
652 (new_path.path.as_ref(), true),
653 (probe.path.as_ref(), true),
654 )
655 }) {
656 Ok(i) => hunks[i].2.extend(
657 new_changes
658 .hunks
659 .iter()
660 .map(|hunk| hunk.buffer_range.clone()),
661 ),
662 Err(i) => hunks.insert(
663 i,
664 (
665 new_path.clone(),
666 new_changes.buffer.clone(),
667 new_changes
668 .hunks
669 .iter()
670 .map(|hunk| hunk.buffer_range.clone())
671 .collect(),
672 ),
673 ),
674 }
675 }
676 continue 'new_hunks;
677 }
678 }
679 /* TODO remove or leave?
680 [ ><<<<<<<<new_e
681 ----[---->--]----<--
682 cur_s > cur_e <
683 > <
684 new_s>>>>>>>><
685 */
686 (
687 Ordering::Greater,
688 Ordering::Greater,
689 ) => {
690 if current_excerpt_range
691 .context
692 .end
693 .cmp(
694 &new_hunk
695 .buffer_range
696 .start,
697 &buffer_snapshot,
698 )
699 .is_ge()
700 {
701 let expand_down = new_hunk
702 .buffer_range
703 .end
704 .to_point(&buffer_snapshot)
705 .row
706 .saturating_sub(
707 current_excerpt_range
708 .context
709 .end
710 .to_point(
711 &buffer_snapshot,
712 )
713 .row,
714 );
715 excerpt_to_expand.entry((expand_down.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Down)).or_default().push(*current_excerpt_id);
716 excerpts_with_new_changes
717 .insert(
718 *current_excerpt_id,
719 );
720 continue 'new_hunks;
721 } else {
722 latest_excerpt_id =
723 *current_excerpt_id;
724 let _ =
725 current_excerpts.next();
726 }
727 }
728 }
729 }
730 None => {
731 let hunks = new_excerpt_hunks
732 .entry(latest_excerpt_id)
733 .or_default();
734 match hunks.binary_search_by(
735 |(probe, ..)| {
736 compare_paths(
737 (
738 new_path.path.as_ref(),
739 true,
740 ),
741 (probe.path.as_ref(), true),
742 )
743 },
744 ) {
745 Ok(i) => hunks[i].2.extend(
746 new_changes.hunks.iter().map(
747 |hunk| {
748 hunk.buffer_range
749 .clone()
750 },
751 ),
752 ),
753 Err(i) => hunks.insert(
754 i,
755 (
756 new_path.clone(),
757 new_changes.buffer.clone(),
758 new_changes
759 .hunks
760 .iter()
761 .map(|hunk| {
762 hunk.buffer_range
763 .clone()
764 })
765 .collect(),
766 ),
767 ),
768 }
769 continue 'new_hunks;
770 }
771 }
772 }
773 }
774
775 for (excerpt_id, excerpt_range) in current_excerpts {
776 if !excerpts_with_new_changes.contains(&excerpt_id)
777 && !new_hunks_unchanged.iter().any(|hunk| {
778 excerpt_range
779 .context
780 .start
781 .cmp(
782 &hunk.buffer_range.end,
783 &buffer_snapshot,
784 )
785 .is_le()
786 && excerpt_range
787 .context
788 .end
789 .cmp(
790 &hunk.buffer_range.start,
791 &buffer_snapshot,
792 )
793 .is_ge()
794 })
795 {
796 excerpts_to_remove.push(excerpt_id);
797 }
798 latest_excerpt_id = excerpt_id;
799 }
800 }
801 None => excerpts_to_remove.extend(
802 current_excerpts.map(|(excerpt_id, _)| excerpt_id),
803 ),
804 }
805 let _ = new_order_entries.next();
806 break;
807 }
808 }
809 }
810 None => {
811 excerpts_to_remove
812 .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
813 break;
814 }
815 }
816 }
817 latest_excerpt_id = last_current_excerpt_id.unwrap_or(latest_excerpt_id);
818 }
819
820 for (path, project_entry_id) in new_order_entries {
821 if let Some(changes) = new_changes.get(project_entry_id) {
822 if !changes.hunks.is_empty() {
823 let hunks = new_excerpt_hunks.entry(latest_excerpt_id).or_default();
824 match hunks.binary_search_by(|(probe, ..)| {
825 compare_paths((path.path.as_ref(), true), (probe.path.as_ref(), true))
826 }) {
827 Ok(i) => hunks[i]
828 .2
829 .extend(changes.hunks.iter().map(|hunk| hunk.buffer_range.clone())),
830 Err(i) => hunks.insert(
831 i,
832 (
833 path.clone(),
834 changes.buffer.clone(),
835 changes
836 .hunks
837 .iter()
838 .map(|hunk| hunk.buffer_range.clone())
839 .collect(),
840 ),
841 ),
842 }
843 }
844 }
845 }
846
847 self.excerpts.update(cx, |multi_buffer, cx| {
848 for (mut after_excerpt_id, excerpts_to_add) in new_excerpt_hunks {
849 for (_, buffer, hunk_ranges) in excerpts_to_add {
850 let buffer_snapshot = buffer.read(cx).snapshot();
851 let max_point = buffer_snapshot.max_point();
852 let new_excerpts = multi_buffer.insert_excerpts_after(
853 after_excerpt_id,
854 buffer,
855 hunk_ranges.into_iter().map(|range| {
856 let mut extended_point_range = range.to_point(&buffer_snapshot);
857 extended_point_range.start.row = extended_point_range
858 .start
859 .row
860 .saturating_sub(DEFAULT_MULTIBUFFER_CONTEXT);
861 extended_point_range.end.row = (extended_point_range.end.row
862 + DEFAULT_MULTIBUFFER_CONTEXT)
863 .min(max_point.row);
864 ExcerptRange {
865 context: extended_point_range,
866 primary: None,
867 }
868 }),
869 cx,
870 );
871 after_excerpt_id = new_excerpts.last().copied().unwrap_or(after_excerpt_id);
872 }
873 }
874 multi_buffer.remove_excerpts(excerpts_to_remove, cx);
875 for ((line_count, direction), excerpts) in excerpt_to_expand {
876 multi_buffer.expand_excerpts(excerpts, line_count, direction, cx);
877 }
878 });
879 } else {
880 self.excerpts.update(cx, |multi_buffer, cx| {
881 for new_changes in new_entry_order
882 .iter()
883 .filter_map(|(_, entry_id)| new_changes.get(entry_id))
884 {
885 multi_buffer.push_excerpts_with_context_lines(
886 new_changes.buffer.clone(),
887 new_changes
888 .hunks
889 .iter()
890 .map(|hunk| hunk.buffer_range.clone())
891 .collect(),
892 DEFAULT_MULTIBUFFER_CONTEXT,
893 cx,
894 );
895 }
896 });
897 };
898
899 let mut new_changes = new_changes;
900 let mut new_entry_order = new_entry_order;
901 std::mem::swap(
902 self.buffer_changes.entry(worktree_id).or_default(),
903 &mut new_changes,
904 );
905 std::mem::swap(
906 self.entry_order.entry(worktree_id).or_default(),
907 &mut new_entry_order,
908 );
909 }
910}
911
912impl EventEmitter<EditorEvent> for ProjectDiffEditor {}
913
914impl FocusableView for ProjectDiffEditor {
915 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
916 self.focus_handle.clone()
917 }
918}
919
920impl Item for ProjectDiffEditor {
921 type Event = EditorEvent;
922
923 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
924 Editor::to_item_events(event, f)
925 }
926
927 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
928 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
929 }
930
931 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
932 self.editor
933 .update(cx, |editor, cx| editor.navigate(data, cx))
934 }
935
936 fn tab_tooltip_text(&self, _: &AppContext) -> Option<SharedString> {
937 Some("Project Diff".into())
938 }
939
940 fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
941 if self.buffer_changes.is_empty() {
942 Label::new("No changes")
943 .color(if params.selected {
944 Color::Default
945 } else {
946 Color::Muted
947 })
948 .into_any_element()
949 } else {
950 h_flex()
951 .gap_1()
952 .when(true, |then| {
953 then.child(
954 h_flex()
955 .gap_1()
956 .child(Icon::new(IconName::XCircle).color(Color::Error))
957 .child(Label::new(self.buffer_changes.len().to_string()).color(
958 if params.selected {
959 Color::Default
960 } else {
961 Color::Muted
962 },
963 )),
964 )
965 })
966 .when(true, |then| {
967 then.child(
968 h_flex()
969 .gap_1()
970 .child(Icon::new(IconName::Indicator).color(Color::Warning))
971 .child(Label::new(self.buffer_changes.len().to_string()).color(
972 if params.selected {
973 Color::Default
974 } else {
975 Color::Muted
976 },
977 )),
978 )
979 })
980 .into_any_element()
981 }
982 }
983
984 fn telemetry_event_text(&self) -> Option<&'static str> {
985 Some("project diagnostics")
986 }
987
988 fn for_each_project_item(
989 &self,
990 cx: &AppContext,
991 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
992 ) {
993 self.editor.for_each_project_item(cx, f)
994 }
995
996 fn is_singleton(&self, _: &AppContext) -> bool {
997 false
998 }
999
1000 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
1001 self.editor.update(cx, |editor, _| {
1002 editor.set_nav_history(Some(nav_history));
1003 });
1004 }
1005
1006 fn clone_on_split(
1007 &self,
1008 _workspace_id: Option<workspace::WorkspaceId>,
1009 cx: &mut ViewContext<Self>,
1010 ) -> Option<View<Self>>
1011 where
1012 Self: Sized,
1013 {
1014 Some(cx.new_view(|cx| {
1015 ProjectDiffEditor::new(self.project.clone(), self.workspace.clone(), cx)
1016 }))
1017 }
1018
1019 fn is_dirty(&self, cx: &AppContext) -> bool {
1020 self.excerpts.read(cx).is_dirty(cx)
1021 }
1022
1023 fn has_conflict(&self, cx: &AppContext) -> bool {
1024 self.excerpts.read(cx).has_conflict(cx)
1025 }
1026
1027 fn can_save(&self, _: &AppContext) -> bool {
1028 true
1029 }
1030
1031 fn save(
1032 &mut self,
1033 format: bool,
1034 project: Model<Project>,
1035 cx: &mut ViewContext<Self>,
1036 ) -> Task<anyhow::Result<()>> {
1037 self.editor.save(format, project, cx)
1038 }
1039
1040 fn save_as(
1041 &mut self,
1042 _: Model<Project>,
1043 _: ProjectPath,
1044 _: &mut ViewContext<Self>,
1045 ) -> Task<anyhow::Result<()>> {
1046 unreachable!()
1047 }
1048
1049 fn reload(
1050 &mut self,
1051 project: Model<Project>,
1052 cx: &mut ViewContext<Self>,
1053 ) -> Task<anyhow::Result<()>> {
1054 self.editor.reload(project, cx)
1055 }
1056
1057 fn act_as_type<'a>(
1058 &'a self,
1059 type_id: TypeId,
1060 self_handle: &'a View<Self>,
1061 _: &'a AppContext,
1062 ) -> Option<AnyView> {
1063 if type_id == TypeId::of::<Self>() {
1064 Some(self_handle.to_any())
1065 } else if type_id == TypeId::of::<Editor>() {
1066 Some(self.editor.to_any())
1067 } else {
1068 None
1069 }
1070 }
1071
1072 fn breadcrumb_location(&self, _: &AppContext) -> ToolbarItemLocation {
1073 ToolbarItemLocation::PrimaryLeft
1074 }
1075
1076 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
1077 self.editor.breadcrumbs(theme, cx)
1078 }
1079
1080 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
1081 self.editor
1082 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
1083 }
1084}
1085
1086impl Render for ProjectDiffEditor {
1087 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1088 let child = if self.buffer_changes.is_empty() {
1089 div()
1090 .bg(cx.theme().colors().editor_background)
1091 .flex()
1092 .items_center()
1093 .justify_center()
1094 .size_full()
1095 .child(Label::new("No changes in the workspace"))
1096 } else {
1097 div().size_full().child(self.editor.clone())
1098 };
1099
1100 div()
1101 .track_focus(&self.focus_handle)
1102 .size_full()
1103 .child(child)
1104 }
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
1110 use project::buffer_store::BufferChangeSet;
1111 use serde_json::json;
1112 use settings::SettingsStore;
1113 use std::{
1114 ops::Deref as _,
1115 path::{Path, PathBuf},
1116 };
1117
1118 use super::*;
1119
1120 // TODO finish
1121 // #[gpui::test]
1122 // async fn randomized_tests(cx: &mut TestAppContext) {
1123 // // Create a new project (how?? temp fs?),
1124 // let fs = FakeFs::new(cx.executor());
1125 // let project = Project::test(fs, [], cx).await;
1126
1127 // // create random files with random content
1128
1129 // // Commit it into git somehow (technically can do with "real" fs in a temp dir)
1130 // //
1131 // // Apply randomized changes to the project: select a random file, random change and apply to buffers
1132 // }
1133
1134 #[gpui::test(iterations = 30)]
1135 async fn simple_edit_test(cx: &mut TestAppContext) {
1136 cx.executor().allow_parking();
1137 init_test(cx);
1138
1139 let fs = fs::FakeFs::new(cx.executor().clone());
1140 fs.insert_tree(
1141 "/root",
1142 json!({
1143 ".git": {},
1144 "file_a": "This is file_a",
1145 "file_b": "This is file_b",
1146 }),
1147 )
1148 .await;
1149
1150 let project = Project::test(fs.clone(), [Path::new("/root")], cx).await;
1151 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1152 let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
1153
1154 let file_a_editor = workspace
1155 .update(cx, |workspace, cx| {
1156 let file_a_editor =
1157 workspace.open_abs_path(PathBuf::from("/root/file_a"), true, cx);
1158 ProjectDiffEditor::deploy(workspace, &Deploy, cx);
1159 file_a_editor
1160 })
1161 .unwrap()
1162 .await
1163 .expect("did not open an item at all")
1164 .downcast::<Editor>()
1165 .expect("did not open an editor for file_a");
1166 let project_diff_editor = workspace
1167 .update(cx, |workspace, cx| {
1168 workspace
1169 .active_pane()
1170 .read(cx)
1171 .items()
1172 .find_map(|item| item.downcast::<ProjectDiffEditor>())
1173 })
1174 .unwrap()
1175 .expect("did not find a ProjectDiffEditor");
1176 project_diff_editor.update(cx, |project_diff_editor, cx| {
1177 assert!(
1178 project_diff_editor.editor.read(cx).text(cx).is_empty(),
1179 "Should have no changes after opening the diff on no git changes"
1180 );
1181 });
1182
1183 let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx));
1184 let change = "an edit after git add";
1185 file_a_editor
1186 .update(cx, |file_a_editor, cx| {
1187 file_a_editor.insert(change, cx);
1188 file_a_editor.save(false, project.clone(), cx)
1189 })
1190 .await
1191 .expect("failed to save a file");
1192 file_a_editor.update(cx, |file_a_editor, cx| {
1193 let change_set = cx.new_model(|cx| {
1194 BufferChangeSet::new_with_base_text(
1195 old_text.clone(),
1196 file_a_editor
1197 .buffer()
1198 .read(cx)
1199 .as_singleton()
1200 .unwrap()
1201 .read(cx)
1202 .text_snapshot(),
1203 cx,
1204 )
1205 });
1206 file_a_editor
1207 .diff_map
1208 .add_change_set(change_set.clone(), cx);
1209 project.update(cx, |project, cx| {
1210 project.buffer_store().update(cx, |buffer_store, cx| {
1211 buffer_store.set_change_set(
1212 file_a_editor
1213 .buffer()
1214 .read(cx)
1215 .as_singleton()
1216 .unwrap()
1217 .read(cx)
1218 .remote_id(),
1219 change_set,
1220 );
1221 });
1222 });
1223 });
1224 fs.set_status_for_repo_via_git_operation(
1225 Path::new("/root/.git"),
1226 &[(Path::new("file_a"), GitFileStatus::Modified)],
1227 );
1228 cx.executor()
1229 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
1230 cx.run_until_parked();
1231
1232 project_diff_editor.update(cx, |project_diff_editor, cx| {
1233 assert_eq!(
1234 // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added)
1235 project_diff_editor.editor.read(cx).text(cx),
1236 format!("{change}{old_text}"),
1237 "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards"
1238 );
1239 });
1240 }
1241
1242 fn init_test(cx: &mut gpui::TestAppContext) {
1243 if std::env::var("RUST_LOG").is_ok() {
1244 env_logger::try_init().ok();
1245 }
1246
1247 cx.update(|cx| {
1248 assets::Assets.load_test_fonts(cx);
1249 let settings_store = SettingsStore::test(cx);
1250 cx.set_global(settings_store);
1251 theme::init(theme::LoadThemes::JustBase, cx);
1252 release_channel::init(SemanticVersion::default(), cx);
1253 client::init_settings(cx);
1254 language::init(cx);
1255 Project::init_settings(cx);
1256 workspace::init_settings(cx);
1257 crate::init(cx);
1258 cx.set_staff(true);
1259 });
1260 }
1261}