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