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