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 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 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 guard = self.start_remote_operation();
1403 let remote = self.get_current_remote(window, cx);
1404 cx.spawn(move |this, mut cx| async move {
1405 let remote = match remote.await {
1406 Ok(Some(remote)) => remote,
1407 Ok(None) => {
1408 return Ok(());
1409 }
1410 Err(e) => {
1411 log::error!("Failed to get current remote: {}", e);
1412 this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
1413 .ok();
1414 return Ok(());
1415 }
1416 };
1417
1418 let pull = repo.update(&mut cx, |repo, _cx| {
1419 repo.pull(branch.name.clone(), remote.name.clone())
1420 })?;
1421
1422 let remote_message = pull.await?;
1423 drop(guard);
1424
1425 this.update(&mut cx, |this, cx| match remote_message {
1426 Ok(remote_message) => {
1427 this.show_remote_output(RemoteAction::Pull, remote_message, cx)
1428 }
1429 Err(err) => this.show_err_toast(err, cx),
1430 })
1431 .ok();
1432
1433 anyhow::Ok(())
1434 })
1435 .detach_and_log_err(cx);
1436 }
1437
1438 fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
1439 let Some(repo) = self.active_repository.clone() else {
1440 return;
1441 };
1442 let Some(branch) = repo.read(cx).current_branch() else {
1443 return;
1444 };
1445 let branch = branch.clone();
1446 let guard = self.start_remote_operation();
1447 let options = action.options;
1448 let remote = self.get_current_remote(window, cx);
1449
1450 cx.spawn(move |this, mut cx| async move {
1451 let remote = match remote.await {
1452 Ok(Some(remote)) => remote,
1453 Ok(None) => {
1454 return Ok(());
1455 }
1456 Err(e) => {
1457 log::error!("Failed to get current remote: {}", e);
1458 this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
1459 .ok();
1460 return Ok(());
1461 }
1462 };
1463
1464 let push = repo.update(&mut cx, |repo, _cx| {
1465 repo.push(branch.name.clone(), remote.name.clone(), options)
1466 })?;
1467
1468 let remote_output = push.await?;
1469
1470 drop(guard);
1471
1472 this.update(&mut cx, |this, cx| match remote_output {
1473 Ok(remote_message) => {
1474 this.show_remote_output(RemoteAction::Push(remote), remote_message, cx);
1475 }
1476 Err(e) => {
1477 this.show_err_toast(e, cx);
1478 }
1479 })?;
1480
1481 anyhow::Ok(())
1482 })
1483 .detach_and_log_err(cx);
1484 }
1485
1486 fn get_current_remote(
1487 &mut self,
1488 window: &mut Window,
1489 cx: &mut Context<Self>,
1490 ) -> impl Future<Output = Result<Option<Remote>>> {
1491 let repo = self.active_repository.clone();
1492 let workspace = self.workspace.clone();
1493 let mut cx = window.to_async(cx);
1494
1495 async move {
1496 let Some(repo) = repo else {
1497 return Err(anyhow::anyhow!("No active repository"));
1498 };
1499
1500 let mut current_remotes: Vec<Remote> = repo
1501 .update(&mut cx, |repo, _| {
1502 let Some(current_branch) = repo.current_branch() else {
1503 return Err(anyhow::anyhow!("No active branch"));
1504 };
1505
1506 Ok(repo.get_remotes(Some(current_branch.name.to_string())))
1507 })??
1508 .await??;
1509
1510 if current_remotes.len() == 0 {
1511 return Err(anyhow::anyhow!("No active remote"));
1512 } else if current_remotes.len() == 1 {
1513 return Ok(Some(current_remotes.pop().unwrap()));
1514 } else {
1515 let current_remotes: Vec<_> = current_remotes
1516 .into_iter()
1517 .map(|remotes| remotes.name)
1518 .collect();
1519 let selection = cx
1520 .update(|window, cx| {
1521 picker_prompt::prompt(
1522 "Pick which remote to push to",
1523 current_remotes.clone(),
1524 workspace,
1525 window,
1526 cx,
1527 )
1528 })?
1529 .await?;
1530
1531 Ok(selection.map(|selection| Remote {
1532 name: current_remotes[selection].clone(),
1533 }))
1534 }
1535 }
1536 }
1537
1538 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
1539 let mut new_co_authors = Vec::new();
1540 let project = self.project.read(cx);
1541
1542 let Some(room) = self
1543 .workspace
1544 .upgrade()
1545 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1546 else {
1547 return Vec::default();
1548 };
1549
1550 let room = room.read(cx);
1551
1552 for (peer_id, collaborator) in project.collaborators() {
1553 if collaborator.is_host {
1554 continue;
1555 }
1556
1557 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
1558 continue;
1559 };
1560 if participant.can_write() && participant.user.email.is_some() {
1561 let email = participant.user.email.clone().unwrap();
1562
1563 new_co_authors.push((
1564 participant
1565 .user
1566 .name
1567 .clone()
1568 .unwrap_or_else(|| participant.user.github_login.clone()),
1569 email,
1570 ))
1571 }
1572 }
1573 if !project.is_local() && !project.is_read_only(cx) {
1574 if let Some(user) = room.local_participant_user(cx) {
1575 if let Some(email) = user.email.clone() {
1576 new_co_authors.push((
1577 user.name
1578 .clone()
1579 .unwrap_or_else(|| user.github_login.clone()),
1580 email.clone(),
1581 ))
1582 }
1583 }
1584 }
1585 new_co_authors
1586 }
1587
1588 fn toggle_fill_co_authors(
1589 &mut self,
1590 _: &ToggleFillCoAuthors,
1591 _: &mut Window,
1592 cx: &mut Context<Self>,
1593 ) {
1594 self.add_coauthors = !self.add_coauthors;
1595 cx.notify();
1596 }
1597
1598 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
1599 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
1600
1601 let existing_text = message.to_ascii_lowercase();
1602 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
1603 let mut ends_with_co_authors = false;
1604 let existing_co_authors = existing_text
1605 .lines()
1606 .filter_map(|line| {
1607 let line = line.trim();
1608 if line.starts_with(&lowercase_co_author_prefix) {
1609 ends_with_co_authors = true;
1610 Some(line)
1611 } else {
1612 ends_with_co_authors = false;
1613 None
1614 }
1615 })
1616 .collect::<HashSet<_>>();
1617
1618 let new_co_authors = self
1619 .potential_co_authors(cx)
1620 .into_iter()
1621 .filter(|(_, email)| {
1622 !existing_co_authors
1623 .iter()
1624 .any(|existing| existing.contains(email.as_str()))
1625 })
1626 .collect::<Vec<_>>();
1627
1628 if new_co_authors.is_empty() {
1629 return;
1630 }
1631
1632 if !ends_with_co_authors {
1633 message.push('\n');
1634 }
1635 for (name, email) in new_co_authors {
1636 message.push('\n');
1637 message.push_str(CO_AUTHOR_PREFIX);
1638 message.push_str(&name);
1639 message.push_str(" <");
1640 message.push_str(&email);
1641 message.push('>');
1642 }
1643 message.push('\n');
1644 }
1645
1646 fn schedule_update(
1647 &mut self,
1648 clear_pending: bool,
1649 window: &mut Window,
1650 cx: &mut Context<Self>,
1651 ) {
1652 let handle = cx.entity().downgrade();
1653 self.reopen_commit_buffer(window, cx);
1654 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1655 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1656 if let Some(git_panel) = handle.upgrade() {
1657 git_panel
1658 .update_in(&mut cx, |git_panel, _, cx| {
1659 if clear_pending {
1660 git_panel.clear_pending();
1661 }
1662 git_panel.update_visible_entries(cx);
1663 git_panel.update_editor_placeholder(cx);
1664 })
1665 .ok();
1666 }
1667 });
1668 }
1669
1670 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1671 let Some(active_repo) = self.active_repository.as_ref() else {
1672 return;
1673 };
1674 let load_buffer = active_repo.update(cx, |active_repo, cx| {
1675 let project = self.project.read(cx);
1676 active_repo.open_commit_buffer(
1677 Some(project.languages().clone()),
1678 project.buffer_store().clone(),
1679 cx,
1680 )
1681 });
1682
1683 cx.spawn_in(window, |git_panel, mut cx| async move {
1684 let buffer = load_buffer.await?;
1685 git_panel.update_in(&mut cx, |git_panel, window, cx| {
1686 if git_panel
1687 .commit_editor
1688 .read(cx)
1689 .buffer()
1690 .read(cx)
1691 .as_singleton()
1692 .as_ref()
1693 != Some(&buffer)
1694 {
1695 git_panel.commit_editor = cx.new(|cx| {
1696 commit_message_editor(
1697 buffer,
1698 git_panel.suggest_commit_message().as_deref(),
1699 git_panel.project.clone(),
1700 true,
1701 window,
1702 cx,
1703 )
1704 });
1705 }
1706 })
1707 })
1708 .detach_and_log_err(cx);
1709 }
1710
1711 fn clear_pending(&mut self) {
1712 self.pending.retain(|v| !v.finished)
1713 }
1714
1715 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1716 self.entries.clear();
1717 let mut changed_entries = Vec::new();
1718 let mut new_entries = Vec::new();
1719 let mut conflict_entries = Vec::new();
1720
1721 let Some(repo) = self.active_repository.as_ref() else {
1722 // Just clear entries if no repository is active.
1723 cx.notify();
1724 return;
1725 };
1726
1727 // First pass - collect all paths
1728 let repo = repo.read(cx);
1729
1730 // Second pass - create entries with proper depth calculation
1731 for entry in repo.status() {
1732 let is_conflict = repo.has_conflict(&entry.repo_path);
1733 let is_new = entry.status.is_created();
1734 let is_staged = entry.status.is_staged();
1735
1736 if self.pending.iter().any(|pending| {
1737 pending.target_status == TargetStatus::Reverted
1738 && !pending.finished
1739 && pending.repo_paths.contains(&entry.repo_path)
1740 }) {
1741 continue;
1742 }
1743
1744 let entry = GitStatusEntry {
1745 repo_path: entry.repo_path.clone(),
1746 status: entry.status,
1747 is_staged,
1748 };
1749
1750 if is_conflict {
1751 conflict_entries.push(entry);
1752 } else if is_new {
1753 new_entries.push(entry);
1754 } else {
1755 changed_entries.push(entry);
1756 }
1757 }
1758
1759 if conflict_entries.len() > 0 {
1760 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1761 header: Section::Conflict,
1762 }));
1763 self.entries.extend(
1764 conflict_entries
1765 .into_iter()
1766 .map(GitListEntry::GitStatusEntry),
1767 );
1768 }
1769
1770 if changed_entries.len() > 0 {
1771 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1772 header: Section::Tracked,
1773 }));
1774 self.entries.extend(
1775 changed_entries
1776 .into_iter()
1777 .map(GitListEntry::GitStatusEntry),
1778 );
1779 }
1780 if new_entries.len() > 0 {
1781 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1782 header: Section::New,
1783 }));
1784 self.entries
1785 .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1786 }
1787
1788 self.update_counts(repo);
1789
1790 self.select_first_entry_if_none(cx);
1791
1792 cx.notify();
1793 }
1794
1795 fn header_state(&self, header_type: Section) -> ToggleState {
1796 let (staged_count, count) = match header_type {
1797 Section::New => (self.new_staged_count, self.new_count),
1798 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1799 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1800 };
1801 if staged_count == 0 {
1802 ToggleState::Unselected
1803 } else if count == staged_count {
1804 ToggleState::Selected
1805 } else {
1806 ToggleState::Indeterminate
1807 }
1808 }
1809
1810 fn update_counts(&mut self, repo: &Repository) {
1811 self.conflicted_count = 0;
1812 self.conflicted_staged_count = 0;
1813 self.new_count = 0;
1814 self.tracked_count = 0;
1815 self.new_staged_count = 0;
1816 self.tracked_staged_count = 0;
1817 for entry in &self.entries {
1818 let Some(status_entry) = entry.status_entry() else {
1819 continue;
1820 };
1821 if repo.has_conflict(&status_entry.repo_path) {
1822 self.conflicted_count += 1;
1823 if self.entry_is_staged(status_entry) != Some(false) {
1824 self.conflicted_staged_count += 1;
1825 }
1826 } else if status_entry.status.is_created() {
1827 self.new_count += 1;
1828 if self.entry_is_staged(status_entry) != Some(false) {
1829 self.new_staged_count += 1;
1830 }
1831 } else {
1832 self.tracked_count += 1;
1833 if self.entry_is_staged(status_entry) != Some(false) {
1834 self.tracked_staged_count += 1;
1835 }
1836 }
1837 }
1838 }
1839
1840 fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1841 for pending in self.pending.iter().rev() {
1842 if pending.repo_paths.contains(&entry.repo_path) {
1843 match pending.target_status {
1844 TargetStatus::Staged => return Some(true),
1845 TargetStatus::Unstaged => return Some(false),
1846 TargetStatus::Reverted => continue,
1847 TargetStatus::Unchanged => continue,
1848 }
1849 }
1850 }
1851 entry.is_staged
1852 }
1853
1854 pub(crate) fn has_staged_changes(&self) -> bool {
1855 self.tracked_staged_count > 0
1856 || self.new_staged_count > 0
1857 || self.conflicted_staged_count > 0
1858 }
1859
1860 pub(crate) fn has_unstaged_changes(&self) -> bool {
1861 self.tracked_count > self.tracked_staged_count
1862 || self.new_count > self.new_staged_count
1863 || self.conflicted_count > self.conflicted_staged_count
1864 }
1865
1866 fn has_conflicts(&self) -> bool {
1867 self.conflicted_count > 0
1868 }
1869
1870 fn has_tracked_changes(&self) -> bool {
1871 self.tracked_count > 0
1872 }
1873
1874 pub fn has_unstaged_conflicts(&self) -> bool {
1875 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1876 }
1877
1878 fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1879 let Some(workspace) = self.workspace.upgrade() else {
1880 return;
1881 };
1882 let notif_id = NotificationId::Named("git-operation-error".into());
1883
1884 let mut message = e.to_string().trim().to_string();
1885 let toast;
1886 if message.matches("Authentication failed").count() >= 1 {
1887 message = format!(
1888 "{}\n\n{}",
1889 message, "Please set your credentials via the CLI"
1890 );
1891 toast = Toast::new(notif_id, message);
1892 } else {
1893 toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1894 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1895 });
1896 }
1897 workspace.update(cx, |workspace, cx| {
1898 workspace.show_toast(toast, cx);
1899 });
1900 }
1901
1902 fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
1903 let Some(workspace) = self.workspace.upgrade() else {
1904 return;
1905 };
1906
1907 let notification_id = NotificationId::Named("git-remote-info".into());
1908
1909 workspace.update(cx, |workspace, cx| {
1910 workspace.show_notification(notification_id.clone(), cx, |cx| {
1911 let workspace = cx.weak_entity();
1912 cx.new(|cx| RemoteOutputToast::new(action, info, notification_id, workspace, cx))
1913 });
1914 });
1915 }
1916
1917 pub fn render_spinner(&self) -> Option<impl IntoElement> {
1918 (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1919 Icon::new(IconName::ArrowCircle)
1920 .size(IconSize::XSmall)
1921 .color(Color::Info)
1922 .with_animation(
1923 "arrow-circle",
1924 Animation::new(Duration::from_secs(2)).repeat(),
1925 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1926 )
1927 .into_any_element()
1928 })
1929 }
1930
1931 pub fn can_open_commit_editor(&self) -> bool {
1932 (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1933 }
1934
1935 pub fn can_stage_all(&self) -> bool {
1936 self.has_unstaged_changes()
1937 }
1938
1939 pub fn can_unstage_all(&self) -> bool {
1940 self.has_staged_changes()
1941 }
1942
1943 pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1944 let potential_co_authors = self.potential_co_authors(cx);
1945 if potential_co_authors.is_empty() {
1946 None
1947 } else {
1948 Some(
1949 IconButton::new("co-authors", IconName::Person)
1950 .icon_color(Color::Disabled)
1951 .selected_icon_color(Color::Selected)
1952 .toggle_state(self.add_coauthors)
1953 .tooltip(move |_, cx| {
1954 let title = format!(
1955 "Add co-authored-by:{}{}",
1956 if potential_co_authors.len() == 1 {
1957 ""
1958 } else {
1959 "\n"
1960 },
1961 potential_co_authors
1962 .iter()
1963 .map(|(name, email)| format!(" {} <{}>", name, email))
1964 .join("\n")
1965 );
1966 Tooltip::simple(title, cx)
1967 })
1968 .on_click(cx.listener(|this, _, _, cx| {
1969 this.add_coauthors = !this.add_coauthors;
1970 cx.notify();
1971 }))
1972 .into_any_element(),
1973 )
1974 }
1975 }
1976
1977 pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
1978 if self.has_unstaged_conflicts() {
1979 (false, "You must resolve conflicts before committing")
1980 } else if !self.has_staged_changes() && !self.has_tracked_changes() {
1981 (
1982 false,
1983 "You must have either staged changes or tracked files to commit",
1984 )
1985 } else if self.pending_commit.is_some() {
1986 (false, "Commit in progress")
1987 } else if self.custom_or_suggested_commit_message(cx).is_none() {
1988 (false, "No commit message")
1989 } else if !self.has_write_access(cx) {
1990 (false, "You do not have write access to this project")
1991 } else {
1992 (true, self.commit_button_title())
1993 }
1994 }
1995
1996 pub fn commit_button_title(&self) -> &'static str {
1997 if self.has_staged_changes() {
1998 "Commit"
1999 } else {
2000 "Commit Tracked"
2001 }
2002 }
2003
2004 pub fn render_footer(
2005 &self,
2006 window: &mut Window,
2007 cx: &mut Context<Self>,
2008 ) -> Option<impl IntoElement> {
2009 let active_repository = self.active_repository.clone()?;
2010 let can_open_commit_editor = self.can_open_commit_editor();
2011 let (can_commit, tooltip) = self.configure_commit_button(cx);
2012 let project = self.project.clone().read(cx);
2013 let panel_editor_style = panel_editor_style(true, window, cx);
2014
2015 let enable_coauthors = self.render_co_authors(cx);
2016
2017 let title = self.commit_button_title();
2018 let editor_focus_handle = self.commit_editor.focus_handle(cx);
2019
2020 let branch = active_repository.read(cx).current_branch().cloned();
2021
2022 let footer_size = px(32.);
2023 let gap = px(8.0);
2024
2025 let max_height = window.line_height() * 5. + gap + footer_size;
2026
2027 let expand_button_size = px(16.);
2028
2029 let git_panel = cx.entity().clone();
2030 let display_name = SharedString::from(Arc::from(
2031 active_repository
2032 .read(cx)
2033 .display_name(project, cx)
2034 .trim_end_matches("/"),
2035 ));
2036 let branches = branch_picker::popover(self.project.clone(), window, cx);
2037 let footer = v_flex()
2038 .child(PanelRepoFooter::new(
2039 "footer-button",
2040 display_name,
2041 branch,
2042 Some(git_panel),
2043 Some(branches),
2044 ))
2045 .child(
2046 panel_editor_container(window, cx)
2047 .id("commit-editor-container")
2048 .relative()
2049 .h(max_height)
2050 // .w_full()
2051 // .border_t_1()
2052 // .border_color(cx.theme().colors().border)
2053 .bg(cx.theme().colors().editor_background)
2054 .cursor_text()
2055 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
2056 window.focus(&this.commit_editor.focus_handle(cx));
2057 }))
2058 .child(
2059 h_flex()
2060 .id("commit-footer")
2061 .absolute()
2062 .bottom_0()
2063 .right_2()
2064 .h(footer_size)
2065 .flex_none()
2066 .children(enable_coauthors)
2067 .child(
2068 panel_filled_button(title)
2069 .tooltip(move |window, cx| {
2070 if can_commit {
2071 Tooltip::for_action_in(
2072 tooltip,
2073 &Commit,
2074 &editor_focus_handle,
2075 window,
2076 cx,
2077 )
2078 } else {
2079 Tooltip::simple(tooltip, cx)
2080 }
2081 })
2082 .disabled(!can_commit || self.modal_open)
2083 .on_click({
2084 cx.listener(move |this, _: &ClickEvent, window, cx| {
2085 this.commit_changes(window, cx)
2086 })
2087 }),
2088 ),
2089 )
2090 // .when(!self.modal_open, |el| {
2091 .child(EditorElement::new(&self.commit_editor, panel_editor_style))
2092 .child(
2093 div()
2094 .absolute()
2095 .top_1()
2096 .right_2()
2097 .opacity(0.5)
2098 .hover(|this| this.opacity(1.0))
2099 .w(expand_button_size)
2100 .child(
2101 panel_icon_button("expand-commit-editor", IconName::Maximize)
2102 .icon_size(IconSize::Small)
2103 .style(ButtonStyle::Transparent)
2104 .width(expand_button_size.into())
2105 .disabled(!can_open_commit_editor)
2106 .on_click(cx.listener({
2107 move |_, _, window, cx| {
2108 window.dispatch_action(
2109 git::ShowCommitEditor.boxed_clone(),
2110 cx,
2111 )
2112 }
2113 })),
2114 ),
2115 ),
2116 );
2117
2118 Some(footer)
2119 }
2120
2121 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2122 let active_repository = self.active_repository.as_ref()?;
2123 let branch = active_repository.read(cx).current_branch()?;
2124 let commit = branch.most_recent_commit.as_ref()?.clone();
2125
2126 let this = cx.entity();
2127 Some(
2128 h_flex()
2129 .items_center()
2130 .py_2()
2131 .px(px(8.))
2132 // .bg(cx.theme().colors().background)
2133 // .border_t_1()
2134 .border_color(cx.theme().colors().border)
2135 .gap_1p5()
2136 .child(
2137 div()
2138 .flex_grow()
2139 .overflow_hidden()
2140 .max_w(relative(0.6))
2141 .h_full()
2142 .child(
2143 Label::new(commit.subject.clone())
2144 .size(LabelSize::Small)
2145 .truncate(),
2146 )
2147 .id("commit-msg-hover")
2148 .hoverable_tooltip(move |window, cx| {
2149 GitPanelMessageTooltip::new(
2150 this.clone(),
2151 commit.sha.clone(),
2152 window,
2153 cx,
2154 )
2155 .into()
2156 }),
2157 )
2158 .child(div().flex_1())
2159 .child(
2160 panel_icon_button("undo", IconName::Undo)
2161 .icon_size(IconSize::Small)
2162 .icon_color(Color::Muted)
2163 .tooltip(Tooltip::for_action_title(
2164 if self.has_staged_changes() {
2165 "git reset HEAD^ --soft"
2166 } else {
2167 "git reset HEAD^"
2168 },
2169 &git::Uncommit,
2170 ))
2171 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2172 ),
2173 )
2174 }
2175
2176 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2177 h_flex()
2178 .h_full()
2179 .flex_grow()
2180 .justify_center()
2181 .items_center()
2182 .child(
2183 v_flex()
2184 .gap_3()
2185 .child(if self.active_repository.is_some() {
2186 "No changes to commit"
2187 } else {
2188 "No Git repositories"
2189 })
2190 .text_ui_sm(cx)
2191 .mx_auto()
2192 .text_color(Color::Placeholder.color(cx)),
2193 )
2194 }
2195
2196 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2197 let scroll_bar_style = self.show_scrollbar(cx);
2198 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2199
2200 if !self.should_show_scrollbar(cx)
2201 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2202 {
2203 return None;
2204 }
2205
2206 Some(
2207 div()
2208 .id("git-panel-vertical-scroll")
2209 .occlude()
2210 .flex_none()
2211 .h_full()
2212 .cursor_default()
2213 .when(show_container, |this| this.pl_1().px_1p5())
2214 .when(!show_container, |this| {
2215 this.absolute().right_1().top_1().bottom_1().w(px(12.))
2216 })
2217 .on_mouse_move(cx.listener(|_, _, _, cx| {
2218 cx.notify();
2219 cx.stop_propagation()
2220 }))
2221 .on_hover(|_, _, cx| {
2222 cx.stop_propagation();
2223 })
2224 .on_any_mouse_down(|_, _, cx| {
2225 cx.stop_propagation();
2226 })
2227 .on_mouse_up(
2228 MouseButton::Left,
2229 cx.listener(|this, _, window, cx| {
2230 if !this.scrollbar_state.is_dragging()
2231 && !this.focus_handle.contains_focused(window, cx)
2232 {
2233 this.hide_scrollbar(window, cx);
2234 cx.notify();
2235 }
2236
2237 cx.stop_propagation();
2238 }),
2239 )
2240 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2241 cx.notify();
2242 }))
2243 .children(Scrollbar::vertical(
2244 // percentage as f32..end_offset as f32,
2245 self.scrollbar_state.clone(),
2246 )),
2247 )
2248 }
2249
2250 fn render_buffer_header_controls(
2251 &self,
2252 entity: &Entity<Self>,
2253 file: &Arc<dyn File>,
2254 _: &Window,
2255 cx: &App,
2256 ) -> Option<AnyElement> {
2257 let repo = self.active_repository.as_ref()?.read(cx);
2258 let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2259 let ix = self.entry_by_path(&repo_path)?;
2260 let entry = self.entries.get(ix)?;
2261
2262 let is_staged = self.entry_is_staged(entry.status_entry()?);
2263
2264 let checkbox = Checkbox::new("stage-file", is_staged.into())
2265 .disabled(!self.has_write_access(cx))
2266 .fill()
2267 .elevation(ElevationIndex::Surface)
2268 .on_click({
2269 let entry = entry.clone();
2270 let git_panel = entity.downgrade();
2271 move |_, window, cx| {
2272 git_panel
2273 .update(cx, |this, cx| {
2274 this.toggle_staged_for_entry(&entry, window, cx);
2275 cx.stop_propagation();
2276 })
2277 .ok();
2278 }
2279 });
2280 Some(
2281 h_flex()
2282 .id("start-slot")
2283 .text_lg()
2284 .child(checkbox)
2285 .on_mouse_down(MouseButton::Left, |_, _, cx| {
2286 // prevent the list item active state triggering when toggling checkbox
2287 cx.stop_propagation();
2288 })
2289 .into_any_element(),
2290 )
2291 }
2292
2293 fn render_entries(
2294 &self,
2295 has_write_access: bool,
2296 _: &Window,
2297 cx: &mut Context<Self>,
2298 ) -> impl IntoElement {
2299 let entry_count = self.entries.len();
2300
2301 h_flex()
2302 .size_full()
2303 .flex_grow()
2304 .overflow_hidden()
2305 .child(
2306 uniform_list(cx.entity().clone(), "entries", entry_count, {
2307 move |this, range, window, cx| {
2308 let mut items = Vec::with_capacity(range.end - range.start);
2309
2310 for ix in range {
2311 match &this.entries.get(ix) {
2312 Some(GitListEntry::GitStatusEntry(entry)) => {
2313 items.push(this.render_entry(
2314 ix,
2315 entry,
2316 has_write_access,
2317 window,
2318 cx,
2319 ));
2320 }
2321 Some(GitListEntry::Header(header)) => {
2322 items.push(this.render_list_header(
2323 ix,
2324 header,
2325 has_write_access,
2326 window,
2327 cx,
2328 ));
2329 }
2330 None => {}
2331 }
2332 }
2333
2334 items
2335 }
2336 })
2337 .size_full()
2338 .with_sizing_behavior(ListSizingBehavior::Auto)
2339 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2340 .track_scroll(self.scroll_handle.clone()),
2341 )
2342 .on_mouse_down(
2343 MouseButton::Right,
2344 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2345 this.deploy_panel_context_menu(event.position, window, cx)
2346 }),
2347 )
2348 .children(self.render_scrollbar(cx))
2349 }
2350
2351 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2352 Label::new(label.into()).color(color).single_line()
2353 }
2354
2355 fn list_item_height(&self) -> Rems {
2356 rems(1.75)
2357 }
2358
2359 fn render_list_header(
2360 &self,
2361 ix: usize,
2362 header: &GitHeaderEntry,
2363 _: bool,
2364 _: &Window,
2365 _: &Context<Self>,
2366 ) -> AnyElement {
2367 let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
2368
2369 h_flex()
2370 .id(id)
2371 .h(self.list_item_height())
2372 .w_full()
2373 .items_end()
2374 .px(rems(0.75)) // ~12px
2375 .pb(rems(0.3125)) // ~ 5px
2376 .child(
2377 Label::new(header.title())
2378 .color(Color::Muted)
2379 .size(LabelSize::Small)
2380 .line_height_style(LineHeightStyle::UiLabel)
2381 .single_line(),
2382 )
2383 .into_any_element()
2384 }
2385
2386 fn load_commit_details(
2387 &self,
2388 sha: &str,
2389 cx: &mut Context<Self>,
2390 ) -> Task<Result<CommitDetails>> {
2391 let Some(repo) = self.active_repository.clone() else {
2392 return Task::ready(Err(anyhow::anyhow!("no active repo")));
2393 };
2394 repo.update(cx, |repo, cx| {
2395 let show = repo.show(sha);
2396 cx.spawn(|_, _| async move { show.await? })
2397 })
2398 }
2399
2400 fn deploy_entry_context_menu(
2401 &mut self,
2402 position: Point<Pixels>,
2403 ix: usize,
2404 window: &mut Window,
2405 cx: &mut Context<Self>,
2406 ) {
2407 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2408 return;
2409 };
2410 let stage_title = if entry.status.is_staged() == Some(true) {
2411 "Unstage File"
2412 } else {
2413 "Stage File"
2414 };
2415 let restore_title = if entry.status.is_created() {
2416 "Trash File"
2417 } else {
2418 "Restore File"
2419 };
2420 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2421 context_menu
2422 .action(stage_title, ToggleStaged.boxed_clone())
2423 .action(restore_title, git::RestoreFile.boxed_clone())
2424 .separator()
2425 .action("Open Diff", Confirm.boxed_clone())
2426 .action("Open File", SecondaryConfirm.boxed_clone())
2427 });
2428 self.selected_entry = Some(ix);
2429 self.set_context_menu(context_menu, position, window, cx);
2430 }
2431
2432 fn deploy_panel_context_menu(
2433 &mut self,
2434 position: Point<Pixels>,
2435 window: &mut Window,
2436 cx: &mut Context<Self>,
2437 ) {
2438 let context_menu = git_panel_context_menu(window, cx);
2439 self.set_context_menu(context_menu, position, window, cx);
2440 }
2441
2442 fn set_context_menu(
2443 &mut self,
2444 context_menu: Entity<ContextMenu>,
2445 position: Point<Pixels>,
2446 window: &Window,
2447 cx: &mut Context<Self>,
2448 ) {
2449 let subscription = cx.subscribe_in(
2450 &context_menu,
2451 window,
2452 |this, _, _: &DismissEvent, window, cx| {
2453 if this.context_menu.as_ref().is_some_and(|context_menu| {
2454 context_menu.0.focus_handle(cx).contains_focused(window, cx)
2455 }) {
2456 cx.focus_self(window);
2457 }
2458 this.context_menu.take();
2459 cx.notify();
2460 },
2461 );
2462 self.context_menu = Some((context_menu, position, subscription));
2463 cx.notify();
2464 }
2465
2466 fn render_entry(
2467 &self,
2468 ix: usize,
2469 entry: &GitStatusEntry,
2470 has_write_access: bool,
2471 window: &Window,
2472 cx: &Context<Self>,
2473 ) -> AnyElement {
2474 let display_name = entry
2475 .repo_path
2476 .file_name()
2477 .map(|name| name.to_string_lossy().into_owned())
2478 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2479
2480 let repo_path = entry.repo_path.clone();
2481 let selected = self.selected_entry == Some(ix);
2482 let marked = self.marked_entries.contains(&ix);
2483 let status_style = GitPanelSettings::get_global(cx).status_style;
2484 let status = entry.status;
2485 let has_conflict = status.is_conflicted();
2486 let is_modified = status.is_modified();
2487 let is_deleted = status.is_deleted();
2488
2489 let label_color = if status_style == StatusStyle::LabelColor {
2490 if has_conflict {
2491 Color::Conflict
2492 } else if is_modified {
2493 Color::Modified
2494 } else if is_deleted {
2495 // We don't want a bunch of red labels in the list
2496 Color::Disabled
2497 } else {
2498 Color::Created
2499 }
2500 } else {
2501 Color::Default
2502 };
2503
2504 let path_color = if status.is_deleted() {
2505 Color::Disabled
2506 } else {
2507 Color::Muted
2508 };
2509
2510 let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
2511 let checkbox_wrapper_id: ElementId =
2512 ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
2513 let checkbox_id: ElementId =
2514 ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
2515
2516 let is_entry_staged = self.entry_is_staged(entry);
2517 let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2518
2519 if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2520 is_staged = ToggleState::Selected;
2521 }
2522
2523 let handle = cx.weak_entity();
2524
2525 let selected_bg_alpha = 0.08;
2526 let marked_bg_alpha = 0.12;
2527 let state_opacity_step = 0.04;
2528
2529 let base_bg = match (selected, marked) {
2530 (true, true) => cx
2531 .theme()
2532 .status()
2533 .info
2534 .alpha(selected_bg_alpha + marked_bg_alpha),
2535 (true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
2536 (false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
2537 _ => cx.theme().colors().ghost_element_background,
2538 };
2539
2540 let hover_bg = if selected {
2541 cx.theme()
2542 .status()
2543 .info
2544 .alpha(selected_bg_alpha + state_opacity_step)
2545 } else {
2546 cx.theme().colors().ghost_element_hover
2547 };
2548
2549 let active_bg = if selected {
2550 cx.theme()
2551 .status()
2552 .info
2553 .alpha(selected_bg_alpha + state_opacity_step * 2.0)
2554 } else {
2555 cx.theme().colors().ghost_element_active
2556 };
2557
2558 h_flex()
2559 .id(id)
2560 .h(self.list_item_height())
2561 .w_full()
2562 .items_center()
2563 .border_1()
2564 .when(selected && self.focus_handle.is_focused(window), |el| {
2565 el.border_color(cx.theme().colors().border_focused)
2566 })
2567 .px(rems(0.75)) // ~12px
2568 .overflow_hidden()
2569 .flex_none()
2570 .gap(DynamicSpacing::Base04.rems(cx))
2571 .bg(base_bg)
2572 .hover(|this| this.bg(hover_bg))
2573 .active(|this| this.bg(active_bg))
2574 .on_click({
2575 cx.listener(move |this, event: &ClickEvent, window, cx| {
2576 this.selected_entry = Some(ix);
2577 cx.notify();
2578 if event.modifiers().secondary() {
2579 this.open_file(&Default::default(), window, cx)
2580 } else {
2581 this.open_diff(&Default::default(), window, cx);
2582 this.focus_handle.focus(window);
2583 }
2584 })
2585 })
2586 .on_mouse_down(
2587 MouseButton::Right,
2588 move |event: &MouseDownEvent, window, cx| {
2589 // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
2590 if event.button != MouseButton::Right {
2591 return;
2592 }
2593
2594 let Some(this) = handle.upgrade() else {
2595 return;
2596 };
2597 this.update(cx, |this, cx| {
2598 this.deploy_entry_context_menu(event.position, ix, window, cx);
2599 });
2600 cx.stop_propagation();
2601 },
2602 )
2603 // .on_secondary_mouse_down(cx.listener(
2604 // move |this, event: &MouseDownEvent, window, cx| {
2605 // this.deploy_entry_context_menu(event.position, ix, window, cx);
2606 // cx.stop_propagation();
2607 // },
2608 // ))
2609 .child(
2610 div()
2611 .id(checkbox_wrapper_id)
2612 .flex_none()
2613 .occlude()
2614 .cursor_pointer()
2615 .child(
2616 Checkbox::new(checkbox_id, is_staged)
2617 .disabled(!has_write_access)
2618 .fill()
2619 .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2620 .elevation(ElevationIndex::Surface)
2621 .on_click({
2622 let entry = entry.clone();
2623 cx.listener(move |this, _, window, cx| {
2624 if !has_write_access {
2625 return;
2626 }
2627 this.toggle_staged_for_entry(
2628 &GitListEntry::GitStatusEntry(entry.clone()),
2629 window,
2630 cx,
2631 );
2632 cx.stop_propagation();
2633 })
2634 })
2635 .tooltip(move |window, cx| {
2636 let tooltip_name = if is_entry_staged.unwrap_or(false) {
2637 "Unstage"
2638 } else {
2639 "Stage"
2640 };
2641
2642 Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
2643 }),
2644 ),
2645 )
2646 .child(git_status_icon(status, cx))
2647 .child(
2648 h_flex()
2649 .items_center()
2650 .overflow_hidden()
2651 .when_some(repo_path.parent(), |this, parent| {
2652 let parent_str = parent.to_string_lossy();
2653 if !parent_str.is_empty() {
2654 this.child(
2655 self.entry_label(format!("{}/", parent_str), path_color)
2656 .when(status.is_deleted(), |this| this.strikethrough()),
2657 )
2658 } else {
2659 this
2660 }
2661 })
2662 .child(
2663 self.entry_label(display_name.clone(), label_color)
2664 .when(status.is_deleted(), |this| this.strikethrough()),
2665 ),
2666 )
2667 .into_any_element()
2668 }
2669
2670 fn has_write_access(&self, cx: &App) -> bool {
2671 !self.project.read(cx).is_read_only(cx)
2672 }
2673}
2674
2675impl Render for GitPanel {
2676 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2677 let project = self.project.read(cx);
2678 let has_entries = self.entries.len() > 0;
2679 let room = self
2680 .workspace
2681 .upgrade()
2682 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2683
2684 let has_write_access = self.has_write_access(cx);
2685
2686 let has_co_authors = room.map_or(false, |room| {
2687 room.read(cx)
2688 .remote_participants()
2689 .values()
2690 .any(|remote_participant| remote_participant.can_write())
2691 });
2692
2693 v_flex()
2694 .id("git_panel")
2695 .key_context(self.dispatch_context(window, cx))
2696 .track_focus(&self.focus_handle)
2697 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2698 .when(has_write_access && !project.is_read_only(cx), |this| {
2699 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2700 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2701 }))
2702 .on_action(cx.listener(GitPanel::commit))
2703 })
2704 .on_action(cx.listener(Self::select_first))
2705 .on_action(cx.listener(Self::select_next))
2706 .on_action(cx.listener(Self::select_previous))
2707 .on_action(cx.listener(Self::select_last))
2708 .on_action(cx.listener(Self::close_panel))
2709 .on_action(cx.listener(Self::open_diff))
2710 .on_action(cx.listener(Self::open_file))
2711 .on_action(cx.listener(Self::revert_selected))
2712 .on_action(cx.listener(Self::focus_changes_list))
2713 .on_action(cx.listener(Self::focus_editor))
2714 .on_action(cx.listener(Self::toggle_staged_for_selected))
2715 .on_action(cx.listener(Self::stage_all))
2716 .on_action(cx.listener(Self::unstage_all))
2717 .on_action(cx.listener(Self::restore_tracked_files))
2718 .on_action(cx.listener(Self::clean_all))
2719 .on_action(cx.listener(Self::fetch))
2720 .on_action(cx.listener(Self::pull))
2721 .on_action(cx.listener(Self::push))
2722 .when(has_write_access && has_co_authors, |git_panel| {
2723 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2724 })
2725 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2726 .on_hover(cx.listener(|this, hovered, window, cx| {
2727 if *hovered {
2728 this.show_scrollbar = true;
2729 this.hide_scrollbar_task.take();
2730 cx.notify();
2731 } else if !this.focus_handle.contains_focused(window, cx) {
2732 this.hide_scrollbar(window, cx);
2733 }
2734 }))
2735 .size_full()
2736 .overflow_hidden()
2737 .bg(ElevationIndex::Surface.bg(cx))
2738 .child(
2739 v_flex()
2740 .size_full()
2741 .map(|this| {
2742 if has_entries {
2743 this.child(self.render_entries(has_write_access, window, cx))
2744 } else {
2745 this.child(self.render_empty_state(cx).into_any_element())
2746 }
2747 })
2748 .children(self.render_footer(window, cx))
2749 .children(self.render_previous_commit(cx))
2750 .into_any_element(),
2751 )
2752 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2753 deferred(
2754 anchored()
2755 .position(*position)
2756 .anchor(gpui::Corner::TopLeft)
2757 .child(menu.clone()),
2758 )
2759 .with_priority(1)
2760 }))
2761 }
2762}
2763
2764impl Focusable for GitPanel {
2765 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2766 self.focus_handle.clone()
2767 }
2768}
2769
2770impl EventEmitter<Event> for GitPanel {}
2771
2772impl EventEmitter<PanelEvent> for GitPanel {}
2773
2774pub(crate) struct GitPanelAddon {
2775 pub(crate) workspace: WeakEntity<Workspace>,
2776}
2777
2778impl editor::Addon for GitPanelAddon {
2779 fn to_any(&self) -> &dyn std::any::Any {
2780 self
2781 }
2782
2783 fn render_buffer_header_controls(
2784 &self,
2785 excerpt_info: &ExcerptInfo,
2786 window: &Window,
2787 cx: &App,
2788 ) -> Option<AnyElement> {
2789 let file = excerpt_info.buffer.file()?;
2790 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2791
2792 git_panel
2793 .read(cx)
2794 .render_buffer_header_controls(&git_panel, &file, window, cx)
2795 }
2796}
2797
2798impl Panel for GitPanel {
2799 fn persistent_name() -> &'static str {
2800 "GitPanel"
2801 }
2802
2803 fn position(&self, _: &Window, cx: &App) -> DockPosition {
2804 GitPanelSettings::get_global(cx).dock
2805 }
2806
2807 fn position_is_valid(&self, position: DockPosition) -> bool {
2808 matches!(position, DockPosition::Left | DockPosition::Right)
2809 }
2810
2811 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2812 settings::update_settings_file::<GitPanelSettings>(
2813 self.fs.clone(),
2814 cx,
2815 move |settings, _| settings.dock = Some(position),
2816 );
2817 }
2818
2819 fn size(&self, _: &Window, cx: &App) -> Pixels {
2820 self.width
2821 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2822 }
2823
2824 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2825 self.width = size;
2826 self.serialize(cx);
2827 cx.notify();
2828 }
2829
2830 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2831 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2832 }
2833
2834 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2835 Some("Git Panel")
2836 }
2837
2838 fn toggle_action(&self) -> Box<dyn Action> {
2839 Box::new(ToggleFocus)
2840 }
2841
2842 fn activation_priority(&self) -> u32 {
2843 2
2844 }
2845}
2846
2847impl PanelHeader for GitPanel {}
2848
2849struct GitPanelMessageTooltip {
2850 commit_tooltip: Option<Entity<CommitTooltip>>,
2851}
2852
2853impl GitPanelMessageTooltip {
2854 fn new(
2855 git_panel: Entity<GitPanel>,
2856 sha: SharedString,
2857 window: &mut Window,
2858 cx: &mut App,
2859 ) -> Entity<Self> {
2860 cx.new(|cx| {
2861 cx.spawn_in(window, |this, mut cx| async move {
2862 let details = git_panel
2863 .update(&mut cx, |git_panel, cx| {
2864 git_panel.load_commit_details(&sha, cx)
2865 })?
2866 .await?;
2867
2868 let commit_details = editor::commit_tooltip::CommitDetails {
2869 sha: details.sha.clone(),
2870 committer_name: details.committer_name.clone(),
2871 committer_email: details.committer_email.clone(),
2872 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2873 message: Some(editor::commit_tooltip::ParsedCommitMessage {
2874 message: details.message.clone(),
2875 ..Default::default()
2876 }),
2877 };
2878
2879 this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2880 this.commit_tooltip =
2881 Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2882 cx.notify();
2883 })
2884 })
2885 .detach();
2886
2887 Self {
2888 commit_tooltip: None,
2889 }
2890 })
2891 }
2892}
2893
2894impl Render for GitPanelMessageTooltip {
2895 fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2896 if let Some(commit_tooltip) = &self.commit_tooltip {
2897 commit_tooltip.clone().into_any_element()
2898 } else {
2899 gpui::Empty.into_any_element()
2900 }
2901 }
2902}
2903
2904fn git_action_tooltip(
2905 label: impl Into<SharedString>,
2906 action: &dyn Action,
2907 command: impl Into<SharedString>,
2908 focus_handle: Option<FocusHandle>,
2909 window: &mut Window,
2910 cx: &mut App,
2911) -> AnyView {
2912 let label = label.into();
2913 let command = command.into();
2914
2915 if let Some(handle) = focus_handle {
2916 Tooltip::with_meta_in(
2917 label.clone(),
2918 Some(action),
2919 command.clone(),
2920 &handle,
2921 window,
2922 cx,
2923 )
2924 } else {
2925 Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
2926 }
2927}
2928
2929#[derive(IntoElement)]
2930struct SplitButton {
2931 pub left: ButtonLike,
2932 pub right: AnyElement,
2933}
2934
2935impl SplitButton {
2936 fn new(
2937 id: impl Into<SharedString>,
2938 left_label: impl Into<SharedString>,
2939 ahead_count: usize,
2940 behind_count: usize,
2941 left_icon: Option<IconName>,
2942 left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
2943 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
2944 ) -> Self {
2945 let id = id.into();
2946
2947 fn count(count: usize) -> impl IntoElement {
2948 h_flex()
2949 .ml_neg_px()
2950 .h(rems(0.875))
2951 .items_center()
2952 .overflow_hidden()
2953 .px_0p5()
2954 .child(
2955 Label::new(count.to_string())
2956 .size(LabelSize::XSmall)
2957 .line_height_style(LineHeightStyle::UiLabel),
2958 )
2959 }
2960
2961 let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
2962
2963 let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
2964 format!("split-button-left-{}", id).into(),
2965 ))
2966 .layer(ui::ElevationIndex::ModalSurface)
2967 .size(ui::ButtonSize::Compact)
2968 .when(should_render_counts, |this| {
2969 this.child(
2970 h_flex()
2971 .ml_neg_0p5()
2972 .mr_1()
2973 .when(behind_count > 0, |this| {
2974 this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
2975 .child(count(behind_count))
2976 })
2977 .when(ahead_count > 0, |this| {
2978 this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
2979 .child(count(ahead_count))
2980 }),
2981 )
2982 })
2983 .when_some(left_icon, |this, left_icon| {
2984 this.child(
2985 h_flex()
2986 .ml_neg_0p5()
2987 .mr_1()
2988 .child(Icon::new(left_icon).size(IconSize::XSmall)),
2989 )
2990 })
2991 .child(
2992 div()
2993 .child(Label::new(left_label).size(LabelSize::Small))
2994 .mr_0p5(),
2995 )
2996 .on_click(left_on_click)
2997 .tooltip(tooltip);
2998
2999 let right =
3000 render_git_action_menu(ElementId::Name(format!("split-button-right-{}", id).into()))
3001 .into_any_element();
3002 // .on_click(right_on_click);
3003
3004 Self { left, right }
3005 }
3006}
3007
3008impl RenderOnce for SplitButton {
3009 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
3010 h_flex()
3011 .rounded_md()
3012 .border_1()
3013 .border_color(cx.theme().colors().text_muted.alpha(0.12))
3014 .child(self.left)
3015 .child(
3016 div()
3017 .h_full()
3018 .w_px()
3019 .bg(cx.theme().colors().text_muted.alpha(0.16)),
3020 )
3021 .child(self.right)
3022 .bg(ElevationIndex::Surface.on_elevation_bg(cx))
3023 .shadow(smallvec![BoxShadow {
3024 color: hsla(0.0, 0.0, 0.0, 0.16),
3025 offset: point(px(0.), px(1.)),
3026 blur_radius: px(0.),
3027 spread_radius: px(0.),
3028 }])
3029 }
3030}
3031
3032fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
3033 PopoverMenu::new(id.into())
3034 .trigger(
3035 ui::ButtonLike::new_rounded_right("split-button-right")
3036 .layer(ui::ElevationIndex::ModalSurface)
3037 .size(ui::ButtonSize::None)
3038 .child(
3039 div()
3040 .px_1()
3041 .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
3042 ),
3043 )
3044 .menu(move |window, cx| {
3045 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
3046 context_menu
3047 .action("Fetch", git::Fetch.boxed_clone())
3048 .action("Pull", git::Pull.boxed_clone())
3049 .separator()
3050 .action("Push", git::Push { options: None }.boxed_clone())
3051 .action(
3052 "Force Push",
3053 git::Push {
3054 options: Some(PushOptions::Force),
3055 }
3056 .boxed_clone(),
3057 )
3058 }))
3059 })
3060 .anchor(Corner::TopRight)
3061}
3062
3063#[derive(IntoElement, IntoComponent)]
3064#[component(scope = "git_panel")]
3065pub struct PanelRepoFooter {
3066 id: SharedString,
3067 active_repository: SharedString,
3068 branch: Option<Branch>,
3069 // Getting a GitPanel in previews will be difficult.
3070 //
3071 // For now just take an option here, and we won't bind handlers to buttons in previews.
3072 git_panel: Option<Entity<GitPanel>>,
3073 branches: Option<Entity<BranchList>>,
3074}
3075
3076impl PanelRepoFooter {
3077 pub fn new(
3078 id: impl Into<SharedString>,
3079 active_repository: SharedString,
3080 branch: Option<Branch>,
3081 git_panel: Option<Entity<GitPanel>>,
3082 branches: Option<Entity<BranchList>>,
3083 ) -> Self {
3084 Self {
3085 id: id.into(),
3086 active_repository,
3087 branch,
3088 git_panel,
3089 branches,
3090 }
3091 }
3092
3093 pub fn new_preview(
3094 id: impl Into<SharedString>,
3095 active_repository: SharedString,
3096 branch: Option<Branch>,
3097 ) -> Self {
3098 Self {
3099 id: id.into(),
3100 active_repository,
3101 branch,
3102 git_panel: None,
3103 branches: None,
3104 }
3105 }
3106
3107 fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
3108 PopoverMenu::new(id.into())
3109 .trigger(
3110 IconButton::new("overflow-menu-trigger", IconName::EllipsisVertical)
3111 .icon_size(IconSize::Small)
3112 .icon_color(Color::Muted),
3113 )
3114 .menu(move |window, cx| Some(git_panel_context_menu(window, cx)))
3115 .anchor(Corner::TopRight)
3116 }
3117
3118 fn panel_focus_handle(&self, cx: &App) -> Option<FocusHandle> {
3119 if let Some(git_panel) = self.git_panel.clone() {
3120 Some(git_panel.focus_handle(cx))
3121 } else {
3122 None
3123 }
3124 }
3125
3126 fn render_push_button(&self, id: SharedString, ahead: u32, cx: &mut App) -> SplitButton {
3127 let panel = self.git_panel.clone();
3128 let panel_focus_handle = self.panel_focus_handle(cx);
3129
3130 SplitButton::new(
3131 id,
3132 "Push",
3133 ahead as usize,
3134 0,
3135 None,
3136 move |_, window, cx| {
3137 if let Some(panel) = panel.as_ref() {
3138 panel.update(cx, |panel, cx| {
3139 panel.push(&git::Push { options: None }, window, cx);
3140 });
3141 }
3142 },
3143 move |window, cx| {
3144 git_action_tooltip(
3145 "Push committed changes to remote",
3146 &git::Push { options: None },
3147 "git push",
3148 panel_focus_handle.clone(),
3149 window,
3150 cx,
3151 )
3152 },
3153 )
3154 }
3155
3156 fn render_pull_button(
3157 &self,
3158 id: SharedString,
3159 ahead: u32,
3160 behind: u32,
3161 cx: &mut App,
3162 ) -> SplitButton {
3163 let panel = self.git_panel.clone();
3164 let panel_focus_handle = self.panel_focus_handle(cx);
3165
3166 SplitButton::new(
3167 id,
3168 "Pull",
3169 ahead as usize,
3170 behind as usize,
3171 None,
3172 move |_, window, cx| {
3173 if let Some(panel) = panel.as_ref() {
3174 panel.update(cx, |panel, cx| {
3175 panel.pull(&git::Pull, window, cx);
3176 });
3177 }
3178 },
3179 move |window, cx| {
3180 git_action_tooltip(
3181 "Pull",
3182 &git::Pull,
3183 "git pull",
3184 panel_focus_handle.clone(),
3185 window,
3186 cx,
3187 )
3188 },
3189 )
3190 }
3191
3192 fn render_fetch_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
3193 let panel = self.git_panel.clone();
3194 let panel_focus_handle = self.panel_focus_handle(cx);
3195
3196 SplitButton::new(
3197 id,
3198 "Fetch",
3199 0,
3200 0,
3201 Some(IconName::ArrowCircle),
3202 move |_, window, cx| {
3203 if let Some(panel) = panel.as_ref() {
3204 panel.update(cx, |panel, cx| {
3205 panel.fetch(&git::Fetch, window, cx);
3206 });
3207 }
3208 },
3209 move |window, cx| {
3210 git_action_tooltip(
3211 "Fetch updates from remote",
3212 &git::Fetch,
3213 "git fetch",
3214 panel_focus_handle.clone(),
3215 window,
3216 cx,
3217 )
3218 },
3219 )
3220 }
3221
3222 fn render_publish_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
3223 let panel = self.git_panel.clone();
3224 let panel_focus_handle = self.panel_focus_handle(cx);
3225
3226 SplitButton::new(
3227 id,
3228 "Publish",
3229 0,
3230 0,
3231 Some(IconName::ArrowUpFromLine),
3232 move |_, window, cx| {
3233 if let Some(panel) = panel.as_ref() {
3234 panel.update(cx, |panel, cx| {
3235 panel.push(
3236 &git::Push {
3237 options: Some(PushOptions::SetUpstream),
3238 },
3239 window,
3240 cx,
3241 );
3242 });
3243 }
3244 },
3245 move |window, cx| {
3246 git_action_tooltip(
3247 "Publish branch to remote",
3248 &git::Push {
3249 options: Some(PushOptions::SetUpstream),
3250 },
3251 "git push --set-upstream",
3252 panel_focus_handle.clone(),
3253 window,
3254 cx,
3255 )
3256 },
3257 )
3258 }
3259
3260 fn render_republish_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
3261 let panel = self.git_panel.clone();
3262 let panel_focus_handle = self.panel_focus_handle(cx);
3263
3264 SplitButton::new(
3265 id,
3266 "Republish",
3267 0,
3268 0,
3269 Some(IconName::ArrowUpFromLine),
3270 move |_, window, cx| {
3271 if let Some(panel) = panel.as_ref() {
3272 panel.update(cx, |panel, cx| {
3273 panel.push(
3274 &git::Push {
3275 options: Some(PushOptions::SetUpstream),
3276 },
3277 window,
3278 cx,
3279 );
3280 });
3281 }
3282 },
3283 move |window, cx| {
3284 git_action_tooltip(
3285 "Re-publish branch to remote",
3286 &git::Push {
3287 options: Some(PushOptions::SetUpstream),
3288 },
3289 "git push --set-upstream",
3290 panel_focus_handle.clone(),
3291 window,
3292 cx,
3293 )
3294 },
3295 )
3296 }
3297
3298 fn render_relevant_button(
3299 &self,
3300 id: impl Into<SharedString>,
3301 branch: &Branch,
3302 cx: &mut App,
3303 ) -> impl IntoElement {
3304 let id = id.into();
3305 let upstream = branch.upstream.as_ref();
3306 match upstream {
3307 Some(Upstream {
3308 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
3309 ..
3310 }) => match (*ahead, *behind) {
3311 (0, 0) => self.render_fetch_button(id, cx),
3312 (ahead, 0) => self.render_push_button(id, ahead, cx),
3313 (ahead, behind) => self.render_pull_button(id, ahead, behind, cx),
3314 },
3315 Some(Upstream {
3316 tracking: UpstreamTracking::Gone,
3317 ..
3318 }) => self.render_republish_button(id, cx),
3319 None => self.render_publish_button(id, cx),
3320 }
3321 }
3322}
3323
3324impl RenderOnce for PanelRepoFooter {
3325 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
3326 let active_repo = self.active_repository.clone();
3327 let overflow_menu_id: SharedString = format!("overflow-menu-{}", active_repo).into();
3328 let repo_selector_trigger = Button::new("repo-selector", active_repo)
3329 .style(ButtonStyle::Transparent)
3330 .size(ButtonSize::None)
3331 .label_size(LabelSize::Small)
3332 .color(Color::Muted);
3333
3334 let repo_selector = if let Some(panel) = self.git_panel.clone() {
3335 let repo_selector = panel.read(cx).repository_selector.clone();
3336 let repo_count = repo_selector.read(cx).repositories_len(cx);
3337 let single_repo = repo_count == 1;
3338
3339 RepositorySelectorPopoverMenu::new(
3340 panel.read(cx).repository_selector.clone(),
3341 repo_selector_trigger.disabled(single_repo).truncate(true),
3342 Tooltip::text("Switch active repository"),
3343 )
3344 .into_any_element()
3345 } else {
3346 // for rendering preview, we don't have git_panel there
3347 repo_selector_trigger.into_any_element()
3348 };
3349
3350 let branch = self.branch.clone();
3351 let branch_name = branch
3352 .as_ref()
3353 .map_or(" (no branch)".into(), |branch| branch.name.clone());
3354
3355 let branches = self.branches.clone();
3356
3357 let branch_selector_button = Button::new("branch-selector", branch_name)
3358 .style(ButtonStyle::Transparent)
3359 .size(ButtonSize::None)
3360 .label_size(LabelSize::Small)
3361 .truncate(true)
3362 .tooltip(Tooltip::for_action_title(
3363 "Switch Branch",
3364 &zed_actions::git::Branch,
3365 ))
3366 .on_click(|_, window, cx| {
3367 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
3368 });
3369
3370 let branch_selector = if let Some(branches) = branches {
3371 PopoverButton::new(
3372 branches,
3373 Corner::BottomLeft,
3374 branch_selector_button,
3375 Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
3376 )
3377 .render(window, cx)
3378 .into_any_element()
3379 } else {
3380 branch_selector_button.into_any_element()
3381 };
3382
3383 let spinner = self
3384 .git_panel
3385 .as_ref()
3386 .and_then(|git_panel| git_panel.read(cx).render_spinner());
3387
3388 h_flex()
3389 .w_full()
3390 .px_2()
3391 .h(px(36.))
3392 .items_center()
3393 .justify_between()
3394 .child(
3395 h_flex()
3396 .flex_1()
3397 .overflow_hidden()
3398 .items_center()
3399 .child(
3400 div().child(
3401 Icon::new(IconName::GitBranchSmall)
3402 .size(IconSize::Small)
3403 .color(Color::Muted),
3404 ),
3405 )
3406 .child(repo_selector)
3407 .when_some(branch.clone(), |this, _| {
3408 this.child(
3409 div()
3410 .text_color(cx.theme().colors().text_muted)
3411 .text_sm()
3412 .child("/"),
3413 )
3414 })
3415 .child(branch_selector),
3416 )
3417 .child(
3418 h_flex()
3419 .gap_1()
3420 .flex_shrink_0()
3421 .children(spinner)
3422 .child(self.render_overflow_menu(overflow_menu_id))
3423 .when_some(branch, |this, branch| {
3424 let button = self.render_relevant_button(self.id.clone(), &branch, cx);
3425 this.child(button)
3426 }),
3427 )
3428 }
3429}
3430
3431impl ComponentPreview for PanelRepoFooter {
3432 fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
3433 let unknown_upstream = None;
3434 let no_remote_upstream = Some(UpstreamTracking::Gone);
3435 let ahead_of_upstream = Some(
3436 UpstreamTrackingStatus {
3437 ahead: 2,
3438 behind: 0,
3439 }
3440 .into(),
3441 );
3442 let behind_upstream = Some(
3443 UpstreamTrackingStatus {
3444 ahead: 0,
3445 behind: 2,
3446 }
3447 .into(),
3448 );
3449 let ahead_and_behind_upstream = Some(
3450 UpstreamTrackingStatus {
3451 ahead: 3,
3452 behind: 1,
3453 }
3454 .into(),
3455 );
3456
3457 let not_ahead_or_behind_upstream = Some(
3458 UpstreamTrackingStatus {
3459 ahead: 0,
3460 behind: 0,
3461 }
3462 .into(),
3463 );
3464
3465 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
3466 Branch {
3467 is_head: true,
3468 name: "some-branch".into(),
3469 upstream: upstream.map(|tracking| Upstream {
3470 ref_name: "origin/some-branch".into(),
3471 tracking,
3472 }),
3473 most_recent_commit: Some(CommitSummary {
3474 sha: "abc123".into(),
3475 subject: "Modify stuff".into(),
3476 commit_timestamp: 1710932954,
3477 }),
3478 }
3479 }
3480
3481 fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
3482 Branch {
3483 is_head: true,
3484 name: branch_name.to_string().into(),
3485 upstream: upstream.map(|tracking| Upstream {
3486 ref_name: format!("zed/{}", branch_name).into(),
3487 tracking,
3488 }),
3489 most_recent_commit: Some(CommitSummary {
3490 sha: "abc123".into(),
3491 subject: "Modify stuff".into(),
3492 commit_timestamp: 1710932954,
3493 }),
3494 }
3495 }
3496
3497 fn active_repository(id: usize) -> SharedString {
3498 format!("repo-{}", id).into()
3499 }
3500
3501 let example_width = px(340.);
3502
3503 v_flex()
3504 .gap_6()
3505 .w_full()
3506 .flex_none()
3507 .children(vec![example_group_with_title(
3508 "Action Button States",
3509 vec![
3510 single_example(
3511 "No Branch",
3512 div()
3513 .w(example_width)
3514 .overflow_hidden()
3515 .child(PanelRepoFooter::new_preview(
3516 "no-branch",
3517 active_repository(1).clone(),
3518 None,
3519 ))
3520 .into_any_element(),
3521 )
3522 .grow(),
3523 single_example(
3524 "Remote status unknown",
3525 div()
3526 .w(example_width)
3527 .overflow_hidden()
3528 .child(PanelRepoFooter::new_preview(
3529 "unknown-upstream",
3530 active_repository(2).clone(),
3531 Some(branch(unknown_upstream)),
3532 ))
3533 .into_any_element(),
3534 )
3535 .grow(),
3536 single_example(
3537 "No Remote Upstream",
3538 div()
3539 .w(example_width)
3540 .overflow_hidden()
3541 .child(PanelRepoFooter::new_preview(
3542 "no-remote-upstream",
3543 active_repository(3).clone(),
3544 Some(branch(no_remote_upstream)),
3545 ))
3546 .into_any_element(),
3547 )
3548 .grow(),
3549 single_example(
3550 "Not Ahead or Behind",
3551 div()
3552 .w(example_width)
3553 .overflow_hidden()
3554 .child(PanelRepoFooter::new_preview(
3555 "not-ahead-or-behind",
3556 active_repository(4).clone(),
3557 Some(branch(not_ahead_or_behind_upstream)),
3558 ))
3559 .into_any_element(),
3560 )
3561 .grow(),
3562 single_example(
3563 "Behind remote",
3564 div()
3565 .w(example_width)
3566 .overflow_hidden()
3567 .child(PanelRepoFooter::new_preview(
3568 "behind-remote",
3569 active_repository(5).clone(),
3570 Some(branch(behind_upstream)),
3571 ))
3572 .into_any_element(),
3573 )
3574 .grow(),
3575 single_example(
3576 "Ahead of remote",
3577 div()
3578 .w(example_width)
3579 .overflow_hidden()
3580 .child(PanelRepoFooter::new_preview(
3581 "ahead-of-remote",
3582 active_repository(6).clone(),
3583 Some(branch(ahead_of_upstream)),
3584 ))
3585 .into_any_element(),
3586 )
3587 .grow(),
3588 single_example(
3589 "Ahead and behind remote",
3590 div()
3591 .w(example_width)
3592 .overflow_hidden()
3593 .child(PanelRepoFooter::new_preview(
3594 "ahead-and-behind",
3595 active_repository(7).clone(),
3596 Some(branch(ahead_and_behind_upstream)),
3597 ))
3598 .into_any_element(),
3599 )
3600 .grow(),
3601 ],
3602 )
3603 .grow()
3604 .vertical()])
3605 .children(vec![example_group_with_title(
3606 "Labels",
3607 vec![
3608 single_example(
3609 "Short Branch & Repo",
3610 div()
3611 .w(example_width)
3612 .overflow_hidden()
3613 .child(PanelRepoFooter::new_preview(
3614 "short-branch",
3615 SharedString::from("zed"),
3616 Some(custom("main", behind_upstream)),
3617 ))
3618 .into_any_element(),
3619 )
3620 .grow(),
3621 single_example(
3622 "Long Branch",
3623 div()
3624 .w(example_width)
3625 .overflow_hidden()
3626 .child(PanelRepoFooter::new_preview(
3627 "long-branch",
3628 SharedString::from("zed"),
3629 Some(custom(
3630 "redesign-and-update-git-ui-list-entry-style",
3631 behind_upstream,
3632 )),
3633 ))
3634 .into_any_element(),
3635 )
3636 .grow(),
3637 single_example(
3638 "Long Repo",
3639 div()
3640 .w(example_width)
3641 .overflow_hidden()
3642 .child(PanelRepoFooter::new_preview(
3643 "long-repo",
3644 SharedString::from("zed-industries-community-examples"),
3645 Some(custom("gpui", ahead_of_upstream)),
3646 ))
3647 .into_any_element(),
3648 )
3649 .grow(),
3650 single_example(
3651 "Long Repo & Branch",
3652 div()
3653 .w(example_width)
3654 .overflow_hidden()
3655 .child(PanelRepoFooter::new_preview(
3656 "long-repo-and-branch",
3657 SharedString::from("zed-industries-community-examples"),
3658 Some(custom(
3659 "redesign-and-update-git-ui-list-entry-style",
3660 behind_upstream,
3661 )),
3662 ))
3663 .into_any_element(),
3664 )
3665 .grow(),
3666 single_example(
3667 "Uppercase Repo",
3668 div()
3669 .w(example_width)
3670 .overflow_hidden()
3671 .child(PanelRepoFooter::new_preview(
3672 "uppercase-repo",
3673 SharedString::from("LICENSES"),
3674 Some(custom("main", ahead_of_upstream)),
3675 ))
3676 .into_any_element(),
3677 )
3678 .grow(),
3679 single_example(
3680 "Uppercase Branch",
3681 div()
3682 .w(example_width)
3683 .overflow_hidden()
3684 .child(PanelRepoFooter::new_preview(
3685 "uppercase-branch",
3686 SharedString::from("zed"),
3687 Some(custom("update-README", behind_upstream)),
3688 ))
3689 .into_any_element(),
3690 )
3691 .grow(),
3692 ],
3693 )
3694 .grow()
3695 .vertical()])
3696 .into_any_element()
3697 }
3698}