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