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