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