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