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