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