git_ui.rs

  1use std::any::Any;
  2
  3use ::settings::Settings;
  4use command_palette_hooks::CommandPaletteFilter;
  5use commit_modal::CommitModal;
  6use editor::{Editor, actions::DiffClipboardWithSelectionData};
  7mod blame_ui;
  8use git::{
  9    repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
 10    status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
 11};
 12use git_panel_settings::GitPanelSettings;
 13use gpui::{
 14    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window,
 15    actions,
 16};
 17use onboarding::GitOnboardingModal;
 18use project_diff::ProjectDiff;
 19use ui::prelude::*;
 20use workspace::{ModalView, Workspace};
 21use zed_actions;
 22
 23use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
 24
 25mod askpass_modal;
 26pub mod branch_picker;
 27mod commit_modal;
 28pub mod commit_tooltip;
 29mod commit_view;
 30mod conflict_view;
 31pub mod file_diff_view;
 32pub mod git_panel;
 33mod git_panel_settings;
 34pub mod onboarding;
 35pub mod picker_prompt;
 36pub mod project_diff;
 37pub(crate) mod remote_output;
 38pub mod repository_selector;
 39pub mod text_diff_view;
 40
 41actions!(
 42    git,
 43    [
 44        /// Resets the git onboarding state to show the tutorial again.
 45        ResetOnboarding
 46    ]
 47);
 48
 49pub fn init(cx: &mut App) {
 50    GitPanelSettings::register(cx);
 51
 52    editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
 53
 54    cx.observe_new(|editor: &mut Editor, _, cx| {
 55        conflict_view::register_editor(editor, editor.buffer().clone(), cx);
 56    })
 57    .detach();
 58
 59    cx.observe_new(|workspace: &mut Workspace, _, cx| {
 60        ProjectDiff::register(workspace, cx);
 61        CommitModal::register(workspace);
 62        git_panel::register(workspace);
 63        repository_selector::register(workspace);
 64        branch_picker::register(workspace);
 65
 66        let project = workspace.project().read(cx);
 67        if project.is_read_only(cx) {
 68            return;
 69        }
 70        if !project.is_via_collab() {
 71            workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
 72                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 73                    return;
 74                };
 75                panel.update(cx, |panel, cx| {
 76                    panel.fetch(true, window, cx);
 77                });
 78            });
 79            workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
 80                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 81                    return;
 82                };
 83                panel.update(cx, |panel, cx| {
 84                    panel.fetch(false, window, cx);
 85                });
 86            });
 87            workspace.register_action(|workspace, _: &git::Push, window, cx| {
 88                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 89                    return;
 90                };
 91                panel.update(cx, |panel, cx| {
 92                    panel.push(false, false, window, cx);
 93                });
 94            });
 95            workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
 96                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 97                    return;
 98                };
 99                panel.update(cx, |panel, cx| {
100                    panel.push(false, true, window, cx);
101                });
102            });
103            workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
104                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
105                    return;
106                };
107                panel.update(cx, |panel, cx| {
108                    panel.push(true, false, window, cx);
109                });
110            });
111            workspace.register_action(|workspace, _: &git::Pull, window, cx| {
112                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
113                    return;
114                };
115                panel.update(cx, |panel, cx| {
116                    panel.pull(window, cx);
117                });
118            });
119        }
120        workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
121            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
122                return;
123            };
124            panel.update(cx, |panel, cx| {
125                panel.stash_all(action, window, cx);
126            });
127        });
128        workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
129            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
130                return;
131            };
132            panel.update(cx, |panel, cx| {
133                panel.stash_pop(action, window, cx);
134            });
135        });
136        workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
137            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
138                return;
139            };
140            panel.update(cx, |panel, cx| {
141                panel.stage_all(action, window, cx);
142            });
143        });
144        workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
145            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
146                return;
147            };
148            panel.update(cx, |panel, cx| {
149                panel.unstage_all(action, window, cx);
150            });
151        });
152        CommandPaletteFilter::update_global(cx, |filter, _cx| {
153            filter.hide_action_types(&[
154                zed_actions::OpenGitIntegrationOnboarding.type_id(),
155                // ResetOnboarding.type_id(),
156            ]);
157        });
158        workspace.register_action(
159            move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| {
160                GitOnboardingModal::toggle(workspace, window, cx)
161            },
162        );
163        workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
164            window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
165            window.refresh();
166        });
167        workspace.register_action(|workspace, _action: &git::Init, window, cx| {
168            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
169                return;
170            };
171            panel.update(cx, |panel, cx| {
172                panel.git_init(window, cx);
173            });
174        });
175        workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
176            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
177                return;
178            };
179
180            workspace.toggle_modal(window, cx, |window, cx| {
181                GitCloneModal::show(panel, window, cx)
182            });
183        });
184        workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
185            open_modified_files(workspace, window, cx);
186        });
187        workspace.register_action(
188            |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
189                if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
190                    task.detach();
191                };
192            },
193        );
194    })
195    .detach();
196}
197
198fn open_modified_files(
199    workspace: &mut Workspace,
200    window: &mut Window,
201    cx: &mut Context<Workspace>,
202) {
203    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
204        return;
205    };
206    let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
207        let Some(repo) = panel.active_repository.as_ref() else {
208            return Vec::new();
209        };
210        let repo = repo.read(cx);
211        repo.cached_status()
212            .filter_map(|entry| {
213                if entry.status.is_modified() {
214                    repo.repo_path_to_project_path(&entry.repo_path, cx)
215                } else {
216                    None
217                }
218            })
219            .collect()
220    });
221    for path in modified_paths {
222        workspace.open_path(path, None, true, window, cx).detach();
223    }
224}
225
226pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
227    GitStatusIcon::new(status)
228}
229
230fn render_remote_button(
231    id: impl Into<SharedString>,
232    branch: &Branch,
233    keybinding_target: Option<FocusHandle>,
234    show_fetch_button: bool,
235) -> Option<impl IntoElement> {
236    let id = id.into();
237    let upstream = branch.upstream.as_ref();
238    match upstream {
239        Some(Upstream {
240            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
241            ..
242        }) => match (*ahead, *behind) {
243            (0, 0) if show_fetch_button => {
244                Some(remote_button::render_fetch_button(keybinding_target, id))
245            }
246            (0, 0) => None,
247            (ahead, 0) => Some(remote_button::render_push_button(
248                keybinding_target,
249                id,
250                ahead,
251            )),
252            (ahead, behind) => Some(remote_button::render_pull_button(
253                keybinding_target,
254                id,
255                ahead,
256                behind,
257            )),
258        },
259        Some(Upstream {
260            tracking: UpstreamTracking::Gone,
261            ..
262        }) => Some(remote_button::render_republish_button(
263            keybinding_target,
264            id,
265        )),
266        None => Some(remote_button::render_publish_button(keybinding_target, id)),
267    }
268}
269
270mod remote_button {
271    use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
272    use ui::{
273        App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
274        IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
275        PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
276    };
277
278    pub fn render_fetch_button(
279        keybinding_target: Option<FocusHandle>,
280        id: SharedString,
281    ) -> SplitButton {
282        split_button(
283            id,
284            "Fetch",
285            0,
286            0,
287            Some(IconName::ArrowCircle),
288            keybinding_target.clone(),
289            move |_, window, cx| {
290                window.dispatch_action(Box::new(git::Fetch), cx);
291            },
292            move |window, cx| {
293                git_action_tooltip(
294                    "Fetch updates from remote",
295                    &git::Fetch,
296                    "git fetch",
297                    keybinding_target.clone(),
298                    window,
299                    cx,
300                )
301            },
302        )
303    }
304
305    pub fn render_push_button(
306        keybinding_target: Option<FocusHandle>,
307        id: SharedString,
308        ahead: u32,
309    ) -> SplitButton {
310        split_button(
311            id,
312            "Push",
313            ahead as usize,
314            0,
315            None,
316            keybinding_target.clone(),
317            move |_, window, cx| {
318                window.dispatch_action(Box::new(git::Push), cx);
319            },
320            move |window, cx| {
321                git_action_tooltip(
322                    "Push committed changes to remote",
323                    &git::Push,
324                    "git push",
325                    keybinding_target.clone(),
326                    window,
327                    cx,
328                )
329            },
330        )
331    }
332
333    pub fn render_pull_button(
334        keybinding_target: Option<FocusHandle>,
335        id: SharedString,
336        ahead: u32,
337        behind: u32,
338    ) -> SplitButton {
339        split_button(
340            id,
341            "Pull",
342            ahead as usize,
343            behind as usize,
344            None,
345            keybinding_target.clone(),
346            move |_, window, cx| {
347                window.dispatch_action(Box::new(git::Pull), cx);
348            },
349            move |window, cx| {
350                git_action_tooltip(
351                    "Pull",
352                    &git::Pull,
353                    "git pull",
354                    keybinding_target.clone(),
355                    window,
356                    cx,
357                )
358            },
359        )
360    }
361
362    pub fn render_publish_button(
363        keybinding_target: Option<FocusHandle>,
364        id: SharedString,
365    ) -> SplitButton {
366        split_button(
367            id,
368            "Publish",
369            0,
370            0,
371            Some(IconName::ExpandUp),
372            keybinding_target.clone(),
373            move |_, window, cx| {
374                window.dispatch_action(Box::new(git::Push), cx);
375            },
376            move |window, cx| {
377                git_action_tooltip(
378                    "Publish branch to remote",
379                    &git::Push,
380                    "git push --set-upstream",
381                    keybinding_target.clone(),
382                    window,
383                    cx,
384                )
385            },
386        )
387    }
388
389    pub fn render_republish_button(
390        keybinding_target: Option<FocusHandle>,
391        id: SharedString,
392    ) -> SplitButton {
393        split_button(
394            id,
395            "Republish",
396            0,
397            0,
398            Some(IconName::ExpandUp),
399            keybinding_target.clone(),
400            move |_, window, cx| {
401                window.dispatch_action(Box::new(git::Push), cx);
402            },
403            move |window, cx| {
404                git_action_tooltip(
405                    "Re-publish branch to remote",
406                    &git::Push,
407                    "git push --set-upstream",
408                    keybinding_target.clone(),
409                    window,
410                    cx,
411                )
412            },
413        )
414    }
415
416    fn git_action_tooltip(
417        label: impl Into<SharedString>,
418        action: &dyn Action,
419        command: impl Into<SharedString>,
420        focus_handle: Option<FocusHandle>,
421        window: &mut Window,
422        cx: &mut App,
423    ) -> AnyView {
424        let label = label.into();
425        let command = command.into();
426
427        if let Some(handle) = focus_handle {
428            Tooltip::with_meta_in(label, Some(action), command, &handle, window, cx)
429        } else {
430            Tooltip::with_meta(label, Some(action), command, window, cx)
431        }
432    }
433
434    fn render_git_action_menu(
435        id: impl Into<ElementId>,
436        keybinding_target: Option<FocusHandle>,
437    ) -> impl IntoElement {
438        PopoverMenu::new(id.into())
439            .trigger(
440                ui::ButtonLike::new_rounded_right("split-button-right")
441                    .layer(ui::ElevationIndex::ModalSurface)
442                    .size(ui::ButtonSize::None)
443                    .child(
444                        div()
445                            .px_1()
446                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
447                    ),
448            )
449            .menu(move |window, cx| {
450                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
451                    context_menu
452                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
453                            el.context(keybinding_target)
454                        })
455                        .action("Fetch", git::Fetch.boxed_clone())
456                        .action("Fetch From", git::FetchFrom.boxed_clone())
457                        .action("Pull", git::Pull.boxed_clone())
458                        .separator()
459                        .action("Push", git::Push.boxed_clone())
460                        .action("Push To", git::PushTo.boxed_clone())
461                        .action("Force Push", git::ForcePush.boxed_clone())
462                }))
463            })
464            .anchor(Corner::TopRight)
465    }
466
467    #[allow(clippy::too_many_arguments)]
468    fn split_button(
469        id: SharedString,
470        left_label: impl Into<SharedString>,
471        ahead_count: usize,
472        behind_count: usize,
473        left_icon: Option<IconName>,
474        keybinding_target: Option<FocusHandle>,
475        left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
476        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
477    ) -> SplitButton {
478        fn count(count: usize) -> impl IntoElement {
479            h_flex()
480                .ml_neg_px()
481                .h(rems(0.875))
482                .items_center()
483                .overflow_hidden()
484                .px_0p5()
485                .child(
486                    Label::new(count.to_string())
487                        .size(LabelSize::XSmall)
488                        .line_height_style(LineHeightStyle::UiLabel),
489                )
490        }
491
492        let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
493
494        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
495            format!("split-button-left-{}", id).into(),
496        ))
497        .layer(ui::ElevationIndex::ModalSurface)
498        .size(ui::ButtonSize::Compact)
499        .when(should_render_counts, |this| {
500            this.child(
501                h_flex()
502                    .ml_neg_0p5()
503                    .mr_1()
504                    .when(behind_count > 0, |this| {
505                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
506                            .child(count(behind_count))
507                    })
508                    .when(ahead_count > 0, |this| {
509                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
510                            .child(count(ahead_count))
511                    }),
512            )
513        })
514        .when_some(left_icon, |this, left_icon| {
515            this.child(
516                h_flex()
517                    .ml_neg_0p5()
518                    .mr_1()
519                    .child(Icon::new(left_icon).size(IconSize::XSmall)),
520            )
521        })
522        .child(
523            div()
524                .child(Label::new(left_label).size(LabelSize::Small))
525                .mr_0p5(),
526        )
527        .on_click(left_on_click)
528        .tooltip(tooltip);
529
530        let right = render_git_action_menu(
531            ElementId::Name(format!("split-button-right-{}", id).into()),
532            keybinding_target,
533        )
534        .into_any_element();
535
536        SplitButton::new(left, right)
537    }
538}
539
540/// A visual representation of a file's Git status.
541#[derive(IntoElement, RegisterComponent)]
542pub struct GitStatusIcon {
543    status: FileStatus,
544}
545
546impl GitStatusIcon {
547    pub fn new(status: FileStatus) -> Self {
548        Self { status }
549    }
550}
551
552impl RenderOnce for GitStatusIcon {
553    fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
554        let status = self.status;
555
556        let (icon_name, color) = if status.is_conflicted() {
557            (
558                IconName::Warning,
559                cx.theme().colors().version_control_conflict,
560            )
561        } else if status.is_deleted() {
562            (
563                IconName::SquareMinus,
564                cx.theme().colors().version_control_deleted,
565            )
566        } else if status.is_modified() {
567            (
568                IconName::SquareDot,
569                cx.theme().colors().version_control_modified,
570            )
571        } else {
572            (
573                IconName::SquarePlus,
574                cx.theme().colors().version_control_added,
575            )
576        };
577
578        Icon::new(icon_name).color(Color::Custom(color))
579    }
580}
581
582// View this component preview using `workspace: open component-preview`
583impl Component for GitStatusIcon {
584    fn scope() -> ComponentScope {
585        ComponentScope::VersionControl
586    }
587
588    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
589        fn tracked_file_status(code: StatusCode) -> FileStatus {
590            FileStatus::Tracked(git::status::TrackedStatus {
591                index_status: code,
592                worktree_status: code,
593            })
594        }
595
596        let modified = tracked_file_status(StatusCode::Modified);
597        let added = tracked_file_status(StatusCode::Added);
598        let deleted = tracked_file_status(StatusCode::Deleted);
599        let conflict = UnmergedStatus {
600            first_head: UnmergedStatusCode::Updated,
601            second_head: UnmergedStatusCode::Updated,
602        }
603        .into();
604
605        Some(
606            v_flex()
607                .gap_6()
608                .children(vec![example_group(vec![
609                    single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
610                    single_example("Added", GitStatusIcon::new(added).into_any_element()),
611                    single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
612                    single_example(
613                        "Conflicted",
614                        GitStatusIcon::new(conflict).into_any_element(),
615                    ),
616                ])])
617                .into_any_element(),
618        )
619    }
620}
621
622struct GitCloneModal {
623    panel: Entity<GitPanel>,
624    repo_input: Entity<Editor>,
625    focus_handle: FocusHandle,
626}
627
628impl GitCloneModal {
629    pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
630        let repo_input = cx.new(|cx| {
631            let mut editor = Editor::single_line(window, cx);
632            editor.set_placeholder_text("Enter repository URL…", cx);
633            editor
634        });
635        let focus_handle = repo_input.focus_handle(cx);
636
637        window.focus(&focus_handle);
638
639        Self {
640            panel,
641            repo_input,
642            focus_handle,
643        }
644    }
645}
646
647impl Focusable for GitCloneModal {
648    fn focus_handle(&self, _: &App) -> FocusHandle {
649        self.focus_handle.clone()
650    }
651}
652
653impl Render for GitCloneModal {
654    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
655        div()
656            .elevation_3(cx)
657            .w(rems(34.))
658            .flex_1()
659            .overflow_hidden()
660            .child(
661                div()
662                    .w_full()
663                    .p_2()
664                    .border_b_1()
665                    .border_color(cx.theme().colors().border_variant)
666                    .child(self.repo_input.clone()),
667            )
668            .child(
669                h_flex()
670                    .w_full()
671                    .p_2()
672                    .gap_0p5()
673                    .rounded_b_sm()
674                    .bg(cx.theme().colors().editor_background)
675                    .child(
676                        Label::new("Clone a repository from GitHub or other sources.")
677                            .color(Color::Muted)
678                            .size(LabelSize::Small),
679                    )
680                    .child(
681                        Button::new("learn-more", "Learn More")
682                            .label_size(LabelSize::Small)
683                            .icon(IconName::ArrowUpRight)
684                            .icon_size(IconSize::XSmall)
685                            .on_click(|_, _, cx| {
686                                cx.open_url("https://github.com/git-guides/git-clone");
687                            }),
688                    ),
689            )
690            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
691                cx.emit(DismissEvent);
692            }))
693            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
694                let repo = this.repo_input.read(cx).text(cx);
695                this.panel.update(cx, |panel, cx| {
696                    panel.git_clone(repo, window, cx);
697                });
698                cx.emit(DismissEvent);
699            }))
700    }
701}
702
703impl EventEmitter<DismissEvent> for GitCloneModal {}
704
705impl ModalView for GitCloneModal {}