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