Skip to repository content1039 lines · 38.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:27:56.127Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
diff_multibuffer.rs
1use crate::{
2 conflict_view,
3 git_panel::{GitPanel, GitStatusEntry},
4 git_panel_settings::GitPanelSettings,
5};
6use anyhow::Result;
7use buffer_diff::BufferDiff;
8use collections::{HashMap, HashSet};
9use editor::{
10 EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::GoToHunk,
11 multibuffer_context_lines, scroll::Autoscroll,
12};
13use futures_lite::future::yield_now;
14use git::{repository::RepoPath, status::FileStatus};
15use gpui::{
16 App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Render,
17 SharedString, Subscription, Task, WeakEntity,
18};
19use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
20use multi_buffer::{MultiBuffer, PathKey};
21use project::{
22 ConflictSet, Project, ProjectPath,
23 git_store::{
24 Repository,
25 diff_buffer_list::{self, BranchDiffEvent, DiffBase},
26 },
27};
28use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore};
29use std::{collections::BTreeMap, sync::Arc};
30use theme::ActiveTheme;
31use ui::{CommonAnimationExt as _, KeyBinding, prelude::*};
32use util::{ResultExt as _, rel_path::RelPath};
33use workspace::{
34 CloseActiveItem, ItemNavHistory, Workspace,
35 item::{Item, SaveOptions},
36};
37use ztracing::instrument;
38
39struct BufferSubscriptions {
40 _diff: Entity<BufferDiff>,
41 display_buffer: Entity<Buffer>,
42 _diff_subscription: Subscription,
43 _conflict_set: Option<Entity<ConflictSet>>,
44 _conflict_set_subscription: Option<Subscription>,
45}
46
47pub struct DiffMultibuffer {
48 multibuffer: Entity<MultiBuffer>,
49 branch_diff: Entity<diff_buffer_list::DiffBufferList>,
50 editor: Entity<SplittableEditor>,
51 buffer_subscriptions: HashMap<RepoPath, BufferSubscriptions>,
52 workspace: WeakEntity<Workspace>,
53 focus_handle: FocusHandle,
54 pending_scroll: Option<PathKey>,
55 review_comment_count: usize,
56 empty_label: SharedString,
57 _task: Task<Result<()>>,
58 _subscription: Subscription,
59}
60
61impl DiffMultibuffer {
62 pub(crate) fn new(
63 branch_diff: Entity<diff_buffer_list::DiffBufferList>,
64 multibuffer_capability: Capability,
65 empty_label: impl Into<SharedString>,
66 configure_editor: impl FnOnce(&mut SplittableEditor, &mut Context<SplittableEditor>) + 'static,
67 project: Entity<Project>,
68 workspace: Entity<Workspace>,
69 window: &mut Window,
70 cx: &mut Context<Self>,
71 ) -> Self {
72 let focus_handle = cx.focus_handle();
73 let multibuffer = cx.new(|cx| {
74 let mut multibuffer = MultiBuffer::new(multibuffer_capability);
75 multibuffer.set_all_diff_hunks_expanded(cx);
76 multibuffer
77 });
78 let editor = cx.new(|cx| {
79 let mut diff_display_editor = SplittableEditor::new(
80 EditorSettings::get_global(cx).diff_view_style,
81 multibuffer.clone(),
82 project.clone(),
83 workspace.clone(),
84 window,
85 cx,
86 );
87 configure_editor(&mut diff_display_editor, cx);
88 diff_display_editor.rhs_editor().update(cx, |editor, cx| {
89 editor.set_show_diff_review_button(true, cx);
90 });
91 diff_display_editor
92 });
93 let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
94
95 let primary_editor = editor.read(cx).rhs_editor().clone();
96 let review_comment_subscription =
97 cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
98 if let EditorEvent::ReviewCommentsChanged { total_count } = event {
99 this.review_comment_count = *total_count;
100 cx.notify();
101 }
102 });
103
104 let branch_diff_subscription = cx.subscribe_in(
105 &branch_diff,
106 window,
107 move |this, _git_store, event, window, cx| match event {
108 BranchDiffEvent::FileListChanged => {
109 this._task = window.spawn(cx, {
110 let this = cx.weak_entity();
111 async |cx| Self::refresh(this, cx).await
112 })
113 }
114 BranchDiffEvent::DiffBaseChanged => {
115 this.pending_scroll.take();
116 this._task = window.spawn(cx, {
117 let this = cx.weak_entity();
118 async |cx| Self::refresh(this, cx).await
119 })
120 }
121 },
122 );
123
124 let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by;
125 let mut was_group_by = GitPanelSettings::get_global(cx).group_by;
126 let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
127 let mut was_collapse_untracked_diff =
128 GitPanelSettings::get_global(cx).collapse_untracked_diff;
129 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
130 let settings = GitPanelSettings::get_global(cx);
131 let sort_by = settings.sort_by;
132 let group_by = settings.group_by;
133 let tree_view = settings.tree_view;
134 let is_collapse_untracked_diff = settings.collapse_untracked_diff;
135 if sort_by != was_sort_by
136 || group_by != was_group_by
137 || tree_view != was_tree_view
138 || is_collapse_untracked_diff != was_collapse_untracked_diff
139 {
140 this._task = {
141 window.spawn(cx, {
142 let this = cx.weak_entity();
143 async |cx| Self::refresh(this, cx).await
144 })
145 }
146 }
147 was_sort_by = sort_by;
148 was_group_by = group_by;
149 was_tree_view = tree_view;
150 was_collapse_untracked_diff = is_collapse_untracked_diff;
151 })
152 .detach();
153
154 let task = window.spawn(cx, {
155 let this = cx.weak_entity();
156 async |cx| Self::refresh(this, cx).await
157 });
158
159 Self {
160 workspace: workspace.downgrade(),
161 branch_diff,
162 focus_handle,
163 editor,
164 multibuffer,
165 buffer_subscriptions: Default::default(),
166 pending_scroll: None,
167 review_comment_count: 0,
168 empty_label: empty_label.into(),
169 _task: task,
170 _subscription: Subscription::join(
171 branch_diff_subscription,
172 Subscription::join(editor_subscription, review_comment_subscription),
173 ),
174 }
175 }
176
177 pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
178 self.branch_diff.read(cx).diff_base()
179 }
180
181 pub(crate) fn branch_diff(&self) -> &Entity<diff_buffer_list::DiffBufferList> {
182 &self.branch_diff
183 }
184
185 pub(crate) fn repo(&self, cx: &App) -> Option<Entity<Repository>> {
186 self.branch_diff.read(cx).repo().cloned()
187 }
188
189 pub(crate) fn set_repo(&mut self, repo: Option<Entity<Repository>>, cx: &mut Context<Self>) {
190 self.branch_diff.update(cx, |branch_diff, cx| {
191 branch_diff.set_repo(repo, cx);
192 });
193 }
194
195 pub(crate) fn is_dirty(&self, cx: &App) -> bool {
196 self.multibuffer.read(cx).is_dirty(cx)
197 }
198
199 pub(crate) fn has_conflict(&self, cx: &App) -> bool {
200 self.multibuffer.read(cx).has_conflict(cx)
201 }
202
203 pub(crate) fn multibuffer(&self) -> &Entity<MultiBuffer> {
204 &self.multibuffer
205 }
206
207 pub(crate) fn move_to_entry(
208 &mut self,
209 entry: GitStatusEntry,
210 window: &mut Window,
211 cx: &mut Context<Self>,
212 ) {
213 let Some(git_repo) = self.branch_diff.read(cx).repo() else {
214 return;
215 };
216 let repo = git_repo.read(cx);
217 let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx);
218
219 self.move_to_path(path_key, window, cx)
220 }
221
222 pub(crate) fn move_to_project_path(
223 &mut self,
224 project_path: &ProjectPath,
225 window: &mut Window,
226 cx: &mut Context<Self>,
227 ) {
228 let Some(git_repo) = self.branch_diff.read(cx).repo() else {
229 return;
230 };
231 let Some(repo_path) = git_repo
232 .read(cx)
233 .project_path_to_repo_path(project_path, cx)
234 else {
235 return;
236 };
237 let status = git_repo
238 .read(cx)
239 .status_for_path(&repo_path)
240 .map(|entry| entry.status)
241 .unwrap_or(FileStatus::Untracked);
242 let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx);
243 self.move_to_path(path_key, window, cx)
244 }
245
246 pub(crate) fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
247 self.editor.update(cx, |editor, cx| {
248 editor.rhs_editor().update(cx, |editor, cx| {
249 editor.change_selections(Default::default(), window, cx, |s| {
250 s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]);
251 });
252 });
253 });
254 }
255
256 pub(crate) fn move_to_path(
257 &mut self,
258 path_key: PathKey,
259 window: &mut Window,
260 cx: &mut Context<Self>,
261 ) {
262 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
263 self.editor.update(cx, |editor, cx| {
264 editor.rhs_editor().update(cx, |editor, cx| {
265 editor.change_selections(
266 SelectionEffects::scroll(Autoscroll::focused()),
267 window,
268 cx,
269 |s| {
270 s.select_ranges([position..position]);
271 },
272 )
273 })
274 });
275 } else {
276 self.pending_scroll = Some(path_key);
277 }
278 }
279
280 pub(crate) fn autoscroll(&self, cx: &mut Context<Self>) {
281 self.editor.update(cx, |editor, cx| {
282 editor.rhs_editor().update(cx, |editor, cx| {
283 editor.request_autoscroll(Autoscroll::fit(), cx);
284 })
285 })
286 }
287
288 pub(crate) fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
289 self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
290 }
291
292 /// Returns the total count of review comments across all hunks/files.
293 pub(crate) fn total_review_comment_count(&self) -> usize {
294 self.review_comment_count
295 }
296
297 /// Returns a reference to the splittable editor.
298 pub(crate) fn editor(&self) -> &Entity<SplittableEditor> {
299 &self.editor
300 }
301
302 pub(crate) fn selected_ranges(
303 &self,
304 cx: &App,
305 ) -> (bool, Vec<std::ops::Range<multi_buffer::Anchor>>) {
306 let editor = self.editor.read(cx).rhs_editor().read(cx);
307 let snapshot = self.multibuffer.read(cx).snapshot(cx);
308 let mut selection = true;
309 let mut ranges = editor
310 .selections
311 .disjoint_anchor_ranges()
312 .collect::<Vec<_>>();
313 if !ranges.iter().any(|range| range.start != range.end) {
314 selection = false;
315 let anchor = editor.selections.newest_anchor().head();
316 if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
317 && let Some(range) = snapshot
318 .anchor_in_buffer(excerpt_range.context.start)
319 .zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
320 .map(|(start, end)| start..end)
321 {
322 ranges = vec![range];
323 } else {
324 ranges = Vec::default();
325 };
326 }
327
328 (selection, ranges)
329 }
330
331 /// Ranges for a toolbar stage/unstage action: the selection, or the cursor
332 /// (a zero-width range that resolves to the single hunk under it) when
333 /// there is no selection. Unlike [`Self::selected_ranges`], this never
334 /// widens to the whole excerpt, so actions affect one hunk at a time.
335 fn hunk_action_ranges(&self, cx: &App) -> Vec<std::ops::Range<multi_buffer::Anchor>> {
336 self.editor
337 .read(cx)
338 .rhs_editor()
339 .read(cx)
340 .selections
341 .disjoint_anchor_ranges()
342 .collect()
343 }
344
345 pub(crate) fn stage_or_unstage_selected_hunks(
346 &mut self,
347 stage: bool,
348 move_to_next: bool,
349 window: &mut Window,
350 cx: &mut Context<Self>,
351 ) {
352 let editor = self.editor.read(cx).rhs_editor().clone();
353 let ranges = self.hunk_action_ranges(cx);
354 // Route through the editor's delegated stage path, the same path taken
355 // by the hunk buttons (on either side of a split) and the keyboard.
356 // For staging, dirty buffers are saved first, exactly as they are when
357 // staging from the uncommitted diff or a normal editor.
358 editor.update(cx, |editor, cx| {
359 editor.stage_or_unstage_diff_hunks(stage, ranges, window, cx);
360 });
361 if move_to_next {
362 editor
363 .focus_handle(cx)
364 .dispatch_action(&GoToHunk, window, cx);
365 }
366 }
367
368 pub(crate) fn restore_selected_hunks(
369 &mut self,
370 move_to_next: bool,
371 window: &mut Window,
372 cx: &mut Context<Self>,
373 ) {
374 let editor = self.editor.read(cx).rhs_editor().clone();
375 let ranges = self.hunk_action_ranges(cx);
376 editor.update(cx, |editor, cx| {
377 let snapshot = editor.buffer().read(cx).snapshot(cx);
378 let hunks: Vec<_> = editor.diff_hunks_in_ranges(&ranges, &snapshot).collect();
379 if !hunks.is_empty() {
380 editor.apply_restore(hunks, window, cx);
381 }
382 });
383 if move_to_next {
384 editor
385 .focus_handle(cx)
386 .dispatch_action(&GoToHunk, window, cx);
387 }
388 }
389
390 fn handle_editor_event(
391 &mut self,
392 editor: &Entity<SplittableEditor>,
393 event: &EditorEvent,
394 window: &mut Window,
395 cx: &mut Context<Self>,
396 ) {
397 match event {
398 EditorEvent::SelectionsChanged { local: true } => {
399 // Only follow the git panel selection from the view the user is
400 // actually interacting with. Background (non-active) diff views
401 // refresh on their own and must not hijack the panel selection.
402 if !editor.focus_handle(cx).contains_focused(window, cx) {
403 return;
404 }
405 let Some(project_path) = self.active_project_path(cx) else {
406 return;
407 };
408 self.workspace
409 .update(cx, |workspace, cx| {
410 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
411 git_panel.update(cx, |git_panel, cx| {
412 git_panel.select_entry_by_path(project_path, window, cx)
413 })
414 }
415 })
416 .ok();
417 }
418 EditorEvent::Saved => {
419 self._task =
420 cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await);
421 }
422
423 _ => {}
424 }
425 if editor.focus_handle(cx).contains_focused(window, cx)
426 && self.multibuffer.read(cx).is_empty()
427 {
428 self.focus_handle.focus(window, cx)
429 }
430 }
431
432 #[instrument(skip_all)]
433 fn register_buffer(
434 &mut self,
435 repo_path: RepoPath,
436 path_key: PathKey,
437 file_status: FileStatus,
438 display_buffer: Entity<Buffer>,
439 main_buffer: Entity<Buffer>,
440 diff: Entity<BufferDiff>,
441 conflict_set: Option<Entity<ConflictSet>>,
442 window: &mut Window,
443 cx: &mut Context<Self>,
444 ) -> Option<BufferId> {
445 let diff_subscription = cx.subscribe_in(&diff, window, {
446 let repo_path = repo_path.clone();
447 let path_key = path_key.clone();
448 let display_buffer = display_buffer.clone();
449 let main_buffer = main_buffer.clone();
450 let diff = diff.clone();
451 let conflict_set = conflict_set.clone();
452 move |this, _, event, window, cx| match event {
453 buffer_diff::BufferDiffEvent::DiffChanged(_) => {
454 this.buffer_ranges_changed(
455 repo_path.clone(),
456 path_key.clone(),
457 file_status,
458 display_buffer.clone(),
459 main_buffer.clone(),
460 diff.clone(),
461 conflict_set.clone(),
462 window,
463 cx,
464 );
465 }
466 buffer_diff::BufferDiffEvent::BaseTextChanged => {}
467 }
468 });
469 let conflict_set_subscription = conflict_set.as_ref().map(|conflict_set| {
470 cx.subscribe_in(conflict_set, window, {
471 let repo_path = repo_path.clone();
472 let path_key = path_key.clone();
473 let display_buffer = display_buffer.clone();
474 let main_buffer = main_buffer.clone();
475 let diff = diff.clone();
476 let conflict_set = Some(conflict_set.clone());
477 move |this, _, _, window, cx| {
478 this.buffer_ranges_changed(
479 repo_path.clone(),
480 path_key.clone(),
481 file_status,
482 display_buffer.clone(),
483 main_buffer.clone(),
484 diff.clone(),
485 conflict_set.clone(),
486 window,
487 cx,
488 )
489 }
490 })
491 });
492 self.buffer_subscriptions.insert(
493 repo_path,
494 BufferSubscriptions {
495 _diff: diff.clone(),
496 display_buffer: display_buffer.clone(),
497 _diff_subscription: diff_subscription,
498 _conflict_set: conflict_set.clone(),
499 _conflict_set_subscription: conflict_set_subscription,
500 },
501 );
502
503 let snapshot = display_buffer.read(cx).snapshot();
504 let diff_snapshot = diff.read(cx).snapshot(cx);
505
506 let excerpt_ranges = {
507 let diff_hunk_ranges = diff_snapshot
508 .hunks_intersecting_range(
509 Anchor::min_max_range_for_buffer(snapshot.remote_id()),
510 &snapshot,
511 )
512 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
513 let conflict_ranges = conflict_set.as_ref().and_then(|conflict_set| {
514 let conflicts = conflict_set.read(cx).snapshot();
515 let conflicts = conflicts
516 .conflicts
517 .iter()
518 .map(|conflict| conflict.range.to_point(&snapshot))
519 .collect::<Vec<_>>();
520 (!conflicts.is_empty()).then_some(conflicts)
521 });
522
523 conflict_ranges.unwrap_or_else(|| diff_hunk_ranges.collect())
524 };
525
526 let buffer_id = snapshot.text.remote_id();
527 let mut needs_fold = false;
528
529 let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
530 let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
531 let is_newly_added = editor.update_excerpts_for_path(
532 path_key.clone(),
533 display_buffer,
534 excerpt_ranges,
535 multibuffer_context_lines(cx),
536 diff,
537 cx,
538 );
539 if let Some(conflict_set) = conflict_set {
540 editor.rhs_editor().update(cx, |editor, cx| {
541 conflict_view::buffer_ranges_updated(editor, conflict_set, cx);
542 });
543 }
544 (was_empty, is_newly_added)
545 });
546
547 self.editor.update(cx, |editor, cx| {
548 editor.rhs_editor().update(cx, |editor, cx| {
549 if was_empty {
550 editor.change_selections(
551 SelectionEffects::no_scroll(),
552 window,
553 cx,
554 |selections| {
555 selections.select_ranges([
556 multi_buffer::Anchor::Min..multi_buffer::Anchor::Min
557 ])
558 },
559 );
560 }
561 if is_excerpt_newly_added
562 && (file_status.is_deleted()
563 || (file_status.is_untracked()
564 && GitPanelSettings::get_global(cx).collapse_untracked_diff))
565 {
566 needs_fold = true;
567 }
568 })
569 });
570
571 if self.multibuffer.read(cx).is_empty()
572 && self
573 .editor
574 .read(cx)
575 .focus_handle(cx)
576 .contains_focused(window, cx)
577 {
578 self.focus_handle.focus(window, cx);
579 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
580 self.editor.update(cx, |editor, cx| {
581 editor.focus_handle(cx).focus(window, cx);
582 });
583 }
584 if self.pending_scroll.as_ref() == Some(&path_key) {
585 self.move_to_path(path_key, window, cx);
586 }
587
588 needs_fold.then_some(buffer_id)
589 }
590
591 fn buffer_ranges_changed(
592 &mut self,
593 repo_path: RepoPath,
594 path_key: PathKey,
595 file_status: FileStatus,
596 display_buffer: Entity<Buffer>,
597 main_buffer: Entity<Buffer>,
598 diff: Entity<BufferDiff>,
599 conflict_set: Option<Entity<ConflictSet>>,
600 window: &mut Window,
601 cx: &mut Context<Self>,
602 ) {
603 if display_buffer.read(cx).is_dirty() {
604 return;
605 }
606 self.register_buffer(
607 repo_path,
608 path_key,
609 file_status,
610 display_buffer,
611 main_buffer,
612 diff,
613 conflict_set,
614 window,
615 cx,
616 );
617 }
618
619 #[instrument(skip(this, cx))]
620 pub(crate) async fn refresh(this: WeakEntity<Self>, cx: &mut AsyncWindowContext) -> Result<()> {
621 let entries = this.update(cx, |this, cx| {
622 let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
623 let load_buffers = branch_diff.load_buffers(cx);
624 (branch_diff.repo().cloned(), load_buffers)
625 });
626 let mut previous_paths = this
627 .multibuffer
628 .read(cx)
629 .snapshot(cx)
630 .buffers_with_paths()
631 .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id()))
632 .collect::<HashMap<_, _>>();
633
634 let mut entries = BTreeMap::new();
635 let mut live_repo_paths = HashSet::default();
636 if let Some(repo) = repo {
637 let repo = repo.read(cx);
638 for diff_buffer in buffers_to_load {
639 live_repo_paths.insert(diff_buffer.repo_path.clone());
640 let path_key = project_diff_path_key(
641 &repo,
642 &diff_buffer.repo_path,
643 diff_buffer.file_status,
644 cx,
645 );
646 previous_paths.remove(&path_key);
647 entries.insert(path_key, diff_buffer);
648 }
649 }
650
651 let repo_path_by_display_id = this
652 .buffer_subscriptions
653 .iter()
654 .map(|(repo_path, sub)| {
655 (sub.display_buffer.read(cx).remote_id(), repo_path.clone())
656 })
657 .collect::<HashMap<_, _>>();
658
659 this.editor.update(cx, |editor, cx| {
660 for (path, buffer_id) in previous_paths {
661 if let Some(repo_path) = repo_path_by_display_id.get(&buffer_id) {
662 this.buffer_subscriptions.remove(repo_path);
663 }
664 editor.rhs_editor().update(cx, |editor, cx| {
665 conflict_view::buffers_removed(editor, &[buffer_id], cx);
666 });
667 let _span = ztracing::info_span!("remove_excerpts_for_path");
668 _span.enter();
669 editor.remove_excerpts_for_path(path, cx);
670 }
671 });
672
673 this.buffer_subscriptions
674 .retain(|repo_path, _| live_repo_paths.contains(repo_path));
675
676 entries
677 })?;
678
679 let mut buffers_to_fold = Vec::new();
680
681 for (path_key, entry) in entries {
682 if let Some(loaded_buffer) = entry.load.await.log_err() {
683 // We might be lagging behind enough that all future entry.load futures are no longer pending.
684 // If that is the case, this task will never yield, starving the foreground thread of execution time.
685 yield_now().await;
686 cx.update(|window, cx| {
687 this.update(cx, |this, cx| {
688 if let Some(buffer_id) = this.register_buffer(
689 entry.repo_path,
690 path_key,
691 entry.file_status,
692 loaded_buffer.display_buffer,
693 loaded_buffer.main_buffer,
694 loaded_buffer.diff,
695 loaded_buffer.conflict_set,
696 window,
697 cx,
698 ) {
699 buffers_to_fold.push(buffer_id);
700 }
701 })
702 .ok();
703 })?;
704 }
705 }
706 this.update(cx, |this, cx| {
707 if !buffers_to_fold.is_empty() {
708 this.editor.update(cx, |editor, cx| {
709 editor
710 .rhs_editor()
711 .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
712 });
713 }
714 this.pending_scroll.take();
715 cx.notify();
716 })?;
717
718 Ok(())
719 }
720
721 pub(crate) fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
722 let editor = self.editor.read(cx).focused_editor().read(cx);
723 let multibuffer = editor.buffer().read(cx);
724 let position = editor.selections.newest_anchor().head();
725 let snapshot = multibuffer.snapshot(cx);
726 let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
727 let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
728
729 let file = buffer.read(cx).file()?;
730 Some(ProjectPath {
731 worktree_id: file.worktree_id(cx),
732 path: file.path().clone(),
733 })
734 }
735
736 pub(crate) fn added_to_workspace(
737 &mut self,
738 workspace: &mut Workspace,
739 window: &mut Window,
740 cx: &mut Context<Self>,
741 ) {
742 self.editor.update(cx, |editor, cx| {
743 editor.added_to_workspace(workspace, window, cx)
744 });
745 }
746
747 pub(crate) fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
748 self.editor.update(cx, |editor, cx| {
749 editor.rhs_editor().update(cx, |primary_editor, cx| {
750 primary_editor.deactivated(window, cx);
751 })
752 });
753 }
754
755 pub(crate) fn navigate(
756 &mut self,
757 data: Arc<dyn std::any::Any + Send>,
758 window: &mut Window,
759 cx: &mut Context<Self>,
760 ) -> bool {
761 self.editor.update(cx, |editor, cx| {
762 editor.rhs_editor().update(cx, |primary_editor, cx| {
763 primary_editor.navigate(data, window, cx)
764 })
765 })
766 }
767
768 pub(crate) fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut Context<Self>) {
769 self.editor.update(cx, |editor, cx| {
770 editor.rhs_editor().update(cx, |primary_editor, _| {
771 primary_editor.set_nav_history(Some(nav_history));
772 })
773 });
774 }
775
776 pub(crate) fn for_each_project_item(
777 &self,
778 cx: &App,
779 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
780 ) {
781 self.editor
782 .read(cx)
783 .rhs_editor()
784 .read(cx)
785 .for_each_project_item(cx, f)
786 }
787
788 pub(crate) fn save(
789 &mut self,
790 options: SaveOptions,
791 project: Entity<Project>,
792 window: &mut Window,
793 cx: &mut Context<Self>,
794 ) -> Task<Result<()>> {
795 self.editor.update(cx, |editor, cx| {
796 editor.rhs_editor().update(cx, |primary_editor, cx| {
797 primary_editor.save(options, project, window, cx)
798 })
799 })
800 }
801
802 pub(crate) fn reload(
803 &mut self,
804 project: Entity<Project>,
805 window: &mut Window,
806 cx: &mut Context<Self>,
807 ) -> Task<Result<()>> {
808 self.editor.update(cx, |editor, cx| {
809 editor.rhs_editor().update(cx, |primary_editor, cx| {
810 primary_editor.reload(project, window, cx)
811 })
812 })
813 }
814
815 #[cfg(any(test, feature = "test-support"))]
816 pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
817 let snapshot = self
818 .editor()
819 .read(cx)
820 .rhs_editor()
821 .read(cx)
822 .buffer()
823 .read(cx)
824 .snapshot(cx);
825 snapshot
826 .excerpts()
827 .map(|excerpt| {
828 snapshot
829 .path_for_buffer(excerpt.context.start.buffer_id)
830 .unwrap()
831 .path
832 .clone()
833 })
834 .collect()
835 }
836
837 /// Returns the real (worktree-relative) path of each excerpted buffer, in
838 /// the order the excerpts appear in the multibuffer. Unlike
839 /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather
840 /// than the (possibly synthetic) `PathKey` path used for sorting.
841 #[cfg(any(test, feature = "test-support"))]
842 pub fn excerpt_file_paths(&self, cx: &App) -> Vec<String> {
843 let multibuffer = self
844 .editor()
845 .read(cx)
846 .rhs_editor()
847 .read(cx)
848 .buffer()
849 .clone();
850 let snapshot = multibuffer.read(cx).snapshot(cx);
851 let mut result = Vec::new();
852 let mut last_buffer_id = None;
853 for excerpt in snapshot.excerpts() {
854 let buffer_id = excerpt.context.start.buffer_id;
855 if last_buffer_id == Some(buffer_id) {
856 continue;
857 }
858 last_buffer_id = Some(buffer_id);
859 if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id)
860 && let Some(file) = buffer.read(cx).file()
861 {
862 result.push(file.path().as_unix_str().to_string());
863 }
864 }
865 result
866 }
867}
868
869impl EventEmitter<EditorEvent> for DiffMultibuffer {}
870
871impl Focusable for DiffMultibuffer {
872 fn focus_handle(&self, cx: &App) -> FocusHandle {
873 if self.multibuffer.read(cx).is_empty() {
874 self.focus_handle.clone()
875 } else {
876 self.editor.focus_handle(cx)
877 }
878 }
879}
880
881impl Render for DiffMultibuffer {
882 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
883 let is_empty = self.multibuffer.read(cx).is_empty();
884 let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready();
885 let empty_label = self.empty_label.clone();
886
887 div()
888 .track_focus(&self.focus_handle)
889 .key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
890 .bg(cx.theme().colors().editor_background)
891 .flex()
892 .items_center()
893 .justify_center()
894 .size_full()
895 .when(is_empty && is_loading, |el| {
896 let rems = TextSize::Large.rems(cx);
897 el.child(
898 Icon::new(IconName::LoadCircle)
899 .size(IconSize::Custom(rems))
900 .color(Color::Accent)
901 .with_rotate_animation(3)
902 .into_any_element(),
903 )
904 })
905 .when(is_empty && !is_loading, |el| {
906 let remote_button = if let Some(panel) = self
907 .workspace
908 .upgrade()
909 .and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
910 {
911 panel.update(cx, |panel, cx| panel.render_remote_button(cx))
912 } else {
913 None
914 };
915 let keybinding_focus_handle = self.focus_handle(cx);
916 el.child(
917 v_flex()
918 .gap_1()
919 .child(h_flex().justify_around().child(Label::new(empty_label)))
920 .map(|el| match remote_button {
921 Some(button) => el.child(h_flex().justify_around().child(button)),
922 None => el.child(
923 h_flex()
924 .justify_around()
925 .child(Label::new("Remote up to date")),
926 ),
927 })
928 .child(
929 h_flex().justify_around().mt_1().child(
930 Button::new("project-diff-close-button", "Close")
931 .key_binding(KeyBinding::for_action_in(
932 &CloseActiveItem::default(),
933 &keybinding_focus_handle,
934 cx,
935 ))
936 .on_click(move |_, window, cx| {
937 window.focus(&keybinding_focus_handle, cx);
938 window.dispatch_action(
939 Box::new(CloseActiveItem::default()),
940 cx,
941 );
942 }),
943 ),
944 ),
945 )
946 })
947 .when(!is_empty, |el| el.child(self.editor.clone()))
948 }
949}
950
951const CONFLICT_SORT_PREFIX: u64 = 1;
952const TRACKED_SORT_PREFIX: u64 = 2;
953const NEW_SORT_PREFIX: u64 = 3;
954
955/// Computes a stable [`PathKey`] for a buffer in the project diff.
956///
957/// The key is an intrinsic function of the file's own repo path and status; it
958/// never depends on which other buffers happen to be present in the
959/// multibuffer. This is required because the multibuffer uses the path key both
960/// to order excerpts and to identify which excerpts belong to a given buffer, so
961/// a key that shifted as files were added or removed would break that identity.
962///
963/// Status grouping is encoded in the `sort_prefix`, and the within-group order
964/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural
965/// ordering reproduces the git panel's order. The path here is only ever used
966/// for sorting and multibuffer identity; the path shown in the UI comes from the
967/// buffer's own `File`.
968pub(crate) fn project_diff_path_key(
969 repo: &Repository,
970 repo_path: &RepoPath,
971 status: FileStatus,
972 cx: &App,
973) -> PathKey {
974 let settings = GitPanelSettings::get_global(cx);
975 let sort_prefix = if settings.group_by != GitPanelGroupBy::Status {
976 TRACKED_SORT_PREFIX
977 } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
978 CONFLICT_SORT_PREFIX
979 } else if status.is_created() {
980 NEW_SORT_PREFIX
981 } else {
982 TRACKED_SORT_PREFIX
983 };
984 let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by);
985 PathKey::with_sort_prefix(sort_prefix, path)
986}
987
988fn project_diff_sort_path(
989 repo_path: &RelPath,
990 tree_view: bool,
991 sort_by: GitPanelSortBy,
992) -> Arc<RelPath> {
993 if tree_view {
994 tree_sort_path(repo_path)
995 } else {
996 match sort_by {
997 GitPanelSortBy::Path => repo_path.into_arc(),
998 GitPanelSortBy::Name => name_sort_path(repo_path),
999 }
1000 }
1001}
1002
1003/// Builds a synthetic path that sorts by file name first, falling back to the
1004/// full path to keep the key unique per file.
1005fn name_sort_path(repo_path: &RelPath) -> Arc<RelPath> {
1006 let Some(file_name) = repo_path.file_name() else {
1007 return repo_path.into_arc();
1008 };
1009 let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str());
1010 RelPath::from_unix_str(&synthetic)
1011 .map(|path| path.into_arc())
1012 .unwrap_or_else(|_| repo_path.into_arc())
1013}
1014
1015/// Builds a synthetic path whose natural component-wise ordering reproduces a
1016/// folder-first tree order. Each directory component is prefixed with a NUL
1017/// byte, which can never appear in a real path component and sorts before every
1018/// printable character, so at each level directories sort before files.
1019fn tree_sort_path(repo_path: &RelPath) -> Arc<RelPath> {
1020 let components: Vec<&str> = repo_path.components().collect();
1021 if components.len() <= 1 {
1022 return repo_path.into_arc();
1023 }
1024 let last = components.len() - 1;
1025 let mut synthetic = String::new();
1026 for (index, component) in components.into_iter().enumerate() {
1027 if index > 0 {
1028 synthetic.push('/');
1029 }
1030 if index < last {
1031 synthetic.push('\0');
1032 }
1033 synthetic.push_str(component);
1034 }
1035 RelPath::from_unix_str(&synthetic)
1036 .map(|path| path.into_arc())
1037 .unwrap_or_else(|_| repo_path.into_arc())
1038}
1039