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 applicable_entries = worktree
198 .read(cx)
199 .entries(false, 0)
200 .filter(|entry| !entry.is_external)
201 .filter(|entry| entry.is_file())
202 .filter_map(|entry| Some((entry.git_status?, entry)))
203 .filter_map(|(git_status, entry)| {
204 Some((git_status, entry.id, project.path_for_entry(entry.id, cx)?))
205 })
206 .collect::<Vec<_>>();
207 Some(
208 applicable_entries
209 .into_iter()
210 .map(|(status, entry_id, entry_path)| {
211 let open_task = project.open_path(entry_path.clone(), cx);
212 (status, entry_id, entry_path, open_task)
213 })
214 .collect::<Vec<_>>(),
215 )
216 })
217 .ok()
218 .flatten()
219 .unwrap_or_default();
220
221 let Some((buffers, mut new_entries, change_sets)) = cx
222 .spawn(|mut cx| async move {
223 let mut new_entries = Vec::new();
224 let mut buffers = HashMap::<
225 ProjectEntryId,
226 (
227 GitFileStatus,
228 text::BufferSnapshot,
229 Model<Buffer>,
230 BufferDiff,
231 ),
232 >::default();
233 let mut change_sets = Vec::new();
234 for (status, entry_id, entry_path, open_task) in open_tasks {
235 let Some(buffer) = open_task
236 .await
237 .and_then(|(_, opened_model)| {
238 opened_model
239 .downcast::<Buffer>()
240 .map_err(|_| anyhow!("Unexpected non-buffer"))
241 })
242 .with_context(|| {
243 format!("loading {:?} for git diff", entry_path.path)
244 })
245 .log_err()
246 else {
247 continue;
248 };
249
250 let Some(change_set) = project
251 .update(&mut cx, |project, cx| {
252 project.open_unstaged_changes(buffer.clone(), cx)
253 })?
254 .await
255 .log_err()
256 else {
257 continue;
258 };
259
260 cx.update(|cx| {
261 buffers.insert(
262 entry_id,
263 (
264 status,
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, (status, buffer_snapshot, buffer, buffer_diff)) in buffers {
288 new_changes.insert(
289 entry_id,
290 Changes {
291 _status: status,
292 buffer,
293 hunks: buffer_diff
294 .hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot)
295 .collect::<Vec<_>>(),
296 },
297 );
298 }
299
300 new_entries.sort_by(|(project_path_a, _), (project_path_b, _)| {
301 compare_paths(
302 (project_path_a.path.as_ref(), true),
303 (project_path_b.path.as_ref(), true),
304 )
305 });
306 (new_changes, new_entries)
307 })
308 .await;
309
310 project_diff_editor
311 .update(&mut cx, |project_diff_editor, cx| {
312 project_diff_editor.update_excerpts(id, new_changes, new_entry_order, cx);
313 project_diff_editor.editor.update(cx, |editor, cx| {
314 for change_set in change_sets {
315 editor.diff_map.add_change_set(change_set, cx)
316 }
317 });
318 })
319 .ok();
320 }),
321 );
322 }
323
324 fn update_excerpts(
325 &mut self,
326 worktree_id: WorktreeId,
327 new_changes: HashMap<ProjectEntryId, Changes>,
328 new_entry_order: Vec<(ProjectPath, ProjectEntryId)>,
329 cx: &mut ViewContext<ProjectDiffEditor>,
330 ) {
331 if let Some(current_order) = self.entry_order.get(&worktree_id) {
332 let current_entries = self.buffer_changes.entry(worktree_id).or_default();
333 let mut new_order_entries = new_entry_order.iter().fuse().peekable();
334 let mut excerpts_to_remove = Vec::new();
335 let mut new_excerpt_hunks = BTreeMap::<
336 ExcerptId,
337 Vec<(ProjectPath, Model<Buffer>, Vec<Range<text::Anchor>>)>,
338 >::new();
339 let mut excerpt_to_expand =
340 HashMap::<(u32, ExpandExcerptDirection), Vec<ExcerptId>>::default();
341 let mut latest_excerpt_id = ExcerptId::min();
342
343 for (current_path, current_entry_id) in current_order {
344 let current_changes = match current_entries.get(current_entry_id) {
345 Some(current_changes) => {
346 if current_changes.hunks.is_empty() {
347 continue;
348 }
349 current_changes
350 }
351 None => continue,
352 };
353 let buffer_excerpts = self
354 .excerpts
355 .read(cx)
356 .excerpts_for_buffer(¤t_changes.buffer, cx);
357 let last_current_excerpt_id =
358 buffer_excerpts.last().map(|(excerpt_id, _)| *excerpt_id);
359 let mut current_excerpts = buffer_excerpts.into_iter().fuse().peekable();
360 loop {
361 match new_order_entries.peek() {
362 Some((new_path, new_entry)) => {
363 match compare_paths(
364 (current_path.path.as_ref(), true),
365 (new_path.path.as_ref(), true),
366 ) {
367 Ordering::Less => {
368 excerpts_to_remove
369 .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
370 break;
371 }
372 Ordering::Greater => {
373 if let Some(new_changes) = new_changes.get(new_entry) {
374 if !new_changes.hunks.is_empty() {
375 let hunks = new_excerpt_hunks
376 .entry(latest_excerpt_id)
377 .or_default();
378 match hunks.binary_search_by(|(probe, ..)| {
379 compare_paths(
380 (new_path.path.as_ref(), true),
381 (probe.path.as_ref(), true),
382 )
383 }) {
384 Ok(i) => hunks[i].2.extend(
385 new_changes
386 .hunks
387 .iter()
388 .map(|hunk| hunk.buffer_range.clone()),
389 ),
390 Err(i) => hunks.insert(
391 i,
392 (
393 new_path.clone(),
394 new_changes.buffer.clone(),
395 new_changes
396 .hunks
397 .iter()
398 .map(|hunk| hunk.buffer_range.clone())
399 .collect(),
400 ),
401 ),
402 }
403 }
404 };
405 let _ = new_order_entries.next();
406 }
407 Ordering::Equal => {
408 match new_changes.get(new_entry) {
409 Some(new_changes) => {
410 let buffer_snapshot =
411 new_changes.buffer.read(cx).snapshot();
412 let mut current_hunks =
413 current_changes.hunks.iter().fuse().peekable();
414 let mut new_hunks_unchanged =
415 Vec::with_capacity(new_changes.hunks.len());
416 let mut new_hunks_with_updates =
417 Vec::with_capacity(new_changes.hunks.len());
418 'new_changes: for new_hunk in &new_changes.hunks {
419 loop {
420 match current_hunks.peek() {
421 Some(current_hunk) => {
422 match (
423 current_hunk
424 .buffer_range
425 .start
426 .cmp(
427 &new_hunk
428 .buffer_range
429 .start,
430 &buffer_snapshot,
431 ),
432 current_hunk.buffer_range.end.cmp(
433 &new_hunk.buffer_range.end,
434 &buffer_snapshot,
435 ),
436 ) {
437 (
438 Ordering::Equal,
439 Ordering::Equal,
440 ) => {
441 new_hunks_unchanged
442 .push(new_hunk);
443 let _ = current_hunks.next();
444 continue 'new_changes;
445 }
446 (Ordering::Equal, _)
447 | (_, Ordering::Equal) => {
448 new_hunks_with_updates
449 .push(new_hunk);
450 continue 'new_changes;
451 }
452 (
453 Ordering::Less,
454 Ordering::Greater,
455 )
456 | (
457 Ordering::Greater,
458 Ordering::Less,
459 ) => {
460 new_hunks_with_updates
461 .push(new_hunk);
462 continue 'new_changes;
463 }
464 (
465 Ordering::Less,
466 Ordering::Less,
467 ) => {
468 if current_hunk
469 .buffer_range
470 .start
471 .cmp(
472 &new_hunk
473 .buffer_range
474 .end,
475 &buffer_snapshot,
476 )
477 .is_le()
478 {
479 new_hunks_with_updates
480 .push(new_hunk);
481 continue 'new_changes;
482 } else {
483 let _ =
484 current_hunks.next();
485 }
486 }
487 (
488 Ordering::Greater,
489 Ordering::Greater,
490 ) => {
491 if current_hunk
492 .buffer_range
493 .end
494 .cmp(
495 &new_hunk
496 .buffer_range
497 .start,
498 &buffer_snapshot,
499 )
500 .is_ge()
501 {
502 new_hunks_with_updates
503 .push(new_hunk);
504 continue 'new_changes;
505 } else {
506 let _ =
507 current_hunks.next();
508 }
509 }
510 }
511 }
512 None => {
513 new_hunks_with_updates.push(new_hunk);
514 continue 'new_changes;
515 }
516 }
517 }
518 }
519
520 let mut excerpts_with_new_changes =
521 HashSet::<ExcerptId>::default();
522 'new_hunks: for new_hunk in new_hunks_with_updates {
523 loop {
524 match current_excerpts.peek() {
525 Some((
526 current_excerpt_id,
527 current_excerpt_range,
528 )) => {
529 match (
530 current_excerpt_range
531 .context
532 .start
533 .cmp(
534 &new_hunk
535 .buffer_range
536 .start,
537 &buffer_snapshot,
538 ),
539 current_excerpt_range
540 .context
541 .end
542 .cmp(
543 &new_hunk.buffer_range.end,
544 &buffer_snapshot,
545 ),
546 ) {
547 (
548 Ordering::Less
549 | Ordering::Equal,
550 Ordering::Greater
551 | Ordering::Equal,
552 ) => {
553 excerpts_with_new_changes
554 .insert(
555 *current_excerpt_id,
556 );
557 continue 'new_hunks;
558 }
559 (
560 Ordering::Greater
561 | Ordering::Equal,
562 Ordering::Less
563 | Ordering::Equal,
564 ) => {
565 let expand_up = current_excerpt_range
566 .context
567 .start
568 .to_point(&buffer_snapshot)
569 .row
570 .saturating_sub(
571 new_hunk
572 .buffer_range
573 .start
574 .to_point(&buffer_snapshot)
575 .row,
576 );
577 let expand_down = new_hunk
578 .buffer_range
579 .end
580 .to_point(&buffer_snapshot)
581 .row
582 .saturating_sub(
583 current_excerpt_range
584 .context
585 .end
586 .to_point(
587 &buffer_snapshot,
588 )
589 .row,
590 );
591 excerpt_to_expand.entry((expand_up.max(expand_down).max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::UpAndDown)).or_default().push(*current_excerpt_id);
592 excerpts_with_new_changes
593 .insert(
594 *current_excerpt_id,
595 );
596 continue 'new_hunks;
597 }
598 (
599 Ordering::Less,
600 Ordering::Less,
601 ) => {
602 if current_excerpt_range
603 .context
604 .start
605 .cmp(
606 &new_hunk
607 .buffer_range
608 .end,
609 &buffer_snapshot,
610 )
611 .is_le()
612 {
613 let expand_up = current_excerpt_range
614 .context
615 .start
616 .to_point(&buffer_snapshot)
617 .row
618 .saturating_sub(
619 new_hunk.buffer_range
620 .start
621 .to_point(
622 &buffer_snapshot,
623 )
624 .row,
625 );
626 excerpt_to_expand.entry((expand_up.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Up)).or_default().push(*current_excerpt_id);
627 excerpts_with_new_changes
628 .insert(
629 *current_excerpt_id,
630 );
631 continue 'new_hunks;
632 } else {
633 if !new_changes
634 .hunks
635 .is_empty()
636 {
637 let hunks = new_excerpt_hunks
638 .entry(latest_excerpt_id)
639 .or_default();
640 match hunks.binary_search_by(|(probe, ..)| {
641 compare_paths(
642 (new_path.path.as_ref(), true),
643 (probe.path.as_ref(), true),
644 )
645 }) {
646 Ok(i) => hunks[i].2.extend(
647 new_changes
648 .hunks
649 .iter()
650 .map(|hunk| hunk.buffer_range.clone()),
651 ),
652 Err(i) => hunks.insert(
653 i,
654 (
655 new_path.clone(),
656 new_changes.buffer.clone(),
657 new_changes
658 .hunks
659 .iter()
660 .map(|hunk| hunk.buffer_range.clone())
661 .collect(),
662 ),
663 ),
664 }
665 }
666 continue 'new_hunks;
667 }
668 }
669 /* TODO remove or leave?
670 [ ><<<<<<<<new_e
671 ----[---->--]----<--
672 cur_s > cur_e <
673 > <
674 new_s>>>>>>>><
675 */
676 (
677 Ordering::Greater,
678 Ordering::Greater,
679 ) => {
680 if current_excerpt_range
681 .context
682 .end
683 .cmp(
684 &new_hunk
685 .buffer_range
686 .start,
687 &buffer_snapshot,
688 )
689 .is_ge()
690 {
691 let expand_down = new_hunk
692 .buffer_range
693 .end
694 .to_point(&buffer_snapshot)
695 .row
696 .saturating_sub(
697 current_excerpt_range
698 .context
699 .end
700 .to_point(
701 &buffer_snapshot,
702 )
703 .row,
704 );
705 excerpt_to_expand.entry((expand_down.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Down)).or_default().push(*current_excerpt_id);
706 excerpts_with_new_changes
707 .insert(
708 *current_excerpt_id,
709 );
710 continue 'new_hunks;
711 } else {
712 latest_excerpt_id =
713 *current_excerpt_id;
714 let _ =
715 current_excerpts.next();
716 }
717 }
718 }
719 }
720 None => {
721 let hunks = new_excerpt_hunks
722 .entry(latest_excerpt_id)
723 .or_default();
724 match hunks.binary_search_by(
725 |(probe, ..)| {
726 compare_paths(
727 (
728 new_path.path.as_ref(),
729 true,
730 ),
731 (probe.path.as_ref(), true),
732 )
733 },
734 ) {
735 Ok(i) => hunks[i].2.extend(
736 new_changes.hunks.iter().map(
737 |hunk| {
738 hunk.buffer_range
739 .clone()
740 },
741 ),
742 ),
743 Err(i) => hunks.insert(
744 i,
745 (
746 new_path.clone(),
747 new_changes.buffer.clone(),
748 new_changes
749 .hunks
750 .iter()
751 .map(|hunk| {
752 hunk.buffer_range
753 .clone()
754 })
755 .collect(),
756 ),
757 ),
758 }
759 continue 'new_hunks;
760 }
761 }
762 }
763 }
764
765 for (excerpt_id, excerpt_range) in current_excerpts {
766 if !excerpts_with_new_changes.contains(&excerpt_id)
767 && !new_hunks_unchanged.iter().any(|hunk| {
768 excerpt_range
769 .context
770 .start
771 .cmp(
772 &hunk.buffer_range.end,
773 &buffer_snapshot,
774 )
775 .is_le()
776 && excerpt_range
777 .context
778 .end
779 .cmp(
780 &hunk.buffer_range.start,
781 &buffer_snapshot,
782 )
783 .is_ge()
784 })
785 {
786 excerpts_to_remove.push(excerpt_id);
787 }
788 latest_excerpt_id = excerpt_id;
789 }
790 }
791 None => excerpts_to_remove.extend(
792 current_excerpts.map(|(excerpt_id, _)| excerpt_id),
793 ),
794 }
795 let _ = new_order_entries.next();
796 break;
797 }
798 }
799 }
800 None => {
801 excerpts_to_remove
802 .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id));
803 break;
804 }
805 }
806 }
807 latest_excerpt_id = last_current_excerpt_id.unwrap_or(latest_excerpt_id);
808 }
809
810 for (path, project_entry_id) in new_order_entries {
811 if let Some(changes) = new_changes.get(project_entry_id) {
812 if !changes.hunks.is_empty() {
813 let hunks = new_excerpt_hunks.entry(latest_excerpt_id).or_default();
814 match hunks.binary_search_by(|(probe, ..)| {
815 compare_paths((path.path.as_ref(), true), (probe.path.as_ref(), true))
816 }) {
817 Ok(i) => hunks[i]
818 .2
819 .extend(changes.hunks.iter().map(|hunk| hunk.buffer_range.clone())),
820 Err(i) => hunks.insert(
821 i,
822 (
823 path.clone(),
824 changes.buffer.clone(),
825 changes
826 .hunks
827 .iter()
828 .map(|hunk| hunk.buffer_range.clone())
829 .collect(),
830 ),
831 ),
832 }
833 }
834 }
835 }
836
837 self.excerpts.update(cx, |multi_buffer, cx| {
838 for (mut after_excerpt_id, excerpts_to_add) in new_excerpt_hunks {
839 for (_, buffer, hunk_ranges) in excerpts_to_add {
840 let buffer_snapshot = buffer.read(cx).snapshot();
841 let max_point = buffer_snapshot.max_point();
842 let new_excerpts = multi_buffer.insert_excerpts_after(
843 after_excerpt_id,
844 buffer,
845 hunk_ranges.into_iter().map(|range| {
846 let mut extended_point_range = range.to_point(&buffer_snapshot);
847 extended_point_range.start.row = extended_point_range
848 .start
849 .row
850 .saturating_sub(DEFAULT_MULTIBUFFER_CONTEXT);
851 extended_point_range.end.row = (extended_point_range.end.row
852 + DEFAULT_MULTIBUFFER_CONTEXT)
853 .min(max_point.row);
854 ExcerptRange {
855 context: extended_point_range,
856 primary: None,
857 }
858 }),
859 cx,
860 );
861 after_excerpt_id = new_excerpts.last().copied().unwrap_or(after_excerpt_id);
862 }
863 }
864 multi_buffer.remove_excerpts(excerpts_to_remove, cx);
865 for ((line_count, direction), excerpts) in excerpt_to_expand {
866 multi_buffer.expand_excerpts(excerpts, line_count, direction, cx);
867 }
868 });
869 } else {
870 self.excerpts.update(cx, |multi_buffer, cx| {
871 for new_changes in new_entry_order
872 .iter()
873 .filter_map(|(_, entry_id)| new_changes.get(entry_id))
874 {
875 multi_buffer.push_excerpts_with_context_lines(
876 new_changes.buffer.clone(),
877 new_changes
878 .hunks
879 .iter()
880 .map(|hunk| hunk.buffer_range.clone())
881 .collect(),
882 DEFAULT_MULTIBUFFER_CONTEXT,
883 cx,
884 );
885 }
886 });
887 };
888
889 let mut new_changes = new_changes;
890 let mut new_entry_order = new_entry_order;
891 std::mem::swap(
892 self.buffer_changes.entry(worktree_id).or_default(),
893 &mut new_changes,
894 );
895 std::mem::swap(
896 self.entry_order.entry(worktree_id).or_default(),
897 &mut new_entry_order,
898 );
899 }
900}
901
902impl EventEmitter<EditorEvent> for ProjectDiffEditor {}
903
904impl FocusableView for ProjectDiffEditor {
905 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
906 self.focus_handle.clone()
907 }
908}
909
910impl Item for ProjectDiffEditor {
911 type Event = EditorEvent;
912
913 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
914 Editor::to_item_events(event, f)
915 }
916
917 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
918 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
919 }
920
921 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
922 self.editor
923 .update(cx, |editor, cx| editor.navigate(data, cx))
924 }
925
926 fn tab_tooltip_text(&self, _: &AppContext) -> Option<SharedString> {
927 Some("Project Diff".into())
928 }
929
930 fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
931 if self.buffer_changes.is_empty() {
932 Label::new("No changes")
933 .color(if params.selected {
934 Color::Default
935 } else {
936 Color::Muted
937 })
938 .into_any_element()
939 } else {
940 h_flex()
941 .gap_1()
942 .when(true, |then| {
943 then.child(
944 h_flex()
945 .gap_1()
946 .child(Icon::new(IconName::XCircle).color(Color::Error))
947 .child(Label::new(self.buffer_changes.len().to_string()).color(
948 if params.selected {
949 Color::Default
950 } else {
951 Color::Muted
952 },
953 )),
954 )
955 })
956 .when(true, |then| {
957 then.child(
958 h_flex()
959 .gap_1()
960 .child(Icon::new(IconName::Indicator).color(Color::Warning))
961 .child(Label::new(self.buffer_changes.len().to_string()).color(
962 if params.selected {
963 Color::Default
964 } else {
965 Color::Muted
966 },
967 )),
968 )
969 })
970 .into_any_element()
971 }
972 }
973
974 fn telemetry_event_text(&self) -> Option<&'static str> {
975 Some("project diagnostics")
976 }
977
978 fn for_each_project_item(
979 &self,
980 cx: &AppContext,
981 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
982 ) {
983 self.editor.for_each_project_item(cx, f)
984 }
985
986 fn is_singleton(&self, _: &AppContext) -> bool {
987 false
988 }
989
990 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
991 self.editor.update(cx, |editor, _| {
992 editor.set_nav_history(Some(nav_history));
993 });
994 }
995
996 fn clone_on_split(
997 &self,
998 _workspace_id: Option<workspace::WorkspaceId>,
999 cx: &mut ViewContext<Self>,
1000 ) -> Option<View<Self>>
1001 where
1002 Self: Sized,
1003 {
1004 Some(cx.new_view(|cx| {
1005 ProjectDiffEditor::new(self.project.clone(), self.workspace.clone(), cx)
1006 }))
1007 }
1008
1009 fn is_dirty(&self, cx: &AppContext) -> bool {
1010 self.excerpts.read(cx).is_dirty(cx)
1011 }
1012
1013 fn has_conflict(&self, cx: &AppContext) -> bool {
1014 self.excerpts.read(cx).has_conflict(cx)
1015 }
1016
1017 fn can_save(&self, _: &AppContext) -> bool {
1018 true
1019 }
1020
1021 fn save(
1022 &mut self,
1023 format: bool,
1024 project: Model<Project>,
1025 cx: &mut ViewContext<Self>,
1026 ) -> Task<anyhow::Result<()>> {
1027 self.editor.save(format, project, cx)
1028 }
1029
1030 fn save_as(
1031 &mut self,
1032 _: Model<Project>,
1033 _: ProjectPath,
1034 _: &mut ViewContext<Self>,
1035 ) -> Task<anyhow::Result<()>> {
1036 unreachable!()
1037 }
1038
1039 fn reload(
1040 &mut self,
1041 project: Model<Project>,
1042 cx: &mut ViewContext<Self>,
1043 ) -> Task<anyhow::Result<()>> {
1044 self.editor.reload(project, cx)
1045 }
1046
1047 fn act_as_type<'a>(
1048 &'a self,
1049 type_id: TypeId,
1050 self_handle: &'a View<Self>,
1051 _: &'a AppContext,
1052 ) -> Option<AnyView> {
1053 if type_id == TypeId::of::<Self>() {
1054 Some(self_handle.to_any())
1055 } else if type_id == TypeId::of::<Editor>() {
1056 Some(self.editor.to_any())
1057 } else {
1058 None
1059 }
1060 }
1061
1062 fn breadcrumb_location(&self, _: &AppContext) -> ToolbarItemLocation {
1063 ToolbarItemLocation::PrimaryLeft
1064 }
1065
1066 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
1067 self.editor.breadcrumbs(theme, cx)
1068 }
1069
1070 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
1071 self.editor
1072 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
1073 }
1074}
1075
1076impl Render for ProjectDiffEditor {
1077 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1078 let child = if self.buffer_changes.is_empty() {
1079 div()
1080 .bg(cx.theme().colors().editor_background)
1081 .flex()
1082 .items_center()
1083 .justify_center()
1084 .size_full()
1085 .child(Label::new("No changes in the workspace"))
1086 } else {
1087 div().size_full().child(self.editor.clone())
1088 };
1089
1090 div()
1091 .track_focus(&self.focus_handle)
1092 .size_full()
1093 .child(child)
1094 }
1095}
1096
1097#[cfg(test)]
1098mod tests {
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 &[(Path::new("file_a"), GitFileStatus::Modified)],
1217 );
1218 cx.executor()
1219 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
1220 cx.run_until_parked();
1221
1222 project_diff_editor.update(cx, |project_diff_editor, cx| {
1223 assert_eq!(
1224 // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added)
1225 project_diff_editor.editor.read(cx).text(cx),
1226 format!("{change}{old_text}"),
1227 "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards"
1228 );
1229 });
1230 }
1231
1232 fn init_test(cx: &mut gpui::TestAppContext) {
1233 if std::env::var("RUST_LOG").is_ok() {
1234 env_logger::try_init().ok();
1235 }
1236
1237 cx.update(|cx| {
1238 assets::Assets.load_test_fonts(cx);
1239 let settings_store = SettingsStore::test(cx);
1240 cx.set_global(settings_store);
1241 theme::init(theme::LoadThemes::JustBase, cx);
1242 release_channel::init(SemanticVersion::default(), cx);
1243 client::init_settings(cx);
1244 language::init(cx);
1245 Project::init_settings(cx);
1246 workspace::init_settings(cx);
1247 crate::init(cx);
1248 cx.set_staff(true);
1249 });
1250 }
1251}