git_ui.rs

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