1use crate::branch_picker::{self};
2use crate::git_panel_settings::StatusStyle;
3use crate::remote_output_toast::{RemoteAction, RemoteOutputToast};
4use crate::{
5 git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
6};
7use crate::{picker_prompt, project_diff, ProjectDiff};
8use db::kvp::KEY_VALUE_STORE;
9use editor::commit_tooltip::CommitTooltip;
10use editor::{
11 scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
12 ShowScrollbar,
13};
14use git::repository::{
15 Branch, CommitDetails, CommitSummary, PushOptions, Remote, RemoteCommandOutput, ResetMode,
16 Upstream, UpstreamTracking, UpstreamTrackingStatus,
17};
18use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
19use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
20use gpui::*;
21use itertools::Itertools;
22use language::{Buffer, File};
23use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
24use multi_buffer::ExcerptInfo;
25use panel::{
26 panel_editor_container, panel_editor_style, panel_filled_button, panel_icon_button, PanelHeader,
27};
28use project::{
29 git::{GitEvent, Repository},
30 Fs, Project, ProjectPath,
31};
32use serde::{Deserialize, Serialize};
33use settings::Settings as _;
34use smallvec::smallvec;
35use std::cell::RefCell;
36use std::future::Future;
37use std::rc::Rc;
38use std::{collections::HashSet, sync::Arc, time::Duration, usize};
39use strum::{IntoEnumIterator, VariantNames};
40use time::OffsetDateTime;
41use ui::{
42 prelude::*, ButtonLike, Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar,
43 ScrollbarState, Tooltip,
44};
45use util::{maybe, post_inc, ResultExt, TryFutureExt};
46
47use workspace::{
48 dock::{DockPosition, Panel, PanelEvent},
49 notifications::{DetachAndPromptErr, NotificationId},
50 Toast, Workspace,
51};
52
53actions!(
54 git_panel,
55 [
56 Close,
57 ToggleFocus,
58 OpenMenu,
59 FocusEditor,
60 FocusChanges,
61 ToggleFillCoAuthors,
62 ]
63);
64
65fn prompt<T>(msg: &str, detail: Option<&str>, window: &mut Window, cx: &mut App) -> Task<Result<T>>
66where
67 T: IntoEnumIterator + VariantNames + 'static,
68{
69 let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
70 cx.spawn(|_| async move { Ok(T::iter().nth(rx.await?).unwrap()) })
71}
72
73#[derive(strum::EnumIter, strum::VariantNames)]
74#[strum(serialize_all = "title_case")]
75enum TrashCancel {
76 Trash,
77 Cancel,
78}
79
80fn git_panel_context_menu(window: &mut Window, cx: &mut App) -> Entity<ContextMenu> {
81 ContextMenu::build(window, cx, |context_menu, _, _| {
82 context_menu
83 .action("Stage All", StageAll.boxed_clone())
84 .action("Unstage All", UnstageAll.boxed_clone())
85 .separator()
86 .action("Open Diff", project_diff::Diff.boxed_clone())
87 .separator()
88 .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
89 .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
90 })
91}
92
93const GIT_PANEL_KEY: &str = "GitPanel";
94
95const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
96
97pub fn init(cx: &mut App) {
98 cx.observe_new(
99 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
100 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
101 workspace.toggle_panel_focus::<GitPanel>(window, cx);
102 });
103 },
104 )
105 .detach();
106}
107
108#[derive(Debug, Clone)]
109pub enum Event {
110 Focus,
111}
112
113#[derive(Serialize, Deserialize)]
114struct SerializedGitPanel {
115 width: Option<Pixels>,
116}
117
118#[derive(Debug, PartialEq, Eq, Clone, Copy)]
119enum Section {
120 Conflict,
121 Tracked,
122 New,
123}
124
125#[derive(Debug, PartialEq, Eq, Clone)]
126struct GitHeaderEntry {
127 header: Section,
128}
129
130impl GitHeaderEntry {
131 pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
132 let this = &self.header;
133 let status = status_entry.status;
134 match this {
135 Section::Conflict => repo.has_conflict(&status_entry.repo_path),
136 Section::Tracked => !status.is_created(),
137 Section::New => status.is_created(),
138 }
139 }
140 pub fn title(&self) -> &'static str {
141 match self.header {
142 Section::Conflict => "Conflicts",
143 Section::Tracked => "Tracked",
144 Section::New => "Untracked",
145 }
146 }
147}
148
149#[derive(Debug, PartialEq, Eq, Clone)]
150enum GitListEntry {
151 GitStatusEntry(GitStatusEntry),
152 Header(GitHeaderEntry),
153}
154
155impl GitListEntry {
156 fn status_entry(&self) -> Option<&GitStatusEntry> {
157 match self {
158 GitListEntry::GitStatusEntry(entry) => Some(entry),
159 _ => None,
160 }
161 }
162}
163
164#[derive(Debug, PartialEq, Eq, Clone)]
165pub struct GitStatusEntry {
166 pub(crate) repo_path: RepoPath,
167 pub(crate) status: FileStatus,
168 pub(crate) is_staged: Option<bool>,
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172enum TargetStatus {
173 Staged,
174 Unstaged,
175 Reverted,
176 Unchanged,
177}
178
179struct PendingOperation {
180 finished: bool,
181 target_status: TargetStatus,
182 repo_paths: HashSet<RepoPath>,
183 op_id: usize,
184}
185
186type RemoteOperations = Rc<RefCell<HashSet<u32>>>;
187
188pub struct GitPanel {
189 remote_operation_id: u32,
190 pending_remote_operations: RemoteOperations,
191 pub(crate) active_repository: Option<Entity<Repository>>,
192 commit_editor: Entity<Editor>,
193 conflicted_count: usize,
194 conflicted_staged_count: usize,
195 current_modifiers: Modifiers,
196 add_coauthors: bool,
197 entries: Vec<GitListEntry>,
198 focus_handle: FocusHandle,
199 fs: Arc<dyn Fs>,
200 hide_scrollbar_task: Option<Task<()>>,
201 new_count: usize,
202 new_staged_count: usize,
203 pending: Vec<PendingOperation>,
204 pending_commit: Option<Task<()>>,
205 pending_serialization: Task<Option<()>>,
206 pub(crate) project: Entity<Project>,
207 scroll_handle: UniformListScrollHandle,
208 scrollbar_state: ScrollbarState,
209 selected_entry: Option<usize>,
210 marked_entries: Vec<usize>,
211 show_scrollbar: bool,
212 tracked_count: usize,
213 tracked_staged_count: usize,
214 update_visible_entries_task: Task<()>,
215 width: Option<Pixels>,
216 workspace: WeakEntity<Workspace>,
217 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
218 modal_open: bool,
219}
220
221struct RemoteOperationGuard {
222 id: u32,
223 pending_remote_operations: RemoteOperations,
224}
225
226impl Drop for RemoteOperationGuard {
227 fn drop(&mut self) {
228 self.pending_remote_operations.borrow_mut().remove(&self.id);
229 }
230}
231
232pub(crate) fn commit_message_editor(
233 commit_message_buffer: Entity<Buffer>,
234 placeholder: Option<&str>,
235 project: Entity<Project>,
236 in_panel: bool,
237 window: &mut Window,
238 cx: &mut Context<'_, Editor>,
239) -> Editor {
240 let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
241 let max_lines = if in_panel { 6 } else { 18 };
242 let mut commit_editor = Editor::new(
243 EditorMode::AutoHeight { max_lines },
244 buffer,
245 None,
246 false,
247 window,
248 cx,
249 );
250 commit_editor.set_collaboration_hub(Box::new(project));
251 commit_editor.set_use_autoclose(false);
252 commit_editor.set_show_gutter(false, cx);
253 commit_editor.set_show_wrap_guides(false, cx);
254 commit_editor.set_show_indent_guides(false, cx);
255 let placeholder = placeholder.unwrap_or("Enter commit message");
256 commit_editor.set_placeholder_text(placeholder, cx);
257 commit_editor
258}
259
260impl GitPanel {
261 pub fn new(
262 workspace: &mut Workspace,
263 window: &mut Window,
264 cx: &mut Context<Workspace>,
265 ) -> Entity<Self> {
266 let fs = workspace.app_state().fs.clone();
267 let project = workspace.project().clone();
268 let git_store = project.read(cx).git_store().clone();
269 let active_repository = project.read(cx).active_repository(cx);
270 let workspace = cx.entity().downgrade();
271
272 cx.new(|cx| {
273 let focus_handle = cx.focus_handle();
274 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
275 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
276 this.hide_scrollbar(window, cx);
277 })
278 .detach();
279
280 // just to let us render a placeholder editor.
281 // Once the active git repo is set, this buffer will be replaced.
282 let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
283 let commit_editor = cx.new(|cx| {
284 commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
285 });
286
287 commit_editor.update(cx, |editor, cx| {
288 editor.clear(window, cx);
289 });
290
291 let scroll_handle = UniformListScrollHandle::new();
292
293 cx.subscribe_in(
294 &git_store,
295 window,
296 move |this, git_store, event, window, cx| match event {
297 GitEvent::FileSystemUpdated => {
298 this.schedule_update(false, window, cx);
299 }
300 GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
301 this.active_repository = git_store.read(cx).active_repository();
302 this.schedule_update(true, window, cx);
303 }
304 },
305 )
306 .detach();
307
308 let scrollbar_state =
309 ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
310
311 let mut git_panel = Self {
312 pending_remote_operations: Default::default(),
313 remote_operation_id: 0,
314 active_repository,
315 commit_editor,
316 conflicted_count: 0,
317 conflicted_staged_count: 0,
318 current_modifiers: window.modifiers(),
319 add_coauthors: true,
320 entries: Vec::new(),
321 focus_handle: cx.focus_handle(),
322 fs,
323 hide_scrollbar_task: None,
324 new_count: 0,
325 new_staged_count: 0,
326 pending: Vec::new(),
327 pending_commit: None,
328 pending_serialization: Task::ready(None),
329 project,
330 scroll_handle,
331 scrollbar_state,
332 selected_entry: None,
333 marked_entries: Vec::new(),
334 show_scrollbar: false,
335 tracked_count: 0,
336 tracked_staged_count: 0,
337 update_visible_entries_task: Task::ready(()),
338 width: Some(px(360.)),
339 context_menu: None,
340 workspace,
341 modal_open: false,
342 };
343 git_panel.schedule_update(false, window, cx);
344 git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
345 git_panel
346 })
347 }
348
349 pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
350 fn binary_search<F>(mut low: usize, mut high: usize, is_target: F) -> Option<usize>
351 where
352 F: Fn(usize) -> std::cmp::Ordering,
353 {
354 while low < high {
355 let mid = low + (high - low) / 2;
356 match is_target(mid) {
357 std::cmp::Ordering::Equal => return Some(mid),
358 std::cmp::Ordering::Less => low = mid + 1,
359 std::cmp::Ordering::Greater => high = mid,
360 }
361 }
362 None
363 }
364 if self.conflicted_count > 0 {
365 let conflicted_start = 1;
366 if let Some(ix) = binary_search(
367 conflicted_start,
368 conflicted_start + self.conflicted_count,
369 |ix| {
370 self.entries[ix]
371 .status_entry()
372 .unwrap()
373 .repo_path
374 .cmp(&path)
375 },
376 ) {
377 return Some(ix);
378 }
379 }
380 if self.tracked_count > 0 {
381 let tracked_start = if self.conflicted_count > 0 {
382 1 + self.conflicted_count
383 } else {
384 0
385 } + 1;
386 if let Some(ix) =
387 binary_search(tracked_start, tracked_start + self.tracked_count, |ix| {
388 self.entries[ix]
389 .status_entry()
390 .unwrap()
391 .repo_path
392 .cmp(&path)
393 })
394 {
395 return Some(ix);
396 }
397 }
398 if self.new_count > 0 {
399 let untracked_start = if self.conflicted_count > 0 {
400 1 + self.conflicted_count
401 } else {
402 0
403 } + if self.tracked_count > 0 {
404 1 + self.tracked_count
405 } else {
406 0
407 } + 1;
408 if let Some(ix) =
409 binary_search(untracked_start, untracked_start + self.new_count, |ix| {
410 self.entries[ix]
411 .status_entry()
412 .unwrap()
413 .repo_path
414 .cmp(&path)
415 })
416 {
417 return Some(ix);
418 }
419 }
420 None
421 }
422
423 pub fn select_entry_by_path(
424 &mut self,
425 path: ProjectPath,
426 _: &mut Window,
427 cx: &mut Context<Self>,
428 ) {
429 let Some(git_repo) = self.active_repository.as_ref() else {
430 return;
431 };
432 let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else {
433 return;
434 };
435 let Some(ix) = self.entry_by_path(&repo_path) else {
436 return;
437 };
438 self.selected_entry = Some(ix);
439 cx.notify();
440 }
441
442 fn start_remote_operation(&mut self) -> RemoteOperationGuard {
443 let id = post_inc(&mut self.remote_operation_id);
444 self.pending_remote_operations.borrow_mut().insert(id);
445
446 RemoteOperationGuard {
447 id,
448 pending_remote_operations: self.pending_remote_operations.clone(),
449 }
450 }
451
452 fn serialize(&mut self, cx: &mut Context<Self>) {
453 let width = self.width;
454 self.pending_serialization = cx.background_spawn(
455 async move {
456 KEY_VALUE_STORE
457 .write_kvp(
458 GIT_PANEL_KEY.into(),
459 serde_json::to_string(&SerializedGitPanel { width })?,
460 )
461 .await?;
462 anyhow::Ok(())
463 }
464 .log_err(),
465 );
466 }
467
468 pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
469 self.modal_open = open;
470 cx.notify();
471 }
472
473 fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
474 let mut dispatch_context = KeyContext::new_with_defaults();
475 dispatch_context.add("GitPanel");
476
477 if self.is_focused(window, cx) {
478 dispatch_context.add("menu");
479 dispatch_context.add("ChangesList");
480 }
481
482 if self.commit_editor.read(cx).is_focused(window) {
483 dispatch_context.add("CommitEditor");
484 }
485
486 dispatch_context
487 }
488
489 fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
490 window
491 .focused(cx)
492 .map_or(false, |focused| self.focus_handle == focused)
493 }
494
495 fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
496 cx.emit(PanelEvent::Close);
497 }
498
499 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
500 if !self.focus_handle.contains_focused(window, cx) {
501 cx.emit(Event::Focus);
502 }
503 }
504
505 fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
506 GitPanelSettings::get_global(cx)
507 .scrollbar
508 .show
509 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
510 }
511
512 fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
513 let show = self.show_scrollbar(cx);
514 match show {
515 ShowScrollbar::Auto => true,
516 ShowScrollbar::System => true,
517 ShowScrollbar::Always => true,
518 ShowScrollbar::Never => false,
519 }
520 }
521
522 fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
523 let show = self.show_scrollbar(cx);
524 match show {
525 ShowScrollbar::Auto => true,
526 ShowScrollbar::System => cx
527 .try_global::<ScrollbarAutoHide>()
528 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
529 ShowScrollbar::Always => false,
530 ShowScrollbar::Never => true,
531 }
532 }
533
534 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
535 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
536 if !self.should_autohide_scrollbar(cx) {
537 return;
538 }
539 self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
540 cx.background_executor()
541 .timer(SCROLLBAR_SHOW_INTERVAL)
542 .await;
543 panel
544 .update(&mut cx, |panel, cx| {
545 panel.show_scrollbar = false;
546 cx.notify();
547 })
548 .log_err();
549 }))
550 }
551
552 fn handle_modifiers_changed(
553 &mut self,
554 event: &ModifiersChangedEvent,
555 _: &mut Window,
556 cx: &mut Context<Self>,
557 ) {
558 self.current_modifiers = event.modifiers;
559 cx.notify();
560 }
561
562 fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
563 if let Some(selected_entry) = self.selected_entry {
564 self.scroll_handle
565 .scroll_to_item(selected_entry, ScrollStrategy::Center);
566 }
567
568 cx.notify();
569 }
570
571 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
572 if !self.entries.is_empty() {
573 self.selected_entry = Some(1);
574 self.scroll_to_selected_entry(cx);
575 }
576 }
577
578 fn select_previous(
579 &mut self,
580 _: &SelectPrevious,
581 _window: &mut Window,
582 cx: &mut Context<Self>,
583 ) {
584 let item_count = self.entries.len();
585 if item_count == 0 {
586 return;
587 }
588
589 if let Some(selected_entry) = self.selected_entry {
590 let new_selected_entry = if selected_entry > 0 {
591 selected_entry - 1
592 } else {
593 selected_entry
594 };
595
596 if matches!(
597 self.entries.get(new_selected_entry),
598 Some(GitListEntry::Header(..))
599 ) {
600 if new_selected_entry > 0 {
601 self.selected_entry = Some(new_selected_entry - 1)
602 }
603 } else {
604 self.selected_entry = Some(new_selected_entry);
605 }
606
607 self.scroll_to_selected_entry(cx);
608 }
609
610 cx.notify();
611 }
612
613 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
614 let item_count = self.entries.len();
615 if item_count == 0 {
616 return;
617 }
618
619 if let Some(selected_entry) = self.selected_entry {
620 let new_selected_entry = if selected_entry < item_count - 1 {
621 selected_entry + 1
622 } else {
623 selected_entry
624 };
625 if matches!(
626 self.entries.get(new_selected_entry),
627 Some(GitListEntry::Header(..))
628 ) {
629 self.selected_entry = Some(new_selected_entry + 1);
630 } else {
631 self.selected_entry = Some(new_selected_entry);
632 }
633
634 self.scroll_to_selected_entry(cx);
635 }
636
637 cx.notify();
638 }
639
640 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
641 if self.entries.last().is_some() {
642 self.selected_entry = Some(self.entries.len() - 1);
643 self.scroll_to_selected_entry(cx);
644 }
645 }
646
647 pub(crate) fn editor_focus_handle(&self, cx: &mut Context<Self>) -> FocusHandle {
648 self.commit_editor.focus_handle(cx).clone()
649 }
650
651 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
652 self.commit_editor.update(cx, |editor, cx| {
653 window.focus(&editor.focus_handle(cx));
654 });
655 cx.notify();
656 }
657
658 fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
659 let have_entries = self
660 .active_repository
661 .as_ref()
662 .map_or(false, |active_repository| {
663 active_repository.read(cx).entry_count() > 0
664 });
665 if have_entries && self.selected_entry.is_none() {
666 self.selected_entry = Some(1);
667 self.scroll_to_selected_entry(cx);
668 cx.notify();
669 }
670 }
671
672 fn focus_changes_list(
673 &mut self,
674 _: &FocusChanges,
675 window: &mut Window,
676 cx: &mut Context<Self>,
677 ) {
678 self.select_first_entry_if_none(cx);
679
680 cx.focus_self(window);
681 cx.notify();
682 }
683
684 fn get_selected_entry(&self) -> Option<&GitListEntry> {
685 self.selected_entry.and_then(|i| self.entries.get(i))
686 }
687
688 fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
689 maybe!({
690 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
691 let workspace = self.workspace.upgrade()?;
692 let git_repo = self.active_repository.as_ref()?;
693
694 if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx) {
695 if let Some(project_path) = project_diff.read(cx).active_path(cx) {
696 if Some(&entry.repo_path)
697 == git_repo
698 .read(cx)
699 .project_path_to_repo_path(&project_path)
700 .as_ref()
701 {
702 project_diff.focus_handle(cx).focus(window);
703 return None;
704 }
705 }
706 };
707
708 self.workspace
709 .update(cx, |workspace, cx| {
710 ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
711 })
712 .ok();
713 self.focus_handle.focus(window);
714
715 Some(())
716 });
717 }
718
719 fn open_file(
720 &mut self,
721 _: &menu::SecondaryConfirm,
722 window: &mut Window,
723 cx: &mut Context<Self>,
724 ) {
725 maybe!({
726 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
727 let active_repo = self.active_repository.as_ref()?;
728 let path = active_repo
729 .read(cx)
730 .repo_path_to_project_path(&entry.repo_path)?;
731 if entry.status.is_deleted() {
732 return None;
733 }
734
735 self.workspace
736 .update(cx, |workspace, cx| {
737 workspace
738 .open_path_preview(path, None, false, false, true, window, cx)
739 .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
740 Some(format!("{e}"))
741 });
742 })
743 .ok()
744 });
745 }
746
747 fn revert_selected(
748 &mut self,
749 _: &git::RestoreFile,
750 window: &mut Window,
751 cx: &mut Context<Self>,
752 ) {
753 maybe!({
754 let list_entry = self.entries.get(self.selected_entry?)?.clone();
755 let entry = list_entry.status_entry()?;
756 self.revert_entry(&entry, window, cx);
757 Some(())
758 });
759 }
760
761 fn revert_entry(
762 &mut self,
763 entry: &GitStatusEntry,
764 window: &mut Window,
765 cx: &mut Context<Self>,
766 ) {
767 maybe!({
768 let active_repo = self.active_repository.clone()?;
769 let path = active_repo
770 .read(cx)
771 .repo_path_to_project_path(&entry.repo_path)?;
772 let workspace = self.workspace.clone();
773
774 if entry.status.is_staged() != Some(false) {
775 self.perform_stage(false, vec![entry.repo_path.clone()], cx);
776 }
777 let filename = path.path.file_name()?.to_string_lossy();
778
779 if !entry.status.is_created() {
780 self.perform_checkout(vec![entry.repo_path.clone()], cx);
781 } else {
782 let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
783 cx.spawn_in(window, |_, mut cx| async move {
784 match prompt.await? {
785 TrashCancel::Trash => {}
786 TrashCancel::Cancel => return Ok(()),
787 }
788 let task = workspace.update(&mut cx, |workspace, cx| {
789 workspace
790 .project()
791 .update(cx, |project, cx| project.delete_file(path, true, cx))
792 })?;
793 if let Some(task) = task {
794 task.await?;
795 }
796 Ok(())
797 })
798 .detach_and_prompt_err(
799 "Failed to trash file",
800 window,
801 cx,
802 |e, _, _| Some(format!("{e}")),
803 );
804 }
805 Some(())
806 });
807 }
808
809 fn perform_checkout(&mut self, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
810 let workspace = self.workspace.clone();
811 let Some(active_repository) = self.active_repository.clone() else {
812 return;
813 };
814
815 let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
816 self.pending.push(PendingOperation {
817 op_id,
818 target_status: TargetStatus::Reverted,
819 repo_paths: repo_paths.iter().cloned().collect(),
820 finished: false,
821 });
822 self.update_visible_entries(cx);
823 let task = cx.spawn(|_, mut cx| async move {
824 let tasks: Vec<_> = workspace.update(&mut cx, |workspace, cx| {
825 workspace.project().update(cx, |project, cx| {
826 repo_paths
827 .iter()
828 .filter_map(|repo_path| {
829 let path = active_repository
830 .read(cx)
831 .repo_path_to_project_path(&repo_path)?;
832 Some(project.open_buffer(path, cx))
833 })
834 .collect()
835 })
836 })?;
837
838 let buffers = futures::future::join_all(tasks).await;
839
840 active_repository
841 .update(&mut cx, |repo, _| repo.checkout_files("HEAD", repo_paths))?
842 .await??;
843
844 let tasks: Vec<_> = cx.update(|cx| {
845 buffers
846 .iter()
847 .filter_map(|buffer| {
848 buffer.as_ref().ok()?.update(cx, |buffer, cx| {
849 buffer.is_dirty().then(|| buffer.reload(cx))
850 })
851 })
852 .collect()
853 })?;
854
855 futures::future::join_all(tasks).await;
856
857 Ok(())
858 });
859
860 cx.spawn(|this, mut cx| async move {
861 let result = task.await;
862
863 this.update(&mut cx, |this, cx| {
864 for pending in this.pending.iter_mut() {
865 if pending.op_id == op_id {
866 pending.finished = true;
867 if result.is_err() {
868 pending.target_status = TargetStatus::Unchanged;
869 this.update_visible_entries(cx);
870 }
871 break;
872 }
873 }
874 result
875 .map_err(|e| {
876 this.show_err_toast(e, cx);
877 })
878 .ok();
879 })
880 .ok();
881 })
882 .detach();
883 }
884
885 fn restore_tracked_files(
886 &mut self,
887 _: &RestoreTrackedFiles,
888 window: &mut Window,
889 cx: &mut Context<Self>,
890 ) {
891 let entries = self
892 .entries
893 .iter()
894 .filter_map(|entry| entry.status_entry().cloned())
895 .filter(|status_entry| !status_entry.status.is_created())
896 .collect::<Vec<_>>();
897
898 match entries.len() {
899 0 => return,
900 1 => return self.revert_entry(&entries[0], window, cx),
901 _ => {}
902 }
903 let mut details = entries
904 .iter()
905 .filter_map(|entry| entry.repo_path.0.file_name())
906 .map(|filename| filename.to_string_lossy())
907 .take(5)
908 .join("\n");
909 if entries.len() > 5 {
910 details.push_str(&format!("\nand {} more…", entries.len() - 5))
911 }
912
913 #[derive(strum::EnumIter, strum::VariantNames)]
914 #[strum(serialize_all = "title_case")]
915 enum RestoreCancel {
916 RestoreTrackedFiles,
917 Cancel,
918 }
919 let prompt = prompt(
920 "Discard changes to these files?",
921 Some(&details),
922 window,
923 cx,
924 );
925 cx.spawn(|this, mut cx| async move {
926 match prompt.await {
927 Ok(RestoreCancel::RestoreTrackedFiles) => {
928 this.update(&mut cx, |this, cx| {
929 let repo_paths = entries.into_iter().map(|entry| entry.repo_path).collect();
930 this.perform_checkout(repo_paths, cx);
931 })
932 .ok();
933 }
934 _ => {
935 return;
936 }
937 }
938 })
939 .detach();
940 }
941
942 fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
943 let workspace = self.workspace.clone();
944 let Some(active_repo) = self.active_repository.clone() else {
945 return;
946 };
947 let to_delete = self
948 .entries
949 .iter()
950 .filter_map(|entry| entry.status_entry())
951 .filter(|status_entry| status_entry.status.is_created())
952 .cloned()
953 .collect::<Vec<_>>();
954
955 match to_delete.len() {
956 0 => return,
957 1 => return self.revert_entry(&to_delete[0], window, cx),
958 _ => {}
959 };
960
961 let mut details = to_delete
962 .iter()
963 .map(|entry| {
964 entry
965 .repo_path
966 .0
967 .file_name()
968 .map(|f| f.to_string_lossy())
969 .unwrap_or_default()
970 })
971 .take(5)
972 .join("\n");
973
974 if to_delete.len() > 5 {
975 details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
976 }
977
978 let prompt = prompt("Trash these files?", Some(&details), window, cx);
979 cx.spawn_in(window, |this, mut cx| async move {
980 match prompt.await? {
981 TrashCancel::Trash => {}
982 TrashCancel::Cancel => return Ok(()),
983 }
984 let tasks = workspace.update(&mut cx, |workspace, cx| {
985 to_delete
986 .iter()
987 .filter_map(|entry| {
988 workspace.project().update(cx, |project, cx| {
989 let project_path = active_repo
990 .read(cx)
991 .repo_path_to_project_path(&entry.repo_path)?;
992 project.delete_file(project_path, true, cx)
993 })
994 })
995 .collect::<Vec<_>>()
996 })?;
997 let to_unstage = to_delete
998 .into_iter()
999 .filter_map(|entry| {
1000 if entry.status.is_staged() != Some(false) {
1001 Some(entry.repo_path.clone())
1002 } else {
1003 None
1004 }
1005 })
1006 .collect();
1007 this.update(&mut cx, |this, cx| {
1008 this.perform_stage(false, to_unstage, cx)
1009 })?;
1010 for task in tasks {
1011 task.await?;
1012 }
1013 Ok(())
1014 })
1015 .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
1016 Some(format!("{e}"))
1017 });
1018 }
1019
1020 fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
1021 let repo_paths = self
1022 .entries
1023 .iter()
1024 .filter_map(|entry| entry.status_entry())
1025 .filter(|status_entry| status_entry.is_staged != Some(true))
1026 .map(|status_entry| status_entry.repo_path.clone())
1027 .collect::<Vec<_>>();
1028 self.perform_stage(true, repo_paths, cx);
1029 }
1030
1031 fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
1032 let repo_paths = self
1033 .entries
1034 .iter()
1035 .filter_map(|entry| entry.status_entry())
1036 .filter(|status_entry| status_entry.is_staged != Some(false))
1037 .map(|status_entry| status_entry.repo_path.clone())
1038 .collect::<Vec<_>>();
1039 self.perform_stage(false, repo_paths, cx);
1040 }
1041
1042 fn toggle_staged_for_entry(
1043 &mut self,
1044 entry: &GitListEntry,
1045 _window: &mut Window,
1046 cx: &mut Context<Self>,
1047 ) {
1048 let Some(active_repository) = self.active_repository.as_ref() else {
1049 return;
1050 };
1051 let (stage, repo_paths) = match entry {
1052 GitListEntry::GitStatusEntry(status_entry) => {
1053 if status_entry.status.is_staged().unwrap_or(false) {
1054 (false, vec![status_entry.repo_path.clone()])
1055 } else {
1056 (true, vec![status_entry.repo_path.clone()])
1057 }
1058 }
1059 GitListEntry::Header(section) => {
1060 let goal_staged_state = !self.header_state(section.header).selected();
1061 let repository = active_repository.read(cx);
1062 let entries = self
1063 .entries
1064 .iter()
1065 .filter_map(|entry| entry.status_entry())
1066 .filter(|status_entry| {
1067 section.contains(&status_entry, repository)
1068 && status_entry.is_staged != Some(goal_staged_state)
1069 })
1070 .map(|status_entry| status_entry.repo_path.clone())
1071 .collect::<Vec<_>>();
1072
1073 (goal_staged_state, entries)
1074 }
1075 };
1076 self.perform_stage(stage, repo_paths, cx);
1077 }
1078
1079 fn perform_stage(&mut self, stage: bool, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
1080 let Some(active_repository) = self.active_repository.clone() else {
1081 return;
1082 };
1083 let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1084 self.pending.push(PendingOperation {
1085 op_id,
1086 target_status: if stage {
1087 TargetStatus::Staged
1088 } else {
1089 TargetStatus::Unstaged
1090 },
1091 repo_paths: repo_paths.iter().cloned().collect(),
1092 finished: false,
1093 });
1094 let repo_paths = repo_paths.clone();
1095 let repository = active_repository.read(cx);
1096 self.update_counts(repository);
1097 cx.notify();
1098
1099 cx.spawn({
1100 |this, mut cx| async move {
1101 let result = cx
1102 .update(|cx| {
1103 if stage {
1104 active_repository
1105 .update(cx, |repo, cx| repo.stage_entries(repo_paths.clone(), cx))
1106 } else {
1107 active_repository
1108 .update(cx, |repo, cx| repo.unstage_entries(repo_paths.clone(), cx))
1109 }
1110 })?
1111 .await;
1112
1113 this.update(&mut cx, |this, cx| {
1114 for pending in this.pending.iter_mut() {
1115 if pending.op_id == op_id {
1116 pending.finished = true
1117 }
1118 }
1119 result
1120 .map_err(|e| {
1121 this.show_err_toast(e, cx);
1122 })
1123 .ok();
1124 cx.notify();
1125 })
1126 }
1127 })
1128 .detach();
1129 }
1130
1131 pub fn total_staged_count(&self) -> usize {
1132 self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1133 }
1134
1135 pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1136 self.commit_editor
1137 .read(cx)
1138 .buffer()
1139 .read(cx)
1140 .as_singleton()
1141 .unwrap()
1142 .clone()
1143 }
1144
1145 fn toggle_staged_for_selected(
1146 &mut self,
1147 _: &git::ToggleStaged,
1148 window: &mut Window,
1149 cx: &mut Context<Self>,
1150 ) {
1151 if let Some(selected_entry) = self.get_selected_entry().cloned() {
1152 self.toggle_staged_for_entry(&selected_entry, window, cx);
1153 }
1154 }
1155
1156 fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1157 if self
1158 .commit_editor
1159 .focus_handle(cx)
1160 .contains_focused(window, cx)
1161 {
1162 self.commit_changes(window, cx)
1163 } else {
1164 cx.propagate();
1165 }
1166 }
1167
1168 fn custom_or_suggested_commit_message(&self, cx: &mut Context<Self>) -> Option<String> {
1169 let message = self.commit_editor.read(cx).text(cx);
1170
1171 if !message.trim().is_empty() {
1172 return Some(message.to_string());
1173 }
1174
1175 self.suggest_commit_message()
1176 .filter(|message| !message.trim().is_empty())
1177 }
1178
1179 pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1180 let Some(active_repository) = self.active_repository.clone() else {
1181 return;
1182 };
1183 let error_spawn = |message, window: &mut Window, cx: &mut App| {
1184 let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1185 cx.spawn(|_| async move {
1186 prompt.await.ok();
1187 })
1188 .detach();
1189 };
1190
1191 if self.has_unstaged_conflicts() {
1192 error_spawn(
1193 "There are still conflicts. You must stage these before committing",
1194 window,
1195 cx,
1196 );
1197 return;
1198 }
1199
1200 let commit_message = self.custom_or_suggested_commit_message(cx);
1201
1202 let Some(mut message) = commit_message else {
1203 self.commit_editor.read(cx).focus_handle(cx).focus(window);
1204 return;
1205 };
1206
1207 if self.add_coauthors {
1208 self.fill_co_authors(&mut message, cx);
1209 }
1210
1211 let task = if self.has_staged_changes() {
1212 // Repository serializes all git operations, so we can just send a commit immediately
1213 let commit_task = active_repository.read(cx).commit(message.into(), None);
1214 cx.background_spawn(async move { commit_task.await? })
1215 } else {
1216 let changed_files = self
1217 .entries
1218 .iter()
1219 .filter_map(|entry| entry.status_entry())
1220 .filter(|status_entry| !status_entry.status.is_created())
1221 .map(|status_entry| status_entry.repo_path.clone())
1222 .collect::<Vec<_>>();
1223
1224 if changed_files.is_empty() {
1225 error_spawn("No changes to commit", window, cx);
1226 return;
1227 }
1228
1229 let stage_task =
1230 active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1231 cx.spawn(|_, mut cx| async move {
1232 stage_task.await?;
1233 let commit_task = active_repository
1234 .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
1235 commit_task.await?
1236 })
1237 };
1238 let task = cx.spawn_in(window, |this, mut cx| async move {
1239 let result = task.await;
1240 this.update_in(&mut cx, |this, window, cx| {
1241 this.pending_commit.take();
1242 match result {
1243 Ok(()) => {
1244 this.commit_editor
1245 .update(cx, |editor, cx| editor.clear(window, cx));
1246 }
1247 Err(e) => this.show_err_toast(e, cx),
1248 }
1249 })
1250 .ok();
1251 });
1252
1253 self.pending_commit = Some(task);
1254 }
1255
1256 fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1257 let Some(repo) = self.active_repository.clone() else {
1258 return;
1259 };
1260
1261 // TODO: Use git merge-base to find the upstream and main branch split
1262 let confirmation = Task::ready(true);
1263 // let confirmation = if self.commit_editor.read(cx).is_empty(cx) {
1264 // Task::ready(true)
1265 // } else {
1266 // let prompt = window.prompt(
1267 // PromptLevel::Warning,
1268 // "Uncomitting will replace the current commit message with the previous commit's message",
1269 // None,
1270 // &["Ok", "Cancel"],
1271 // cx,
1272 // );
1273 // cx.spawn(|_, _| async move { prompt.await.is_ok_and(|i| i == 0) })
1274 // };
1275
1276 let prior_head = self.load_commit_details("HEAD", cx);
1277
1278 let task = cx.spawn_in(window, |this, mut cx| async move {
1279 let result = maybe!(async {
1280 if !confirmation.await {
1281 Ok(None)
1282 } else {
1283 let prior_head = prior_head.await?;
1284
1285 repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
1286 .await??;
1287
1288 Ok(Some(prior_head))
1289 }
1290 })
1291 .await;
1292
1293 this.update_in(&mut cx, |this, window, cx| {
1294 this.pending_commit.take();
1295 match result {
1296 Ok(None) => {}
1297 Ok(Some(prior_commit)) => {
1298 this.commit_editor.update(cx, |editor, cx| {
1299 editor.set_text(prior_commit.message, window, cx)
1300 });
1301 }
1302 Err(e) => this.show_err_toast(e, cx),
1303 }
1304 })
1305 .ok();
1306 });
1307
1308 self.pending_commit = Some(task);
1309 }
1310
1311 /// Suggests a commit message based on the changed files and their statuses
1312 pub fn suggest_commit_message(&self) -> Option<String> {
1313 if self.total_staged_count() != 1 {
1314 return None;
1315 }
1316
1317 let entry = self
1318 .entries
1319 .iter()
1320 .find(|entry| match entry.status_entry() {
1321 Some(entry) => entry.is_staged.unwrap_or(false),
1322 _ => false,
1323 })?;
1324
1325 let GitListEntry::GitStatusEntry(git_status_entry) = entry.clone() else {
1326 return None;
1327 };
1328
1329 let action_text = if git_status_entry.status.is_deleted() {
1330 Some("Delete")
1331 } else if git_status_entry.status.is_created() {
1332 Some("Create")
1333 } else if git_status_entry.status.is_modified() {
1334 Some("Update")
1335 } else {
1336 None
1337 }?;
1338
1339 let file_name = git_status_entry
1340 .repo_path
1341 .file_name()
1342 .unwrap_or_default()
1343 .to_string_lossy();
1344
1345 Some(format!("{} {}", action_text, file_name))
1346 }
1347
1348 fn update_editor_placeholder(&mut self, cx: &mut Context<Self>) {
1349 let suggested_commit_message = self.suggest_commit_message();
1350 let placeholder_text = suggested_commit_message
1351 .as_deref()
1352 .unwrap_or("Enter commit message");
1353
1354 self.commit_editor.update(cx, |editor, cx| {
1355 editor.set_placeholder_text(Arc::from(placeholder_text), cx)
1356 });
1357
1358 cx.notify();
1359 }
1360
1361 fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
1362 let Some(repo) = self.active_repository.clone() else {
1363 return;
1364 };
1365 let guard = self.start_remote_operation();
1366 let fetch = repo.read(cx).fetch();
1367 cx.spawn(|this, mut cx| async move {
1368 let remote_message = fetch.await?;
1369 drop(guard);
1370 this.update(&mut cx, |this, cx| {
1371 match remote_message {
1372 Ok(remote_message) => {
1373 this.show_remote_output(RemoteAction::Fetch, remote_message, cx);
1374 }
1375 Err(e) => {
1376 this.show_err_toast(e, cx);
1377 }
1378 }
1379
1380 anyhow::Ok(())
1381 })
1382 .ok();
1383 anyhow::Ok(())
1384 })
1385 .detach_and_log_err(cx);
1386 }
1387
1388 fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
1389 let Some(repo) = self.active_repository.clone() else {
1390 return;
1391 };
1392 let Some(branch) = repo.read(cx).current_branch() else {
1393 return;
1394 };
1395 let branch = branch.clone();
1396 let guard = self.start_remote_operation();
1397 let remote = self.get_current_remote(window, cx);
1398 cx.spawn(move |this, mut cx| async move {
1399 let remote = match remote.await {
1400 Ok(Some(remote)) => remote,
1401 Ok(None) => {
1402 return Ok(());
1403 }
1404 Err(e) => {
1405 log::error!("Failed to get current remote: {}", e);
1406 this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
1407 .ok();
1408 return Ok(());
1409 }
1410 };
1411
1412 let pull = repo.update(&mut cx, |repo, _cx| {
1413 repo.pull(branch.name.clone(), remote.name.clone())
1414 })?;
1415
1416 let remote_message = pull.await?;
1417 drop(guard);
1418
1419 this.update(&mut cx, |this, cx| match remote_message {
1420 Ok(remote_message) => {
1421 this.show_remote_output(RemoteAction::Pull, remote_message, cx)
1422 }
1423 Err(err) => this.show_err_toast(err, cx),
1424 })
1425 .ok();
1426
1427 anyhow::Ok(())
1428 })
1429 .detach_and_log_err(cx);
1430 }
1431
1432 fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
1433 let Some(repo) = self.active_repository.clone() else {
1434 return;
1435 };
1436 let Some(branch) = repo.read(cx).current_branch() else {
1437 return;
1438 };
1439 let branch = branch.clone();
1440 let guard = self.start_remote_operation();
1441 let options = action.options;
1442 let remote = self.get_current_remote(window, cx);
1443
1444 cx.spawn(move |this, mut cx| async move {
1445 let remote = match remote.await {
1446 Ok(Some(remote)) => remote,
1447 Ok(None) => {
1448 return Ok(());
1449 }
1450 Err(e) => {
1451 log::error!("Failed to get current remote: {}", e);
1452 this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
1453 .ok();
1454 return Ok(());
1455 }
1456 };
1457
1458 let push = repo.update(&mut cx, |repo, _cx| {
1459 repo.push(branch.name.clone(), remote.name.clone(), options)
1460 })?;
1461
1462 let remote_output = push.await?;
1463
1464 drop(guard);
1465
1466 this.update(&mut cx, |this, cx| match remote_output {
1467 Ok(remote_message) => {
1468 this.show_remote_output(RemoteAction::Push(remote), remote_message, cx);
1469 }
1470 Err(e) => {
1471 this.show_err_toast(e, cx);
1472 }
1473 })?;
1474
1475 anyhow::Ok(())
1476 })
1477 .detach_and_log_err(cx);
1478 }
1479
1480 fn get_current_remote(
1481 &mut self,
1482 window: &mut Window,
1483 cx: &mut Context<Self>,
1484 ) -> impl Future<Output = Result<Option<Remote>>> {
1485 let repo = self.active_repository.clone();
1486 let workspace = self.workspace.clone();
1487 let mut cx = window.to_async(cx);
1488
1489 async move {
1490 let Some(repo) = repo else {
1491 return Err(anyhow::anyhow!("No active repository"));
1492 };
1493
1494 let mut current_remotes: Vec<Remote> = repo
1495 .update(&mut cx, |repo, _| {
1496 let Some(current_branch) = repo.current_branch() else {
1497 return Err(anyhow::anyhow!("No active branch"));
1498 };
1499
1500 Ok(repo.get_remotes(Some(current_branch.name.to_string())))
1501 })??
1502 .await??;
1503
1504 if current_remotes.len() == 0 {
1505 return Err(anyhow::anyhow!("No active remote"));
1506 } else if current_remotes.len() == 1 {
1507 return Ok(Some(current_remotes.pop().unwrap()));
1508 } else {
1509 let current_remotes: Vec<_> = current_remotes
1510 .into_iter()
1511 .map(|remotes| remotes.name)
1512 .collect();
1513 let selection = cx
1514 .update(|window, cx| {
1515 picker_prompt::prompt(
1516 "Pick which remote to push to",
1517 current_remotes.clone(),
1518 workspace,
1519 window,
1520 cx,
1521 )
1522 })?
1523 .await?;
1524
1525 Ok(selection.map(|selection| Remote {
1526 name: current_remotes[selection].clone(),
1527 }))
1528 }
1529 }
1530 }
1531
1532 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
1533 let mut new_co_authors = Vec::new();
1534 let project = self.project.read(cx);
1535
1536 let Some(room) = self
1537 .workspace
1538 .upgrade()
1539 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1540 else {
1541 return Vec::default();
1542 };
1543
1544 let room = room.read(cx);
1545
1546 for (peer_id, collaborator) in project.collaborators() {
1547 if collaborator.is_host {
1548 continue;
1549 }
1550
1551 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
1552 continue;
1553 };
1554 if participant.can_write() && participant.user.email.is_some() {
1555 let email = participant.user.email.clone().unwrap();
1556
1557 new_co_authors.push((
1558 participant
1559 .user
1560 .name
1561 .clone()
1562 .unwrap_or_else(|| participant.user.github_login.clone()),
1563 email,
1564 ))
1565 }
1566 }
1567 if !project.is_local() && !project.is_read_only(cx) {
1568 if let Some(user) = room.local_participant_user(cx) {
1569 if let Some(email) = user.email.clone() {
1570 new_co_authors.push((
1571 user.name
1572 .clone()
1573 .unwrap_or_else(|| user.github_login.clone()),
1574 email.clone(),
1575 ))
1576 }
1577 }
1578 }
1579 new_co_authors
1580 }
1581
1582 fn toggle_fill_co_authors(
1583 &mut self,
1584 _: &ToggleFillCoAuthors,
1585 _: &mut Window,
1586 cx: &mut Context<Self>,
1587 ) {
1588 self.add_coauthors = !self.add_coauthors;
1589 cx.notify();
1590 }
1591
1592 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
1593 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
1594
1595 let existing_text = message.to_ascii_lowercase();
1596 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
1597 let mut ends_with_co_authors = false;
1598 let existing_co_authors = existing_text
1599 .lines()
1600 .filter_map(|line| {
1601 let line = line.trim();
1602 if line.starts_with(&lowercase_co_author_prefix) {
1603 ends_with_co_authors = true;
1604 Some(line)
1605 } else {
1606 ends_with_co_authors = false;
1607 None
1608 }
1609 })
1610 .collect::<HashSet<_>>();
1611
1612 let new_co_authors = self
1613 .potential_co_authors(cx)
1614 .into_iter()
1615 .filter(|(_, email)| {
1616 !existing_co_authors
1617 .iter()
1618 .any(|existing| existing.contains(email.as_str()))
1619 })
1620 .collect::<Vec<_>>();
1621
1622 if new_co_authors.is_empty() {
1623 return;
1624 }
1625
1626 if !ends_with_co_authors {
1627 message.push('\n');
1628 }
1629 for (name, email) in new_co_authors {
1630 message.push('\n');
1631 message.push_str(CO_AUTHOR_PREFIX);
1632 message.push_str(&name);
1633 message.push_str(" <");
1634 message.push_str(&email);
1635 message.push('>');
1636 }
1637 message.push('\n');
1638 }
1639
1640 fn schedule_update(
1641 &mut self,
1642 clear_pending: bool,
1643 window: &mut Window,
1644 cx: &mut Context<Self>,
1645 ) {
1646 let handle = cx.entity().downgrade();
1647 self.reopen_commit_buffer(window, cx);
1648 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1649 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1650 if let Some(git_panel) = handle.upgrade() {
1651 git_panel
1652 .update_in(&mut cx, |git_panel, _, cx| {
1653 if clear_pending {
1654 git_panel.clear_pending();
1655 }
1656 git_panel.update_visible_entries(cx);
1657 git_panel.update_editor_placeholder(cx);
1658 })
1659 .ok();
1660 }
1661 });
1662 }
1663
1664 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1665 let Some(active_repo) = self.active_repository.as_ref() else {
1666 return;
1667 };
1668 let load_buffer = active_repo.update(cx, |active_repo, cx| {
1669 let project = self.project.read(cx);
1670 active_repo.open_commit_buffer(
1671 Some(project.languages().clone()),
1672 project.buffer_store().clone(),
1673 cx,
1674 )
1675 });
1676
1677 cx.spawn_in(window, |git_panel, mut cx| async move {
1678 let buffer = load_buffer.await?;
1679 git_panel.update_in(&mut cx, |git_panel, window, cx| {
1680 if git_panel
1681 .commit_editor
1682 .read(cx)
1683 .buffer()
1684 .read(cx)
1685 .as_singleton()
1686 .as_ref()
1687 != Some(&buffer)
1688 {
1689 git_panel.commit_editor = cx.new(|cx| {
1690 commit_message_editor(
1691 buffer,
1692 git_panel.suggest_commit_message().as_deref(),
1693 git_panel.project.clone(),
1694 true,
1695 window,
1696 cx,
1697 )
1698 });
1699 }
1700 })
1701 })
1702 .detach_and_log_err(cx);
1703 }
1704
1705 fn clear_pending(&mut self) {
1706 self.pending.retain(|v| !v.finished)
1707 }
1708
1709 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1710 self.entries.clear();
1711 let mut changed_entries = Vec::new();
1712 let mut new_entries = Vec::new();
1713 let mut conflict_entries = Vec::new();
1714
1715 let Some(repo) = self.active_repository.as_ref() else {
1716 // Just clear entries if no repository is active.
1717 cx.notify();
1718 return;
1719 };
1720
1721 // First pass - collect all paths
1722 let repo = repo.read(cx);
1723
1724 // Second pass - create entries with proper depth calculation
1725 for entry in repo.status() {
1726 let is_conflict = repo.has_conflict(&entry.repo_path);
1727 let is_new = entry.status.is_created();
1728 let is_staged = entry.status.is_staged();
1729
1730 if self.pending.iter().any(|pending| {
1731 pending.target_status == TargetStatus::Reverted
1732 && !pending.finished
1733 && pending.repo_paths.contains(&entry.repo_path)
1734 }) {
1735 continue;
1736 }
1737
1738 let entry = GitStatusEntry {
1739 repo_path: entry.repo_path.clone(),
1740 status: entry.status,
1741 is_staged,
1742 };
1743
1744 if is_conflict {
1745 conflict_entries.push(entry);
1746 } else if is_new {
1747 new_entries.push(entry);
1748 } else {
1749 changed_entries.push(entry);
1750 }
1751 }
1752
1753 if conflict_entries.len() > 0 {
1754 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1755 header: Section::Conflict,
1756 }));
1757 self.entries.extend(
1758 conflict_entries
1759 .into_iter()
1760 .map(GitListEntry::GitStatusEntry),
1761 );
1762 }
1763
1764 if changed_entries.len() > 0 {
1765 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1766 header: Section::Tracked,
1767 }));
1768 self.entries.extend(
1769 changed_entries
1770 .into_iter()
1771 .map(GitListEntry::GitStatusEntry),
1772 );
1773 }
1774 if new_entries.len() > 0 {
1775 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1776 header: Section::New,
1777 }));
1778 self.entries
1779 .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1780 }
1781
1782 self.update_counts(repo);
1783
1784 self.select_first_entry_if_none(cx);
1785
1786 cx.notify();
1787 }
1788
1789 fn header_state(&self, header_type: Section) -> ToggleState {
1790 let (staged_count, count) = match header_type {
1791 Section::New => (self.new_staged_count, self.new_count),
1792 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1793 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1794 };
1795 if staged_count == 0 {
1796 ToggleState::Unselected
1797 } else if count == staged_count {
1798 ToggleState::Selected
1799 } else {
1800 ToggleState::Indeterminate
1801 }
1802 }
1803
1804 fn update_counts(&mut self, repo: &Repository) {
1805 self.conflicted_count = 0;
1806 self.conflicted_staged_count = 0;
1807 self.new_count = 0;
1808 self.tracked_count = 0;
1809 self.new_staged_count = 0;
1810 self.tracked_staged_count = 0;
1811 for entry in &self.entries {
1812 let Some(status_entry) = entry.status_entry() else {
1813 continue;
1814 };
1815 if repo.has_conflict(&status_entry.repo_path) {
1816 self.conflicted_count += 1;
1817 if self.entry_is_staged(status_entry) != Some(false) {
1818 self.conflicted_staged_count += 1;
1819 }
1820 } else if status_entry.status.is_created() {
1821 self.new_count += 1;
1822 if self.entry_is_staged(status_entry) != Some(false) {
1823 self.new_staged_count += 1;
1824 }
1825 } else {
1826 self.tracked_count += 1;
1827 if self.entry_is_staged(status_entry) != Some(false) {
1828 self.tracked_staged_count += 1;
1829 }
1830 }
1831 }
1832 }
1833
1834 fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1835 for pending in self.pending.iter().rev() {
1836 if pending.repo_paths.contains(&entry.repo_path) {
1837 match pending.target_status {
1838 TargetStatus::Staged => return Some(true),
1839 TargetStatus::Unstaged => return Some(false),
1840 TargetStatus::Reverted => continue,
1841 TargetStatus::Unchanged => continue,
1842 }
1843 }
1844 }
1845 entry.is_staged
1846 }
1847
1848 pub(crate) fn has_staged_changes(&self) -> bool {
1849 self.tracked_staged_count > 0
1850 || self.new_staged_count > 0
1851 || self.conflicted_staged_count > 0
1852 }
1853
1854 pub(crate) fn has_unstaged_changes(&self) -> bool {
1855 self.tracked_count > self.tracked_staged_count
1856 || self.new_count > self.new_staged_count
1857 || self.conflicted_count > self.conflicted_staged_count
1858 }
1859
1860 fn has_conflicts(&self) -> bool {
1861 self.conflicted_count > 0
1862 }
1863
1864 fn has_tracked_changes(&self) -> bool {
1865 self.tracked_count > 0
1866 }
1867
1868 pub fn has_unstaged_conflicts(&self) -> bool {
1869 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1870 }
1871
1872 fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1873 let Some(workspace) = self.workspace.upgrade() else {
1874 return;
1875 };
1876 let notif_id = NotificationId::Named("git-operation-error".into());
1877
1878 let mut message = e.to_string().trim().to_string();
1879 let toast;
1880 if message.matches("Authentication failed").count() >= 1 {
1881 message = format!(
1882 "{}\n\n{}",
1883 message, "Please set your credentials via the CLI"
1884 );
1885 toast = Toast::new(notif_id, message);
1886 } else {
1887 toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1888 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1889 });
1890 }
1891 workspace.update(cx, |workspace, cx| {
1892 workspace.show_toast(toast, cx);
1893 });
1894 }
1895
1896 fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
1897 let Some(workspace) = self.workspace.upgrade() else {
1898 return;
1899 };
1900
1901 let notification_id = NotificationId::Named("git-remote-info".into());
1902
1903 workspace.update(cx, |workspace, cx| {
1904 workspace.show_notification(notification_id.clone(), cx, |cx| {
1905 let workspace = cx.weak_entity();
1906 cx.new(|cx| RemoteOutputToast::new(action, info, notification_id, workspace, cx))
1907 });
1908 });
1909 }
1910
1911 pub fn render_spinner(&self) -> Option<impl IntoElement> {
1912 (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1913 Icon::new(IconName::ArrowCircle)
1914 .size(IconSize::XSmall)
1915 .color(Color::Info)
1916 .with_animation(
1917 "arrow-circle",
1918 Animation::new(Duration::from_secs(2)).repeat(),
1919 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1920 )
1921 .into_any_element()
1922 })
1923 }
1924
1925 pub fn can_open_commit_editor(&self) -> bool {
1926 (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1927 }
1928
1929 pub fn can_stage_all(&self) -> bool {
1930 self.has_unstaged_changes()
1931 }
1932
1933 pub fn can_unstage_all(&self) -> bool {
1934 self.has_staged_changes()
1935 }
1936
1937 pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1938 let potential_co_authors = self.potential_co_authors(cx);
1939 if potential_co_authors.is_empty() {
1940 None
1941 } else {
1942 Some(
1943 IconButton::new("co-authors", IconName::Person)
1944 .icon_color(Color::Disabled)
1945 .selected_icon_color(Color::Selected)
1946 .toggle_state(self.add_coauthors)
1947 .tooltip(move |_, cx| {
1948 let title = format!(
1949 "Add co-authored-by:{}{}",
1950 if potential_co_authors.len() == 1 {
1951 ""
1952 } else {
1953 "\n"
1954 },
1955 potential_co_authors
1956 .iter()
1957 .map(|(name, email)| format!(" {} <{}>", name, email))
1958 .join("\n")
1959 );
1960 Tooltip::simple(title, cx)
1961 })
1962 .on_click(cx.listener(|this, _, _, cx| {
1963 this.add_coauthors = !this.add_coauthors;
1964 cx.notify();
1965 }))
1966 .into_any_element(),
1967 )
1968 }
1969 }
1970
1971 pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
1972 if self.has_unstaged_conflicts() {
1973 (false, "You must resolve conflicts before committing")
1974 } else if !self.has_staged_changes() && !self.has_tracked_changes() {
1975 (
1976 false,
1977 "You must have either staged changes or tracked files to commit",
1978 )
1979 } else if self.pending_commit.is_some() {
1980 (false, "Commit in progress")
1981 } else if self.custom_or_suggested_commit_message(cx).is_none() {
1982 (false, "No commit message")
1983 } else if !self.has_write_access(cx) {
1984 (false, "You do not have write access to this project")
1985 } else {
1986 (true, self.commit_button_title())
1987 }
1988 }
1989
1990 pub fn commit_button_title(&self) -> &'static str {
1991 if self.has_staged_changes() {
1992 "Commit"
1993 } else {
1994 "Commit Tracked"
1995 }
1996 }
1997
1998 pub fn render_footer(
1999 &self,
2000 window: &mut Window,
2001 cx: &mut Context<Self>,
2002 ) -> Option<impl IntoElement> {
2003 let active_repository = self.active_repository.clone()?;
2004 let can_open_commit_editor = self.can_open_commit_editor();
2005 let (can_commit, tooltip) = self.configure_commit_button(cx);
2006 let project = self.project.clone().read(cx);
2007 let panel_editor_style = panel_editor_style(true, window, cx);
2008
2009 let enable_coauthors = self.render_co_authors(cx);
2010
2011 let title = self.commit_button_title();
2012 let editor_focus_handle = self.commit_editor.focus_handle(cx);
2013
2014 let branch = active_repository.read(cx).current_branch().cloned();
2015
2016 let footer_size = px(32.);
2017 let gap = px(8.0);
2018
2019 let max_height = window.line_height() * 5. + gap + footer_size;
2020
2021 let expand_button_size = px(16.);
2022
2023 let git_panel = cx.entity().clone();
2024 let display_name = SharedString::from(Arc::from(
2025 active_repository
2026 .read(cx)
2027 .display_name(project, cx)
2028 .trim_end_matches("/"),
2029 ));
2030
2031 let footer = v_flex()
2032 .child(PanelRepoFooter::new(
2033 "footer-button",
2034 display_name,
2035 branch,
2036 Some(git_panel),
2037 ))
2038 .child(
2039 panel_editor_container(window, cx)
2040 .id("commit-editor-container")
2041 .relative()
2042 .h(max_height)
2043 // .w_full()
2044 // .border_t_1()
2045 // .border_color(cx.theme().colors().border)
2046 .bg(cx.theme().colors().editor_background)
2047 .cursor_text()
2048 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
2049 window.focus(&this.commit_editor.focus_handle(cx));
2050 }))
2051 .child(
2052 h_flex()
2053 .id("commit-footer")
2054 .absolute()
2055 .bottom_0()
2056 .right_2()
2057 .h(footer_size)
2058 .flex_none()
2059 .children(enable_coauthors)
2060 .child(
2061 panel_filled_button(title)
2062 .tooltip(move |window, cx| {
2063 if can_commit {
2064 Tooltip::for_action_in(
2065 tooltip,
2066 &Commit,
2067 &editor_focus_handle,
2068 window,
2069 cx,
2070 )
2071 } else {
2072 Tooltip::simple(tooltip, cx)
2073 }
2074 })
2075 .disabled(!can_commit || self.modal_open)
2076 .on_click({
2077 cx.listener(move |this, _: &ClickEvent, window, cx| {
2078 this.commit_changes(window, cx)
2079 })
2080 }),
2081 ),
2082 )
2083 // .when(!self.modal_open, |el| {
2084 .child(EditorElement::new(&self.commit_editor, panel_editor_style))
2085 .child(
2086 div()
2087 .absolute()
2088 .top_1()
2089 .right_2()
2090 .opacity(0.5)
2091 .hover(|this| this.opacity(1.0))
2092 .w(expand_button_size)
2093 .child(
2094 panel_icon_button("expand-commit-editor", IconName::Maximize)
2095 .icon_size(IconSize::Small)
2096 .style(ButtonStyle::Transparent)
2097 .width(expand_button_size.into())
2098 .disabled(!can_open_commit_editor)
2099 .on_click(cx.listener({
2100 move |_, _, window, cx| {
2101 window.dispatch_action(
2102 git::ShowCommitEditor.boxed_clone(),
2103 cx,
2104 )
2105 }
2106 })),
2107 ),
2108 ),
2109 );
2110
2111 Some(footer)
2112 }
2113
2114 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2115 let active_repository = self.active_repository.as_ref()?;
2116 let branch = active_repository.read(cx).current_branch()?;
2117 let commit = branch.most_recent_commit.as_ref()?.clone();
2118
2119 let this = cx.entity();
2120 Some(
2121 h_flex()
2122 .items_center()
2123 .py_2()
2124 .px(px(8.))
2125 // .bg(cx.theme().colors().background)
2126 // .border_t_1()
2127 .border_color(cx.theme().colors().border)
2128 .gap_1p5()
2129 .child(
2130 div()
2131 .flex_grow()
2132 .overflow_hidden()
2133 .max_w(relative(0.6))
2134 .h_full()
2135 .child(
2136 Label::new(commit.subject.clone())
2137 .size(LabelSize::Small)
2138 .truncate(),
2139 )
2140 .id("commit-msg-hover")
2141 .hoverable_tooltip(move |window, cx| {
2142 GitPanelMessageTooltip::new(
2143 this.clone(),
2144 commit.sha.clone(),
2145 window,
2146 cx,
2147 )
2148 .into()
2149 }),
2150 )
2151 .child(div().flex_1())
2152 .child(
2153 panel_icon_button("undo", IconName::Undo)
2154 .icon_size(IconSize::Small)
2155 .icon_color(Color::Muted)
2156 .tooltip(Tooltip::for_action_title(
2157 if self.has_staged_changes() {
2158 "git reset HEAD^ --soft"
2159 } else {
2160 "git reset HEAD^"
2161 },
2162 &git::Uncommit,
2163 ))
2164 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2165 ),
2166 )
2167 }
2168
2169 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2170 h_flex()
2171 .h_full()
2172 .flex_grow()
2173 .justify_center()
2174 .items_center()
2175 .child(
2176 v_flex()
2177 .gap_3()
2178 .child(if self.active_repository.is_some() {
2179 "No changes to commit"
2180 } else {
2181 "No Git repositories"
2182 })
2183 .text_ui_sm(cx)
2184 .mx_auto()
2185 .text_color(Color::Placeholder.color(cx)),
2186 )
2187 }
2188
2189 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2190 let scroll_bar_style = self.show_scrollbar(cx);
2191 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2192
2193 if !self.should_show_scrollbar(cx)
2194 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2195 {
2196 return None;
2197 }
2198
2199 Some(
2200 div()
2201 .id("git-panel-vertical-scroll")
2202 .occlude()
2203 .flex_none()
2204 .h_full()
2205 .cursor_default()
2206 .when(show_container, |this| this.pl_1().px_1p5())
2207 .when(!show_container, |this| {
2208 this.absolute().right_1().top_1().bottom_1().w(px(12.))
2209 })
2210 .on_mouse_move(cx.listener(|_, _, _, cx| {
2211 cx.notify();
2212 cx.stop_propagation()
2213 }))
2214 .on_hover(|_, _, cx| {
2215 cx.stop_propagation();
2216 })
2217 .on_any_mouse_down(|_, _, cx| {
2218 cx.stop_propagation();
2219 })
2220 .on_mouse_up(
2221 MouseButton::Left,
2222 cx.listener(|this, _, window, cx| {
2223 if !this.scrollbar_state.is_dragging()
2224 && !this.focus_handle.contains_focused(window, cx)
2225 {
2226 this.hide_scrollbar(window, cx);
2227 cx.notify();
2228 }
2229
2230 cx.stop_propagation();
2231 }),
2232 )
2233 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2234 cx.notify();
2235 }))
2236 .children(Scrollbar::vertical(
2237 // percentage as f32..end_offset as f32,
2238 self.scrollbar_state.clone(),
2239 )),
2240 )
2241 }
2242
2243 fn render_buffer_header_controls(
2244 &self,
2245 entity: &Entity<Self>,
2246 file: &Arc<dyn File>,
2247 _: &Window,
2248 cx: &App,
2249 ) -> Option<AnyElement> {
2250 let repo = self.active_repository.as_ref()?.read(cx);
2251 let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2252 let ix = self.entry_by_path(&repo_path)?;
2253 let entry = self.entries.get(ix)?;
2254
2255 let is_staged = self.entry_is_staged(entry.status_entry()?);
2256
2257 let checkbox = Checkbox::new("stage-file", is_staged.into())
2258 .disabled(!self.has_write_access(cx))
2259 .fill()
2260 .elevation(ElevationIndex::Surface)
2261 .on_click({
2262 let entry = entry.clone();
2263 let git_panel = entity.downgrade();
2264 move |_, window, cx| {
2265 git_panel
2266 .update(cx, |this, cx| {
2267 this.toggle_staged_for_entry(&entry, window, cx);
2268 cx.stop_propagation();
2269 })
2270 .ok();
2271 }
2272 });
2273 Some(
2274 h_flex()
2275 .id("start-slot")
2276 .text_lg()
2277 .child(checkbox)
2278 .on_mouse_down(MouseButton::Left, |_, _, cx| {
2279 // prevent the list item active state triggering when toggling checkbox
2280 cx.stop_propagation();
2281 })
2282 .into_any_element(),
2283 )
2284 }
2285
2286 fn render_entries(
2287 &self,
2288 has_write_access: bool,
2289 _: &Window,
2290 cx: &mut Context<Self>,
2291 ) -> impl IntoElement {
2292 let entry_count = self.entries.len();
2293
2294 h_flex()
2295 .size_full()
2296 .flex_grow()
2297 .overflow_hidden()
2298 .child(
2299 uniform_list(cx.entity().clone(), "entries", entry_count, {
2300 move |this, range, window, cx| {
2301 let mut items = Vec::with_capacity(range.end - range.start);
2302
2303 for ix in range {
2304 match &this.entries.get(ix) {
2305 Some(GitListEntry::GitStatusEntry(entry)) => {
2306 items.push(this.render_entry(
2307 ix,
2308 entry,
2309 has_write_access,
2310 window,
2311 cx,
2312 ));
2313 }
2314 Some(GitListEntry::Header(header)) => {
2315 items.push(this.render_list_header(
2316 ix,
2317 header,
2318 has_write_access,
2319 window,
2320 cx,
2321 ));
2322 }
2323 None => {}
2324 }
2325 }
2326
2327 items
2328 }
2329 })
2330 .size_full()
2331 .with_sizing_behavior(ListSizingBehavior::Auto)
2332 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2333 .track_scroll(self.scroll_handle.clone()),
2334 )
2335 .on_mouse_down(
2336 MouseButton::Right,
2337 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2338 this.deploy_panel_context_menu(event.position, window, cx)
2339 }),
2340 )
2341 .children(self.render_scrollbar(cx))
2342 }
2343
2344 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2345 Label::new(label.into()).color(color).single_line()
2346 }
2347
2348 fn list_item_height(&self) -> Rems {
2349 rems(1.75)
2350 }
2351
2352 fn render_list_header(
2353 &self,
2354 ix: usize,
2355 header: &GitHeaderEntry,
2356 _: bool,
2357 _: &Window,
2358 _: &Context<Self>,
2359 ) -> AnyElement {
2360 let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
2361
2362 h_flex()
2363 .id(id)
2364 .h(self.list_item_height())
2365 .w_full()
2366 .items_end()
2367 .px(rems(0.75)) // ~12px
2368 .pb(rems(0.3125)) // ~ 5px
2369 .child(
2370 Label::new(header.title())
2371 .color(Color::Muted)
2372 .size(LabelSize::Small)
2373 .line_height_style(LineHeightStyle::UiLabel)
2374 .single_line(),
2375 )
2376 .into_any_element()
2377 }
2378
2379 fn load_commit_details(
2380 &self,
2381 sha: &str,
2382 cx: &mut Context<Self>,
2383 ) -> Task<Result<CommitDetails>> {
2384 let Some(repo) = self.active_repository.clone() else {
2385 return Task::ready(Err(anyhow::anyhow!("no active repo")));
2386 };
2387 repo.update(cx, |repo, cx| {
2388 let show = repo.show(sha);
2389 cx.spawn(|_, _| async move { show.await? })
2390 })
2391 }
2392
2393 fn deploy_entry_context_menu(
2394 &mut self,
2395 position: Point<Pixels>,
2396 ix: usize,
2397 window: &mut Window,
2398 cx: &mut Context<Self>,
2399 ) {
2400 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2401 return;
2402 };
2403 let stage_title = if entry.status.is_staged() == Some(true) {
2404 "Unstage File"
2405 } else {
2406 "Stage File"
2407 };
2408 let restore_title = if entry.status.is_created() {
2409 "Trash File"
2410 } else {
2411 "Restore File"
2412 };
2413 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2414 context_menu
2415 .action(stage_title, ToggleStaged.boxed_clone())
2416 .action(restore_title, git::RestoreFile.boxed_clone())
2417 .separator()
2418 .action("Open Diff", Confirm.boxed_clone())
2419 .action("Open File", SecondaryConfirm.boxed_clone())
2420 });
2421 self.selected_entry = Some(ix);
2422 self.set_context_menu(context_menu, position, window, cx);
2423 }
2424
2425 fn deploy_panel_context_menu(
2426 &mut self,
2427 position: Point<Pixels>,
2428 window: &mut Window,
2429 cx: &mut Context<Self>,
2430 ) {
2431 let context_menu = git_panel_context_menu(window, cx);
2432 self.set_context_menu(context_menu, position, window, cx);
2433 }
2434
2435 fn set_context_menu(
2436 &mut self,
2437 context_menu: Entity<ContextMenu>,
2438 position: Point<Pixels>,
2439 window: &Window,
2440 cx: &mut Context<Self>,
2441 ) {
2442 let subscription = cx.subscribe_in(
2443 &context_menu,
2444 window,
2445 |this, _, _: &DismissEvent, window, cx| {
2446 if this.context_menu.as_ref().is_some_and(|context_menu| {
2447 context_menu.0.focus_handle(cx).contains_focused(window, cx)
2448 }) {
2449 cx.focus_self(window);
2450 }
2451 this.context_menu.take();
2452 cx.notify();
2453 },
2454 );
2455 self.context_menu = Some((context_menu, position, subscription));
2456 cx.notify();
2457 }
2458
2459 fn render_entry(
2460 &self,
2461 ix: usize,
2462 entry: &GitStatusEntry,
2463 has_write_access: bool,
2464 window: &Window,
2465 cx: &Context<Self>,
2466 ) -> AnyElement {
2467 let display_name = entry
2468 .repo_path
2469 .file_name()
2470 .map(|name| name.to_string_lossy().into_owned())
2471 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2472
2473 let repo_path = entry.repo_path.clone();
2474 let selected = self.selected_entry == Some(ix);
2475 let marked = self.marked_entries.contains(&ix);
2476 let status_style = GitPanelSettings::get_global(cx).status_style;
2477 let status = entry.status;
2478 let has_conflict = status.is_conflicted();
2479 let is_modified = status.is_modified();
2480 let is_deleted = status.is_deleted();
2481
2482 let label_color = if status_style == StatusStyle::LabelColor {
2483 if has_conflict {
2484 Color::Conflict
2485 } else if is_modified {
2486 Color::Modified
2487 } else if is_deleted {
2488 // We don't want a bunch of red labels in the list
2489 Color::Disabled
2490 } else {
2491 Color::Created
2492 }
2493 } else {
2494 Color::Default
2495 };
2496
2497 let path_color = if status.is_deleted() {
2498 Color::Disabled
2499 } else {
2500 Color::Muted
2501 };
2502
2503 let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
2504 let checkbox_wrapper_id: ElementId =
2505 ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
2506 let checkbox_id: ElementId =
2507 ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
2508
2509 let is_entry_staged = self.entry_is_staged(entry);
2510 let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2511
2512 if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2513 is_staged = ToggleState::Selected;
2514 }
2515
2516 let handle = cx.weak_entity();
2517
2518 let selected_bg_alpha = 0.08;
2519 let marked_bg_alpha = 0.12;
2520 let state_opacity_step = 0.04;
2521
2522 let base_bg = match (selected, marked) {
2523 (true, true) => cx
2524 .theme()
2525 .status()
2526 .info
2527 .alpha(selected_bg_alpha + marked_bg_alpha),
2528 (true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
2529 (false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
2530 _ => cx.theme().colors().ghost_element_background,
2531 };
2532
2533 let hover_bg = if selected {
2534 cx.theme()
2535 .status()
2536 .info
2537 .alpha(selected_bg_alpha + state_opacity_step)
2538 } else {
2539 cx.theme().colors().ghost_element_hover
2540 };
2541
2542 let active_bg = if selected {
2543 cx.theme()
2544 .status()
2545 .info
2546 .alpha(selected_bg_alpha + state_opacity_step * 2.0)
2547 } else {
2548 cx.theme().colors().ghost_element_active
2549 };
2550
2551 h_flex()
2552 .id(id)
2553 .h(self.list_item_height())
2554 .w_full()
2555 .items_center()
2556 .border_1()
2557 .when(selected && self.focus_handle.is_focused(window), |el| {
2558 el.border_color(cx.theme().colors().border_focused)
2559 })
2560 .px(rems(0.75)) // ~12px
2561 .overflow_hidden()
2562 .flex_none()
2563 .gap(DynamicSpacing::Base04.rems(cx))
2564 .bg(base_bg)
2565 .hover(|this| this.bg(hover_bg))
2566 .active(|this| this.bg(active_bg))
2567 .on_click({
2568 cx.listener(move |this, event: &ClickEvent, window, cx| {
2569 this.selected_entry = Some(ix);
2570 cx.notify();
2571 if event.modifiers().secondary() {
2572 this.open_file(&Default::default(), window, cx)
2573 } else {
2574 this.open_diff(&Default::default(), window, cx);
2575 this.focus_handle.focus(window);
2576 }
2577 })
2578 })
2579 .on_mouse_down(
2580 MouseButton::Right,
2581 move |event: &MouseDownEvent, window, cx| {
2582 // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
2583 if event.button != MouseButton::Right {
2584 return;
2585 }
2586
2587 let Some(this) = handle.upgrade() else {
2588 return;
2589 };
2590 this.update(cx, |this, cx| {
2591 this.deploy_entry_context_menu(event.position, ix, window, cx);
2592 });
2593 cx.stop_propagation();
2594 },
2595 )
2596 // .on_secondary_mouse_down(cx.listener(
2597 // move |this, event: &MouseDownEvent, window, cx| {
2598 // this.deploy_entry_context_menu(event.position, ix, window, cx);
2599 // cx.stop_propagation();
2600 // },
2601 // ))
2602 .child(
2603 div()
2604 .id(checkbox_wrapper_id)
2605 .flex_none()
2606 .occlude()
2607 .cursor_pointer()
2608 .child(
2609 Checkbox::new(checkbox_id, is_staged)
2610 .disabled(!has_write_access)
2611 .fill()
2612 .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2613 .elevation(ElevationIndex::Surface)
2614 .on_click({
2615 let entry = entry.clone();
2616 cx.listener(move |this, _, window, cx| {
2617 if !has_write_access {
2618 return;
2619 }
2620 this.toggle_staged_for_entry(
2621 &GitListEntry::GitStatusEntry(entry.clone()),
2622 window,
2623 cx,
2624 );
2625 cx.stop_propagation();
2626 })
2627 })
2628 .tooltip(move |window, cx| {
2629 let tooltip_name = if is_entry_staged.unwrap_or(false) {
2630 "Unstage"
2631 } else {
2632 "Stage"
2633 };
2634
2635 Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
2636 }),
2637 ),
2638 )
2639 .child(git_status_icon(status, cx))
2640 .child(
2641 h_flex()
2642 .items_center()
2643 .overflow_hidden()
2644 .when_some(repo_path.parent(), |this, parent| {
2645 let parent_str = parent.to_string_lossy();
2646 if !parent_str.is_empty() {
2647 this.child(
2648 self.entry_label(format!("{}/", parent_str), path_color)
2649 .when(status.is_deleted(), |this| this.strikethrough()),
2650 )
2651 } else {
2652 this
2653 }
2654 })
2655 .child(
2656 self.entry_label(display_name.clone(), label_color)
2657 .when(status.is_deleted(), |this| this.strikethrough()),
2658 ),
2659 )
2660 .into_any_element()
2661 }
2662
2663 fn has_write_access(&self, cx: &App) -> bool {
2664 !self.project.read(cx).is_read_only(cx)
2665 }
2666}
2667
2668impl Render for GitPanel {
2669 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2670 let project = self.project.read(cx);
2671 let has_entries = self.entries.len() > 0;
2672 let room = self
2673 .workspace
2674 .upgrade()
2675 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2676
2677 let has_write_access = self.has_write_access(cx);
2678
2679 let has_co_authors = room.map_or(false, |room| {
2680 room.read(cx)
2681 .remote_participants()
2682 .values()
2683 .any(|remote_participant| remote_participant.can_write())
2684 });
2685
2686 v_flex()
2687 .id("git_panel")
2688 .key_context(self.dispatch_context(window, cx))
2689 .track_focus(&self.focus_handle)
2690 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2691 .when(has_write_access && !project.is_read_only(cx), |this| {
2692 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2693 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2694 }))
2695 .on_action(cx.listener(GitPanel::commit))
2696 })
2697 .on_action(cx.listener(Self::select_first))
2698 .on_action(cx.listener(Self::select_next))
2699 .on_action(cx.listener(Self::select_previous))
2700 .on_action(cx.listener(Self::select_last))
2701 .on_action(cx.listener(Self::close_panel))
2702 .on_action(cx.listener(Self::open_diff))
2703 .on_action(cx.listener(Self::open_file))
2704 .on_action(cx.listener(Self::revert_selected))
2705 .on_action(cx.listener(Self::focus_changes_list))
2706 .on_action(cx.listener(Self::focus_editor))
2707 .on_action(cx.listener(Self::toggle_staged_for_selected))
2708 .on_action(cx.listener(Self::stage_all))
2709 .on_action(cx.listener(Self::unstage_all))
2710 .on_action(cx.listener(Self::restore_tracked_files))
2711 .on_action(cx.listener(Self::clean_all))
2712 .on_action(cx.listener(Self::fetch))
2713 .on_action(cx.listener(Self::pull))
2714 .on_action(cx.listener(Self::push))
2715 .when(has_write_access && has_co_authors, |git_panel| {
2716 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2717 })
2718 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2719 .on_hover(cx.listener(|this, hovered, window, cx| {
2720 if *hovered {
2721 this.show_scrollbar = true;
2722 this.hide_scrollbar_task.take();
2723 cx.notify();
2724 } else if !this.focus_handle.contains_focused(window, cx) {
2725 this.hide_scrollbar(window, cx);
2726 }
2727 }))
2728 .size_full()
2729 .overflow_hidden()
2730 .bg(ElevationIndex::Surface.bg(cx))
2731 .child(
2732 v_flex()
2733 .size_full()
2734 .map(|this| {
2735 if has_entries {
2736 this.child(self.render_entries(has_write_access, window, cx))
2737 } else {
2738 this.child(self.render_empty_state(cx).into_any_element())
2739 }
2740 })
2741 .children(self.render_footer(window, cx))
2742 .children(self.render_previous_commit(cx))
2743 .into_any_element(),
2744 )
2745 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2746 deferred(
2747 anchored()
2748 .position(*position)
2749 .anchor(gpui::Corner::TopLeft)
2750 .child(menu.clone()),
2751 )
2752 .with_priority(1)
2753 }))
2754 }
2755}
2756
2757impl Focusable for GitPanel {
2758 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2759 self.focus_handle.clone()
2760 }
2761}
2762
2763impl EventEmitter<Event> for GitPanel {}
2764
2765impl EventEmitter<PanelEvent> for GitPanel {}
2766
2767pub(crate) struct GitPanelAddon {
2768 pub(crate) workspace: WeakEntity<Workspace>,
2769}
2770
2771impl editor::Addon for GitPanelAddon {
2772 fn to_any(&self) -> &dyn std::any::Any {
2773 self
2774 }
2775
2776 fn render_buffer_header_controls(
2777 &self,
2778 excerpt_info: &ExcerptInfo,
2779 window: &Window,
2780 cx: &App,
2781 ) -> Option<AnyElement> {
2782 let file = excerpt_info.buffer.file()?;
2783 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2784
2785 git_panel
2786 .read(cx)
2787 .render_buffer_header_controls(&git_panel, &file, window, cx)
2788 }
2789}
2790
2791impl Panel for GitPanel {
2792 fn persistent_name() -> &'static str {
2793 "GitPanel"
2794 }
2795
2796 fn position(&self, _: &Window, cx: &App) -> DockPosition {
2797 GitPanelSettings::get_global(cx).dock
2798 }
2799
2800 fn position_is_valid(&self, position: DockPosition) -> bool {
2801 matches!(position, DockPosition::Left | DockPosition::Right)
2802 }
2803
2804 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2805 settings::update_settings_file::<GitPanelSettings>(
2806 self.fs.clone(),
2807 cx,
2808 move |settings, _| settings.dock = Some(position),
2809 );
2810 }
2811
2812 fn size(&self, _: &Window, cx: &App) -> Pixels {
2813 self.width
2814 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2815 }
2816
2817 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2818 self.width = size;
2819 self.serialize(cx);
2820 cx.notify();
2821 }
2822
2823 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2824 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2825 }
2826
2827 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2828 Some("Git Panel")
2829 }
2830
2831 fn toggle_action(&self) -> Box<dyn Action> {
2832 Box::new(ToggleFocus)
2833 }
2834
2835 fn activation_priority(&self) -> u32 {
2836 2
2837 }
2838}
2839
2840impl PanelHeader for GitPanel {}
2841
2842struct GitPanelMessageTooltip {
2843 commit_tooltip: Option<Entity<CommitTooltip>>,
2844}
2845
2846impl GitPanelMessageTooltip {
2847 fn new(
2848 git_panel: Entity<GitPanel>,
2849 sha: SharedString,
2850 window: &mut Window,
2851 cx: &mut App,
2852 ) -> Entity<Self> {
2853 cx.new(|cx| {
2854 cx.spawn_in(window, |this, mut cx| async move {
2855 let details = git_panel
2856 .update(&mut cx, |git_panel, cx| {
2857 git_panel.load_commit_details(&sha, cx)
2858 })?
2859 .await?;
2860
2861 let commit_details = editor::commit_tooltip::CommitDetails {
2862 sha: details.sha.clone(),
2863 committer_name: details.committer_name.clone(),
2864 committer_email: details.committer_email.clone(),
2865 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2866 message: Some(editor::commit_tooltip::ParsedCommitMessage {
2867 message: details.message.clone(),
2868 ..Default::default()
2869 }),
2870 };
2871
2872 this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2873 this.commit_tooltip =
2874 Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2875 cx.notify();
2876 })
2877 })
2878 .detach();
2879
2880 Self {
2881 commit_tooltip: None,
2882 }
2883 })
2884 }
2885}
2886
2887impl Render for GitPanelMessageTooltip {
2888 fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2889 if let Some(commit_tooltip) = &self.commit_tooltip {
2890 commit_tooltip.clone().into_any_element()
2891 } else {
2892 gpui::Empty.into_any_element()
2893 }
2894 }
2895}
2896
2897fn git_action_tooltip(
2898 label: impl Into<SharedString>,
2899 action: &dyn Action,
2900 command: impl Into<SharedString>,
2901 focus_handle: Option<FocusHandle>,
2902 window: &mut Window,
2903 cx: &mut App,
2904) -> AnyView {
2905 let label = label.into();
2906 let command = command.into();
2907
2908 if let Some(handle) = focus_handle {
2909 Tooltip::with_meta_in(
2910 label.clone(),
2911 Some(action),
2912 command.clone(),
2913 &handle,
2914 window,
2915 cx,
2916 )
2917 } else {
2918 Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
2919 }
2920}
2921
2922#[derive(IntoElement)]
2923struct SplitButton {
2924 pub left: ButtonLike,
2925 pub right: AnyElement,
2926}
2927
2928impl SplitButton {
2929 fn new(
2930 id: impl Into<SharedString>,
2931 left_label: impl Into<SharedString>,
2932 ahead_count: usize,
2933 behind_count: usize,
2934 left_icon: Option<IconName>,
2935 left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
2936 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
2937 ) -> Self {
2938 let id = id.into();
2939
2940 fn count(count: usize) -> impl IntoElement {
2941 h_flex()
2942 .ml_neg_px()
2943 .h(rems(0.875))
2944 .items_center()
2945 .overflow_hidden()
2946 .px_0p5()
2947 .child(
2948 Label::new(count.to_string())
2949 .size(LabelSize::XSmall)
2950 .line_height_style(LineHeightStyle::UiLabel),
2951 )
2952 }
2953
2954 let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
2955
2956 let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
2957 format!("split-button-left-{}", id).into(),
2958 ))
2959 .layer(ui::ElevationIndex::ModalSurface)
2960 .size(ui::ButtonSize::Compact)
2961 .when(should_render_counts, |this| {
2962 this.child(
2963 h_flex()
2964 .ml_neg_0p5()
2965 .mr_1()
2966 .when(behind_count > 0, |this| {
2967 this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
2968 .child(count(behind_count))
2969 })
2970 .when(ahead_count > 0, |this| {
2971 this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
2972 .child(count(ahead_count))
2973 }),
2974 )
2975 })
2976 .when_some(left_icon, |this, left_icon| {
2977 this.child(
2978 h_flex()
2979 .ml_neg_0p5()
2980 .mr_1()
2981 .child(Icon::new(left_icon).size(IconSize::XSmall)),
2982 )
2983 })
2984 .child(
2985 div()
2986 .child(Label::new(left_label).size(LabelSize::Small))
2987 .mr_0p5(),
2988 )
2989 .on_click(left_on_click)
2990 .tooltip(tooltip);
2991
2992 let right =
2993 render_git_action_menu(ElementId::Name(format!("split-button-right-{}", id).into()))
2994 .into_any_element();
2995 // .on_click(right_on_click);
2996
2997 Self { left, right }
2998 }
2999}
3000
3001impl RenderOnce for SplitButton {
3002 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
3003 h_flex()
3004 .rounded_md()
3005 .border_1()
3006 .border_color(cx.theme().colors().text_muted.alpha(0.12))
3007 .child(self.left)
3008 .child(
3009 div()
3010 .h_full()
3011 .w_px()
3012 .bg(cx.theme().colors().text_muted.alpha(0.16)),
3013 )
3014 .child(self.right)
3015 .bg(ElevationIndex::Surface.on_elevation_bg(cx))
3016 .shadow(smallvec![BoxShadow {
3017 color: hsla(0.0, 0.0, 0.0, 0.16),
3018 offset: point(px(0.), px(1.)),
3019 blur_radius: px(0.),
3020 spread_radius: px(0.),
3021 }])
3022 }
3023}
3024
3025fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
3026 PopoverMenu::new(id.into())
3027 .trigger(
3028 ui::ButtonLike::new_rounded_right("split-button-right")
3029 .layer(ui::ElevationIndex::ModalSurface)
3030 .size(ui::ButtonSize::None)
3031 .child(
3032 div()
3033 .px_1()
3034 .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
3035 ),
3036 )
3037 .menu(move |window, cx| {
3038 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
3039 context_menu
3040 .action("Fetch", git::Fetch.boxed_clone())
3041 .action("Pull", git::Pull.boxed_clone())
3042 .separator()
3043 .action("Push", git::Push { options: None }.boxed_clone())
3044 .action(
3045 "Force Push",
3046 git::Push {
3047 options: Some(PushOptions::Force),
3048 }
3049 .boxed_clone(),
3050 )
3051 }))
3052 })
3053 .anchor(Corner::TopRight)
3054}
3055
3056#[derive(IntoElement, IntoComponent)]
3057#[component(scope = "git_panel")]
3058pub struct PanelRepoFooter {
3059 id: SharedString,
3060 active_repository: SharedString,
3061 branch: Option<Branch>,
3062 // Getting a GitPanel in previews will be difficult.
3063 //
3064 // For now just take an option here, and we won't bind handlers to buttons in previews.
3065 git_panel: Option<Entity<GitPanel>>,
3066}
3067
3068impl PanelRepoFooter {
3069 pub fn new(
3070 id: impl Into<SharedString>,
3071 active_repository: SharedString,
3072 branch: Option<Branch>,
3073 git_panel: Option<Entity<GitPanel>>,
3074 ) -> Self {
3075 Self {
3076 id: id.into(),
3077 active_repository,
3078 branch,
3079 git_panel,
3080 }
3081 }
3082
3083 pub fn new_preview(
3084 id: impl Into<SharedString>,
3085 active_repository: SharedString,
3086 branch: Option<Branch>,
3087 ) -> Self {
3088 Self {
3089 id: id.into(),
3090 active_repository,
3091 branch,
3092 git_panel: None,
3093 }
3094 }
3095
3096 fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
3097 PopoverMenu::new(id.into())
3098 .trigger(
3099 IconButton::new("overflow-menu-trigger", IconName::EllipsisVertical)
3100 .icon_size(IconSize::Small)
3101 .icon_color(Color::Muted),
3102 )
3103 .menu(move |window, cx| Some(git_panel_context_menu(window, cx)))
3104 .anchor(Corner::TopRight)
3105 }
3106
3107 fn panel_focus_handle(&self, cx: &App) -> Option<FocusHandle> {
3108 if let Some(git_panel) = self.git_panel.clone() {
3109 Some(git_panel.focus_handle(cx))
3110 } else {
3111 None
3112 }
3113 }
3114
3115 fn render_push_button(&self, id: SharedString, ahead: u32, cx: &mut App) -> SplitButton {
3116 let panel = self.git_panel.clone();
3117 let panel_focus_handle = self.panel_focus_handle(cx);
3118
3119 SplitButton::new(
3120 id,
3121 "Push",
3122 ahead as usize,
3123 0,
3124 None,
3125 move |_, window, cx| {
3126 if let Some(panel) = panel.as_ref() {
3127 panel.update(cx, |panel, cx| {
3128 panel.push(&git::Push { options: None }, window, cx);
3129 });
3130 }
3131 },
3132 move |window, cx| {
3133 git_action_tooltip(
3134 "Push committed changes to remote",
3135 &git::Push { options: None },
3136 "git push",
3137 panel_focus_handle.clone(),
3138 window,
3139 cx,
3140 )
3141 },
3142 )
3143 }
3144
3145 fn render_pull_button(
3146 &self,
3147 id: SharedString,
3148 ahead: u32,
3149 behind: u32,
3150 cx: &mut App,
3151 ) -> SplitButton {
3152 let panel = self.git_panel.clone();
3153 let panel_focus_handle = self.panel_focus_handle(cx);
3154
3155 SplitButton::new(
3156 id,
3157 "Pull",
3158 ahead as usize,
3159 behind as usize,
3160 None,
3161 move |_, window, cx| {
3162 if let Some(panel) = panel.as_ref() {
3163 panel.update(cx, |panel, cx| {
3164 panel.pull(&git::Pull, window, cx);
3165 });
3166 }
3167 },
3168 move |window, cx| {
3169 git_action_tooltip(
3170 "Pull",
3171 &git::Pull,
3172 "git pull",
3173 panel_focus_handle.clone(),
3174 window,
3175 cx,
3176 )
3177 },
3178 )
3179 }
3180
3181 fn render_fetch_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
3182 let panel = self.git_panel.clone();
3183 let panel_focus_handle = self.panel_focus_handle(cx);
3184
3185 SplitButton::new(
3186 id,
3187 "Fetch",
3188 0,
3189 0,
3190 Some(IconName::ArrowCircle),
3191 move |_, window, cx| {
3192 if let Some(panel) = panel.as_ref() {
3193 panel.update(cx, |panel, cx| {
3194 panel.fetch(&git::Fetch, window, cx);
3195 });
3196 }
3197 },
3198 move |window, cx| {
3199 git_action_tooltip(
3200 "Fetch updates from remote",
3201 &git::Fetch,
3202 "git fetch",
3203 panel_focus_handle.clone(),
3204 window,
3205 cx,
3206 )
3207 },
3208 )
3209 }
3210
3211 fn render_publish_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
3212 let panel = self.git_panel.clone();
3213 let panel_focus_handle = self.panel_focus_handle(cx);
3214
3215 SplitButton::new(
3216 id,
3217 "Publish",
3218 0,
3219 0,
3220 Some(IconName::ArrowUpFromLine),
3221 move |_, window, cx| {
3222 if let Some(panel) = panel.as_ref() {
3223 panel.update(cx, |panel, cx| {
3224 panel.push(
3225 &git::Push {
3226 options: Some(PushOptions::SetUpstream),
3227 },
3228 window,
3229 cx,
3230 );
3231 });
3232 }
3233 },
3234 move |window, cx| {
3235 git_action_tooltip(
3236 "Publish branch to remote",
3237 &git::Push {
3238 options: Some(PushOptions::SetUpstream),
3239 },
3240 "git push --set-upstream",
3241 panel_focus_handle.clone(),
3242 window,
3243 cx,
3244 )
3245 },
3246 )
3247 }
3248
3249 fn render_republish_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
3250 let panel = self.git_panel.clone();
3251 let panel_focus_handle = self.panel_focus_handle(cx);
3252
3253 SplitButton::new(
3254 id,
3255 "Republish",
3256 0,
3257 0,
3258 Some(IconName::ArrowUpFromLine),
3259 move |_, window, cx| {
3260 if let Some(panel) = panel.as_ref() {
3261 panel.update(cx, |panel, cx| {
3262 panel.push(
3263 &git::Push {
3264 options: Some(PushOptions::SetUpstream),
3265 },
3266 window,
3267 cx,
3268 );
3269 });
3270 }
3271 },
3272 move |window, cx| {
3273 git_action_tooltip(
3274 "Re-publish branch to remote",
3275 &git::Push {
3276 options: Some(PushOptions::SetUpstream),
3277 },
3278 "git push --set-upstream",
3279 panel_focus_handle.clone(),
3280 window,
3281 cx,
3282 )
3283 },
3284 )
3285 }
3286
3287 fn render_relevant_button(
3288 &self,
3289 id: impl Into<SharedString>,
3290 branch: &Branch,
3291 cx: &mut App,
3292 ) -> impl IntoElement {
3293 let id = id.into();
3294 let upstream = branch.upstream.as_ref();
3295 match upstream {
3296 Some(Upstream {
3297 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
3298 ..
3299 }) => match (*ahead, *behind) {
3300 (0, 0) => self.render_fetch_button(id, cx),
3301 (ahead, 0) => self.render_push_button(id, ahead, cx),
3302 (ahead, behind) => self.render_pull_button(id, ahead, behind, cx),
3303 },
3304 Some(Upstream {
3305 tracking: UpstreamTracking::Gone,
3306 ..
3307 }) => self.render_republish_button(id, cx),
3308 None => self.render_publish_button(id, cx),
3309 }
3310 }
3311}
3312
3313impl RenderOnce for PanelRepoFooter {
3314 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
3315 let active_repo = self.active_repository.clone();
3316 let overflow_menu_id: SharedString = format!("overflow-menu-{}", active_repo).into();
3317 let repo_selector_trigger = Button::new("repo-selector", active_repo)
3318 .style(ButtonStyle::Transparent)
3319 .size(ButtonSize::None)
3320 .label_size(LabelSize::Small)
3321 .color(Color::Muted);
3322
3323 let project = self
3324 .git_panel
3325 .as_ref()
3326 .map(|panel| panel.read(cx).project.clone());
3327
3328 let single_repo = project
3329 .as_ref()
3330 .map(|project| project.read(cx).all_repositories(cx).len() == 1)
3331 .unwrap_or(true);
3332
3333 let repo_selector = PopoverMenu::new("repository-switcher")
3334 .menu({
3335 let project = project.clone();
3336 move |window, cx| {
3337 let project = project.clone()?;
3338 Some(cx.new(|cx| RepositorySelector::new(project, window, cx)))
3339 }
3340 })
3341 .trigger_with_tooltip(
3342 repo_selector_trigger.disabled(single_repo).truncate(true),
3343 Tooltip::text("Switch active repository"),
3344 )
3345 .attach(gpui::Corner::BottomLeft)
3346 .into_any_element();
3347
3348 let branch = self.branch.clone();
3349 let branch_name = branch
3350 .as_ref()
3351 .map_or(" (no branch)".into(), |branch| branch.name.clone());
3352
3353 let branch_selector_button = Button::new("branch-selector", branch_name)
3354 .style(ButtonStyle::Transparent)
3355 .size(ButtonSize::None)
3356 .label_size(LabelSize::Small)
3357 .truncate(true)
3358 .tooltip(Tooltip::for_action_title(
3359 "Switch Branch",
3360 &zed_actions::git::Branch,
3361 ))
3362 .on_click(|_, window, cx| {
3363 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
3364 });
3365
3366 let branch_selector = PopoverMenu::new("popover-button")
3367 .menu(move |window, cx| Some(branch_picker::popover(project.clone()?, window, cx)))
3368 .trigger_with_tooltip(
3369 branch_selector_button,
3370 Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
3371 )
3372 .anchor(Corner::TopLeft)
3373 .offset(gpui::Point {
3374 x: px(0.0),
3375 y: px(-2.0),
3376 });
3377
3378 let spinner = self
3379 .git_panel
3380 .as_ref()
3381 .and_then(|git_panel| git_panel.read(cx).render_spinner());
3382
3383 h_flex()
3384 .w_full()
3385 .px_2()
3386 .h(px(36.))
3387 .items_center()
3388 .justify_between()
3389 .child(
3390 h_flex()
3391 .flex_1()
3392 .overflow_hidden()
3393 .items_center()
3394 .child(
3395 div().child(
3396 Icon::new(IconName::GitBranchSmall)
3397 .size(IconSize::Small)
3398 .color(Color::Muted),
3399 ),
3400 )
3401 .child(repo_selector)
3402 .when_some(branch.clone(), |this, _| {
3403 this.child(
3404 div()
3405 .text_color(cx.theme().colors().text_muted)
3406 .text_sm()
3407 .child("/"),
3408 )
3409 })
3410 .child(branch_selector),
3411 )
3412 .child(
3413 h_flex()
3414 .gap_1()
3415 .flex_shrink_0()
3416 .children(spinner)
3417 .child(self.render_overflow_menu(overflow_menu_id))
3418 .when_some(branch, |this, branch| {
3419 let button = self.render_relevant_button(self.id.clone(), &branch, cx);
3420 this.child(button)
3421 }),
3422 )
3423 }
3424}
3425
3426impl ComponentPreview for PanelRepoFooter {
3427 fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
3428 let unknown_upstream = None;
3429 let no_remote_upstream = Some(UpstreamTracking::Gone);
3430 let ahead_of_upstream = Some(
3431 UpstreamTrackingStatus {
3432 ahead: 2,
3433 behind: 0,
3434 }
3435 .into(),
3436 );
3437 let behind_upstream = Some(
3438 UpstreamTrackingStatus {
3439 ahead: 0,
3440 behind: 2,
3441 }
3442 .into(),
3443 );
3444 let ahead_and_behind_upstream = Some(
3445 UpstreamTrackingStatus {
3446 ahead: 3,
3447 behind: 1,
3448 }
3449 .into(),
3450 );
3451
3452 let not_ahead_or_behind_upstream = Some(
3453 UpstreamTrackingStatus {
3454 ahead: 0,
3455 behind: 0,
3456 }
3457 .into(),
3458 );
3459
3460 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
3461 Branch {
3462 is_head: true,
3463 name: "some-branch".into(),
3464 upstream: upstream.map(|tracking| Upstream {
3465 ref_name: "origin/some-branch".into(),
3466 tracking,
3467 }),
3468 most_recent_commit: Some(CommitSummary {
3469 sha: "abc123".into(),
3470 subject: "Modify stuff".into(),
3471 commit_timestamp: 1710932954,
3472 }),
3473 }
3474 }
3475
3476 fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
3477 Branch {
3478 is_head: true,
3479 name: branch_name.to_string().into(),
3480 upstream: upstream.map(|tracking| Upstream {
3481 ref_name: format!("zed/{}", branch_name).into(),
3482 tracking,
3483 }),
3484 most_recent_commit: Some(CommitSummary {
3485 sha: "abc123".into(),
3486 subject: "Modify stuff".into(),
3487 commit_timestamp: 1710932954,
3488 }),
3489 }
3490 }
3491
3492 fn active_repository(id: usize) -> SharedString {
3493 format!("repo-{}", id).into()
3494 }
3495
3496 let example_width = px(340.);
3497
3498 v_flex()
3499 .gap_6()
3500 .w_full()
3501 .flex_none()
3502 .children(vec![example_group_with_title(
3503 "Action Button States",
3504 vec![
3505 single_example(
3506 "No Branch",
3507 div()
3508 .w(example_width)
3509 .overflow_hidden()
3510 .child(PanelRepoFooter::new_preview(
3511 "no-branch",
3512 active_repository(1).clone(),
3513 None,
3514 ))
3515 .into_any_element(),
3516 )
3517 .grow(),
3518 single_example(
3519 "Remote status unknown",
3520 div()
3521 .w(example_width)
3522 .overflow_hidden()
3523 .child(PanelRepoFooter::new_preview(
3524 "unknown-upstream",
3525 active_repository(2).clone(),
3526 Some(branch(unknown_upstream)),
3527 ))
3528 .into_any_element(),
3529 )
3530 .grow(),
3531 single_example(
3532 "No Remote Upstream",
3533 div()
3534 .w(example_width)
3535 .overflow_hidden()
3536 .child(PanelRepoFooter::new_preview(
3537 "no-remote-upstream",
3538 active_repository(3).clone(),
3539 Some(branch(no_remote_upstream)),
3540 ))
3541 .into_any_element(),
3542 )
3543 .grow(),
3544 single_example(
3545 "Not Ahead or Behind",
3546 div()
3547 .w(example_width)
3548 .overflow_hidden()
3549 .child(PanelRepoFooter::new_preview(
3550 "not-ahead-or-behind",
3551 active_repository(4).clone(),
3552 Some(branch(not_ahead_or_behind_upstream)),
3553 ))
3554 .into_any_element(),
3555 )
3556 .grow(),
3557 single_example(
3558 "Behind remote",
3559 div()
3560 .w(example_width)
3561 .overflow_hidden()
3562 .child(PanelRepoFooter::new_preview(
3563 "behind-remote",
3564 active_repository(5).clone(),
3565 Some(branch(behind_upstream)),
3566 ))
3567 .into_any_element(),
3568 )
3569 .grow(),
3570 single_example(
3571 "Ahead of remote",
3572 div()
3573 .w(example_width)
3574 .overflow_hidden()
3575 .child(PanelRepoFooter::new_preview(
3576 "ahead-of-remote",
3577 active_repository(6).clone(),
3578 Some(branch(ahead_of_upstream)),
3579 ))
3580 .into_any_element(),
3581 )
3582 .grow(),
3583 single_example(
3584 "Ahead and behind remote",
3585 div()
3586 .w(example_width)
3587 .overflow_hidden()
3588 .child(PanelRepoFooter::new_preview(
3589 "ahead-and-behind",
3590 active_repository(7).clone(),
3591 Some(branch(ahead_and_behind_upstream)),
3592 ))
3593 .into_any_element(),
3594 )
3595 .grow(),
3596 ],
3597 )
3598 .grow()
3599 .vertical()])
3600 .children(vec![example_group_with_title(
3601 "Labels",
3602 vec![
3603 single_example(
3604 "Short Branch & Repo",
3605 div()
3606 .w(example_width)
3607 .overflow_hidden()
3608 .child(PanelRepoFooter::new_preview(
3609 "short-branch",
3610 SharedString::from("zed"),
3611 Some(custom("main", behind_upstream)),
3612 ))
3613 .into_any_element(),
3614 )
3615 .grow(),
3616 single_example(
3617 "Long Branch",
3618 div()
3619 .w(example_width)
3620 .overflow_hidden()
3621 .child(PanelRepoFooter::new_preview(
3622 "long-branch",
3623 SharedString::from("zed"),
3624 Some(custom(
3625 "redesign-and-update-git-ui-list-entry-style",
3626 behind_upstream,
3627 )),
3628 ))
3629 .into_any_element(),
3630 )
3631 .grow(),
3632 single_example(
3633 "Long Repo",
3634 div()
3635 .w(example_width)
3636 .overflow_hidden()
3637 .child(PanelRepoFooter::new_preview(
3638 "long-repo",
3639 SharedString::from("zed-industries-community-examples"),
3640 Some(custom("gpui", ahead_of_upstream)),
3641 ))
3642 .into_any_element(),
3643 )
3644 .grow(),
3645 single_example(
3646 "Long Repo & Branch",
3647 div()
3648 .w(example_width)
3649 .overflow_hidden()
3650 .child(PanelRepoFooter::new_preview(
3651 "long-repo-and-branch",
3652 SharedString::from("zed-industries-community-examples"),
3653 Some(custom(
3654 "redesign-and-update-git-ui-list-entry-style",
3655 behind_upstream,
3656 )),
3657 ))
3658 .into_any_element(),
3659 )
3660 .grow(),
3661 single_example(
3662 "Uppercase Repo",
3663 div()
3664 .w(example_width)
3665 .overflow_hidden()
3666 .child(PanelRepoFooter::new_preview(
3667 "uppercase-repo",
3668 SharedString::from("LICENSES"),
3669 Some(custom("main", ahead_of_upstream)),
3670 ))
3671 .into_any_element(),
3672 )
3673 .grow(),
3674 single_example(
3675 "Uppercase Branch",
3676 div()
3677 .w(example_width)
3678 .overflow_hidden()
3679 .child(PanelRepoFooter::new_preview(
3680 "uppercase-branch",
3681 SharedString::from("zed"),
3682 Some(custom("update-README", behind_upstream)),
3683 ))
3684 .into_any_element(),
3685 )
3686 .grow(),
3687 ],
3688 )
3689 .grow()
3690 .vertical()])
3691 .into_any_element()
3692 }
3693}