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