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