git_ui.rs

  1use std::any::Any;
  2
  3use ::settings::Settings;
  4use command_palette_hooks::CommandPaletteFilter;
  5use commit_modal::CommitModal;
  6use editor::{Editor, EditorElement, EditorStyle, 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, TextStyle,
 15    Window, actions,
 16};
 17use onboarding::GitOnboardingModal;
 18use project_diff::ProjectDiff;
 19use theme::ThemeSettings;
 20use ui::prelude::*;
 21use workspace::{ModalView, Workspace};
 22use zed_actions;
 23
 24use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
 25
 26mod askpass_modal;
 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.clone(),
250                id,
251                ahead,
252            )),
253            (ahead, behind) => Some(remote_button::render_pull_button(
254                keybinding_target.clone(),
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(
430                label.clone(),
431                Some(action),
432                command.clone(),
433                &handle,
434                window,
435                cx,
436            )
437        } else {
438            Tooltip::with_meta(label.clone(), Some(action), command.clone(), 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.clone())
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", 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    fn render_editor(&self, window: &Window, cx: &App) -> impl IntoElement {
655        let settings = ThemeSettings::get_global(cx);
656        let theme = cx.theme();
657
658        let text_style = TextStyle {
659            color: cx.theme().colors().text,
660            font_family: settings.buffer_font.family.clone(),
661            font_features: settings.buffer_font.features.clone(),
662            font_size: settings.buffer_font_size(cx).into(),
663            font_weight: settings.buffer_font.weight,
664            line_height: relative(settings.buffer_line_height.value()),
665            background_color: Some(theme.colors().editor_background),
666            ..Default::default()
667        };
668
669        let element = EditorElement::new(
670            &self.repo_input,
671            EditorStyle {
672                background: theme.colors().editor_background,
673                local_player: theme.players().local(),
674                text: text_style,
675                ..Default::default()
676            },
677        );
678
679        div()
680            .rounded_md()
681            .p_1()
682            .border_1()
683            .border_color(theme.colors().border_variant)
684            .when(
685                self.repo_input
686                    .focus_handle(cx)
687                    .contains_focused(window, cx),
688                |this| this.border_color(theme.colors().border_focused),
689            )
690            .child(element)
691            .bg(theme.colors().editor_background)
692    }
693}
694
695impl Focusable for GitCloneModal {
696    fn focus_handle(&self, _: &App) -> FocusHandle {
697        self.focus_handle.clone()
698    }
699}
700
701impl Render for GitCloneModal {
702    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
703        div()
704            .size_full()
705            .w(rems(34.))
706            .elevation_3(cx)
707            .child(self.render_editor(window, cx))
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 {}