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