git_ui.rs

  1use anyhow::anyhow;
  2use commit_modal::CommitModal;
  3use editor::{Editor, actions::DiffClipboardWithSelectionData};
  4
  5use ui::{
  6    Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled,
  7    StyledExt, div, h_flex, rems, v_flex,
  8};
  9
 10mod blame_ui;
 11pub mod clone;
 12
 13use git::{
 14    repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
 15    status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
 16};
 17use gpui::{
 18    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, Window,
 19};
 20use menu::{Cancel, Confirm};
 21use project::git_store::Repository;
 22use project_diff::ProjectDiff;
 23use ui::prelude::*;
 24use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
 25use zed_actions;
 26
 27use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
 28
 29mod askpass_modal;
 30pub mod branch_picker;
 31mod commit_modal;
 32pub mod commit_tooltip;
 33pub mod commit_view;
 34mod conflict_view;
 35pub mod file_diff_view;
 36pub mod file_history_view;
 37pub mod git_panel;
 38mod git_panel_settings;
 39pub mod git_picker;
 40pub mod multi_diff_view;
 41pub mod picker_prompt;
 42pub mod project_diff;
 43pub(crate) mod remote_output;
 44pub mod repository_selector;
 45pub mod stash_picker;
 46pub mod text_diff_view;
 47pub mod worktree_picker;
 48
 49pub use conflict_view::MergeConflictIndicator;
 50
 51pub fn init(cx: &mut App) {
 52    editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
 53    commit_view::init(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        git_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(
 73                |workspace, _: &zed_actions::git::CreatePullRequest, window, cx| {
 74                    if let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) {
 75                        panel.update(cx, |panel, cx| {
 76                            panel.create_pull_request(window, cx);
 77                        });
 78                    }
 79                },
 80            );
 81            workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
 82                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 83                    return;
 84                };
 85                panel.update(cx, |panel, cx| {
 86                    panel.fetch(true, window, cx);
 87                });
 88            });
 89            workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
 90                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 91                    return;
 92                };
 93                panel.update(cx, |panel, cx| {
 94                    panel.fetch(false, window, cx);
 95                });
 96            });
 97            workspace.register_action(|workspace, _: &git::Push, window, cx| {
 98                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 99                    return;
100                };
101                panel.update(cx, |panel, cx| {
102                    panel.push(false, false, window, cx);
103                });
104            });
105            workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
106                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
107                    return;
108                };
109                panel.update(cx, |panel, cx| {
110                    panel.push(false, true, window, cx);
111                });
112            });
113            workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
114                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
115                    return;
116                };
117                panel.update(cx, |panel, cx| {
118                    panel.push(true, false, window, cx);
119                });
120            });
121            workspace.register_action(|workspace, _: &git::Pull, window, cx| {
122                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
123                    return;
124                };
125                panel.update(cx, |panel, cx| {
126                    panel.pull(false, window, cx);
127                });
128            });
129            workspace.register_action(|workspace, _: &git::PullRebase, window, cx| {
130                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
131                    return;
132                };
133                panel.update(cx, |panel, cx| {
134                    panel.pull(true, window, cx);
135                });
136            });
137        }
138        workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
139            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
140                return;
141            };
142            panel.update(cx, |panel, cx| {
143                panel.stash_all(action, window, cx);
144            });
145        });
146        workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
147            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
148                return;
149            };
150            panel.update(cx, |panel, cx| {
151                panel.stash_pop(action, window, cx);
152            });
153        });
154        workspace.register_action(|workspace, action: &git::StashApply, window, cx| {
155            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
156                return;
157            };
158            panel.update(cx, |panel, cx| {
159                panel.stash_apply(action, window, cx);
160            });
161        });
162        workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
163            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
164                return;
165            };
166            panel.update(cx, |panel, cx| {
167                panel.stage_all(action, window, cx);
168            });
169        });
170        workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
171            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
172                return;
173            };
174            panel.update(cx, |panel, cx| {
175                panel.unstage_all(action, window, cx);
176            });
177        });
178        workspace.register_action(|workspace, _: &git::Uncommit, window, cx| {
179            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
180                return;
181            };
182            panel.update(cx, |panel, cx| {
183                panel.uncommit(window, cx);
184            })
185        });
186        workspace.register_action(|workspace, _action: &git::Init, window, cx| {
187            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
188                return;
189            };
190            panel.update(cx, |panel, cx| {
191                panel.git_init(window, cx);
192            });
193        });
194        workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
195            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
196                return;
197            };
198
199            workspace.toggle_modal(window, cx, |window, cx| {
200                GitCloneModal::show(panel, window, cx)
201            });
202        });
203        workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
204            open_modified_files(workspace, window, cx);
205        });
206        workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| {
207            rename_current_branch(workspace, window, cx);
208        });
209        workspace.register_action(
210            |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
211                if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
212                    task.detach();
213                };
214            },
215        );
216    })
217    .detach();
218}
219
220fn open_modified_files(
221    workspace: &mut Workspace,
222    window: &mut Window,
223    cx: &mut Context<Workspace>,
224) {
225    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
226        return;
227    };
228    let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
229        let Some(repo) = panel.active_repository.as_ref() else {
230            return Vec::new();
231        };
232        let repo = repo.read(cx);
233        repo.cached_status()
234            .filter_map(|entry| {
235                if entry.status.is_modified() {
236                    repo.repo_path_to_project_path(&entry.repo_path, cx)
237                } else {
238                    None
239                }
240            })
241            .collect()
242    });
243    for path in modified_paths {
244        workspace.open_path(path, None, true, window, cx).detach();
245    }
246}
247
248/// Resolves the repository for git operations, respecting the workspace's
249/// active worktree override from the project dropdown.
250pub fn resolve_active_repository(workspace: &Workspace, cx: &App) -> Option<Entity<Repository>> {
251    let project = workspace.project().read(cx);
252    workspace
253        .active_worktree_override()
254        .and_then(|override_id| {
255            project
256                .worktree_for_id(override_id, cx)
257                .and_then(|worktree| {
258                    let worktree_abs_path = worktree.read(cx).abs_path();
259                    let git_store = project.git_store().read(cx);
260                    git_store
261                        .repositories()
262                        .values()
263                        .filter(|repo| {
264                            let repo_path = &repo.read(cx).work_directory_abs_path;
265                            *repo_path == worktree_abs_path
266                                || worktree_abs_path.starts_with(repo_path.as_ref())
267                        })
268                        .max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
269                        .cloned()
270                })
271        })
272        .or_else(|| project.active_repository(cx))
273}
274
275pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
276    GitStatusIcon::new(status)
277}
278
279struct RenameBranchModal {
280    current_branch: SharedString,
281    editor: Entity<Editor>,
282    repo: Entity<Repository>,
283}
284
285impl RenameBranchModal {
286    fn new(
287        current_branch: String,
288        repo: Entity<Repository>,
289        window: &mut Window,
290        cx: &mut Context<Self>,
291    ) -> Self {
292        let editor = cx.new(|cx| {
293            let mut editor = Editor::single_line(window, cx);
294            editor.set_text(current_branch.clone(), window, cx);
295            editor
296        });
297        Self {
298            current_branch: current_branch.into(),
299            editor,
300            repo,
301        }
302    }
303
304    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
305        cx.emit(DismissEvent);
306    }
307
308    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
309        let new_name = self.editor.read(cx).text(cx);
310        if new_name.is_empty() || new_name == self.current_branch.as_ref() {
311            cx.emit(DismissEvent);
312            return;
313        }
314
315        let repo = self.repo.clone();
316        let current_branch = self.current_branch.to_string();
317        cx.spawn(async move |_, cx| {
318            match repo
319                .update(cx, |repo, _| {
320                    repo.rename_branch(current_branch, new_name.clone())
321                })
322                .await
323            {
324                Ok(Ok(_)) => Ok(()),
325                Ok(Err(error)) => Err(error),
326                Err(_) => Err(anyhow!("Operation was canceled")),
327            }
328        })
329        .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
330        cx.emit(DismissEvent);
331    }
332}
333
334impl EventEmitter<DismissEvent> for RenameBranchModal {}
335impl ModalView for RenameBranchModal {}
336impl Focusable for RenameBranchModal {
337    fn focus_handle(&self, cx: &App) -> FocusHandle {
338        self.editor.focus_handle(cx)
339    }
340}
341
342impl Render for RenameBranchModal {
343    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
344        v_flex()
345            .key_context("RenameBranchModal")
346            .on_action(cx.listener(Self::cancel))
347            .on_action(cx.listener(Self::confirm))
348            .elevation_2(cx)
349            .w(rems(34.))
350            .child(
351                h_flex()
352                    .px_3()
353                    .pt_2()
354                    .pb_1()
355                    .w_full()
356                    .gap_1p5()
357                    .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
358                    .child(
359                        Headline::new(format!("Rename Branch ({})", self.current_branch))
360                            .size(HeadlineSize::XSmall),
361                    ),
362            )
363            .child(div().px_3().pb_3().w_full().child(self.editor.clone()))
364    }
365}
366
367fn rename_current_branch(
368    workspace: &mut Workspace,
369    window: &mut Window,
370    cx: &mut Context<Workspace>,
371) {
372    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
373        return;
374    };
375    let current_branch: Option<String> = panel.update(cx, |panel, cx| {
376        let repo = panel.active_repository.as_ref()?;
377        let repo = repo.read(cx);
378        repo.branch.as_ref().map(|branch| branch.name().to_string())
379    });
380
381    let Some(current_branch_name) = current_branch else {
382        return;
383    };
384
385    let repo = panel.read(cx).active_repository.clone();
386    let Some(repo) = repo else {
387        return;
388    };
389
390    workspace.toggle_modal(window, cx, |window, cx| {
391        RenameBranchModal::new(current_branch_name, repo, window, cx)
392    });
393}
394
395fn render_remote_button(
396    id: impl Into<SharedString>,
397    branch: &Branch,
398    keybinding_target: Option<FocusHandle>,
399    show_fetch_button: bool,
400) -> Option<impl IntoElement> {
401    let id = id.into();
402    let upstream = branch.upstream.as_ref();
403    match upstream {
404        Some(Upstream {
405            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
406            ..
407        }) => match (*ahead, *behind) {
408            (0, 0) if show_fetch_button => {
409                Some(remote_button::render_fetch_button(keybinding_target, id))
410            }
411            (0, 0) => None,
412            (ahead, 0) => Some(remote_button::render_push_button(
413                keybinding_target,
414                id,
415                ahead,
416            )),
417            (ahead, behind) => Some(remote_button::render_pull_button(
418                keybinding_target,
419                id,
420                ahead,
421                behind,
422            )),
423        },
424        Some(Upstream {
425            tracking: UpstreamTracking::Gone,
426            ..
427        }) => Some(remote_button::render_republish_button(
428            keybinding_target,
429            id,
430        )),
431        None => Some(remote_button::render_publish_button(keybinding_target, id)),
432    }
433}
434
435mod remote_button {
436    use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
437    use ui::{
438        App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
439        IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
440        PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
441    };
442
443    pub fn render_fetch_button(
444        keybinding_target: Option<FocusHandle>,
445        id: SharedString,
446    ) -> SplitButton {
447        split_button(
448            id,
449            "Fetch",
450            0,
451            0,
452            Some(IconName::ArrowCircle),
453            keybinding_target.clone(),
454            move |_, window, cx| {
455                window.dispatch_action(Box::new(git::Fetch), cx);
456            },
457            move |_window, cx| {
458                git_action_tooltip(
459                    "Fetch updates from remote",
460                    &git::Fetch,
461                    "git fetch",
462                    keybinding_target.clone(),
463                    cx,
464                )
465            },
466        )
467    }
468
469    pub fn render_push_button(
470        keybinding_target: Option<FocusHandle>,
471        id: SharedString,
472        ahead: u32,
473    ) -> SplitButton {
474        split_button(
475            id,
476            "Push",
477            ahead as usize,
478            0,
479            None,
480            keybinding_target.clone(),
481            move |_, window, cx| {
482                window.dispatch_action(Box::new(git::Push), cx);
483            },
484            move |_window, cx| {
485                git_action_tooltip(
486                    "Push committed changes to remote",
487                    &git::Push,
488                    "git push",
489                    keybinding_target.clone(),
490                    cx,
491                )
492            },
493        )
494    }
495
496    pub fn render_pull_button(
497        keybinding_target: Option<FocusHandle>,
498        id: SharedString,
499        ahead: u32,
500        behind: u32,
501    ) -> SplitButton {
502        split_button(
503            id,
504            "Pull",
505            ahead as usize,
506            behind as usize,
507            None,
508            keybinding_target.clone(),
509            move |_, window, cx| {
510                window.dispatch_action(Box::new(git::Pull), cx);
511            },
512            move |_window, cx| {
513                git_action_tooltip(
514                    "Pull",
515                    &git::Pull,
516                    "git pull",
517                    keybinding_target.clone(),
518                    cx,
519                )
520            },
521        )
522    }
523
524    pub fn render_publish_button(
525        keybinding_target: Option<FocusHandle>,
526        id: SharedString,
527    ) -> SplitButton {
528        split_button(
529            id,
530            "Publish",
531            0,
532            0,
533            Some(IconName::ExpandUp),
534            keybinding_target.clone(),
535            move |_, window, cx| {
536                window.dispatch_action(Box::new(git::Push), cx);
537            },
538            move |_window, cx| {
539                git_action_tooltip(
540                    "Publish branch to remote",
541                    &git::Push,
542                    "git push --set-upstream",
543                    keybinding_target.clone(),
544                    cx,
545                )
546            },
547        )
548    }
549
550    pub fn render_republish_button(
551        keybinding_target: Option<FocusHandle>,
552        id: SharedString,
553    ) -> SplitButton {
554        split_button(
555            id,
556            "Republish",
557            0,
558            0,
559            Some(IconName::ExpandUp),
560            keybinding_target.clone(),
561            move |_, window, cx| {
562                window.dispatch_action(Box::new(git::Push), cx);
563            },
564            move |_window, cx| {
565                git_action_tooltip(
566                    "Re-publish branch to remote",
567                    &git::Push,
568                    "git push --set-upstream",
569                    keybinding_target.clone(),
570                    cx,
571                )
572            },
573        )
574    }
575
576    fn git_action_tooltip(
577        label: impl Into<SharedString>,
578        action: &dyn Action,
579        command: impl Into<SharedString>,
580        focus_handle: Option<FocusHandle>,
581        cx: &mut App,
582    ) -> AnyView {
583        let label = label.into();
584        let command = command.into();
585
586        if let Some(handle) = focus_handle {
587            Tooltip::with_meta_in(label, Some(action), command, &handle, cx)
588        } else {
589            Tooltip::with_meta(label, Some(action), command, cx)
590        }
591    }
592
593    fn render_git_action_menu(
594        id: impl Into<ElementId>,
595        keybinding_target: Option<FocusHandle>,
596    ) -> impl IntoElement {
597        PopoverMenu::new(id.into())
598            .trigger(
599                ui::ButtonLike::new_rounded_right("split-button-right")
600                    .layer(ui::ElevationIndex::ModalSurface)
601                    .size(ui::ButtonSize::None)
602                    .child(
603                        div()
604                            .px_1()
605                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
606                    ),
607            )
608            .menu(move |window, cx| {
609                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
610                    context_menu
611                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
612                            el.context(keybinding_target)
613                        })
614                        .action("Fetch", git::Fetch.boxed_clone())
615                        .action("Fetch From", git::FetchFrom.boxed_clone())
616                        .action("Pull", git::Pull.boxed_clone())
617                        .action("Pull (Rebase)", git::PullRebase.boxed_clone())
618                        .separator()
619                        .action("Push", git::Push.boxed_clone())
620                        .action("Push To", git::PushTo.boxed_clone())
621                        .action("Force Push", git::ForcePush.boxed_clone())
622                }))
623            })
624            .anchor(Corner::TopRight)
625    }
626
627    #[allow(clippy::too_many_arguments)]
628    fn split_button(
629        id: SharedString,
630        left_label: impl Into<SharedString>,
631        ahead_count: usize,
632        behind_count: usize,
633        left_icon: Option<IconName>,
634        keybinding_target: Option<FocusHandle>,
635        left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
636        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
637    ) -> SplitButton {
638        fn count(count: usize) -> impl IntoElement {
639            h_flex()
640                .ml_neg_px()
641                .h(rems(0.875))
642                .items_center()
643                .overflow_hidden()
644                .px_0p5()
645                .child(
646                    Label::new(count.to_string())
647                        .size(LabelSize::XSmall)
648                        .line_height_style(LineHeightStyle::UiLabel),
649                )
650        }
651
652        let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
653
654        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
655            format!("split-button-left-{}", id).into(),
656        ))
657        .layer(ui::ElevationIndex::ModalSurface)
658        .size(ui::ButtonSize::Compact)
659        .when(should_render_counts, |this| {
660            this.child(
661                h_flex()
662                    .ml_neg_0p5()
663                    .when(behind_count > 0, |this| {
664                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
665                            .child(count(behind_count))
666                    })
667                    .when(ahead_count > 0, |this| {
668                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
669                            .child(count(ahead_count))
670                    }),
671            )
672        })
673        .when_some(left_icon, |this, left_icon| {
674            this.child(
675                h_flex()
676                    .ml_neg_0p5()
677                    .child(Icon::new(left_icon).size(IconSize::XSmall)),
678            )
679        })
680        .child(
681            div()
682                .child(Label::new(left_label).size(LabelSize::Small))
683                .mr_0p5(),
684        )
685        .on_click(left_on_click)
686        .tooltip(tooltip);
687
688        let right = render_git_action_menu(
689            ElementId::Name(format!("split-button-right-{}", id).into()),
690            keybinding_target,
691        )
692        .into_any_element();
693
694        SplitButton::new(left, right)
695    }
696}
697
698/// A visual representation of a file's Git status.
699#[derive(IntoElement, RegisterComponent)]
700pub struct GitStatusIcon {
701    status: FileStatus,
702}
703
704impl GitStatusIcon {
705    pub fn new(status: FileStatus) -> Self {
706        Self { status }
707    }
708}
709
710impl RenderOnce for GitStatusIcon {
711    fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
712        let status = self.status;
713
714        let (icon_name, color) = if status.is_conflicted() {
715            (
716                IconName::Warning,
717                cx.theme().colors().version_control_conflict,
718            )
719        } else if status.is_deleted() {
720            (
721                IconName::SquareMinus,
722                cx.theme().colors().version_control_deleted,
723            )
724        } else if status.is_modified() {
725            (
726                IconName::SquareDot,
727                cx.theme().colors().version_control_modified,
728            )
729        } else {
730            (
731                IconName::SquarePlus,
732                cx.theme().colors().version_control_added,
733            )
734        };
735
736        Icon::new(icon_name).color(Color::Custom(color))
737    }
738}
739
740// View this component preview using `workspace: open component-preview`
741impl Component for GitStatusIcon {
742    fn scope() -> ComponentScope {
743        ComponentScope::VersionControl
744    }
745
746    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
747        fn tracked_file_status(code: StatusCode) -> FileStatus {
748            FileStatus::Tracked(git::status::TrackedStatus {
749                index_status: code,
750                worktree_status: code,
751            })
752        }
753
754        let modified = tracked_file_status(StatusCode::Modified);
755        let added = tracked_file_status(StatusCode::Added);
756        let deleted = tracked_file_status(StatusCode::Deleted);
757        let conflict = UnmergedStatus {
758            first_head: UnmergedStatusCode::Updated,
759            second_head: UnmergedStatusCode::Updated,
760        }
761        .into();
762
763        Some(
764            v_flex()
765                .gap_6()
766                .children(vec![example_group(vec![
767                    single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
768                    single_example("Added", GitStatusIcon::new(added).into_any_element()),
769                    single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
770                    single_example(
771                        "Conflicted",
772                        GitStatusIcon::new(conflict).into_any_element(),
773                    ),
774                ])])
775                .into_any_element(),
776        )
777    }
778}
779
780struct GitCloneModal {
781    panel: Entity<GitPanel>,
782    repo_input: Entity<Editor>,
783    focus_handle: FocusHandle,
784}
785
786impl GitCloneModal {
787    pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
788        let repo_input = cx.new(|cx| {
789            let mut editor = Editor::single_line(window, cx);
790            editor.set_placeholder_text("Enter repository URL…", window, cx);
791            editor
792        });
793        let focus_handle = repo_input.focus_handle(cx);
794
795        window.focus(&focus_handle, cx);
796
797        Self {
798            panel,
799            repo_input,
800            focus_handle,
801        }
802    }
803}
804
805impl Focusable for GitCloneModal {
806    fn focus_handle(&self, _: &App) -> FocusHandle {
807        self.focus_handle.clone()
808    }
809}
810
811impl Render for GitCloneModal {
812    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
813        div()
814            .elevation_3(cx)
815            .w(rems(34.))
816            .flex_1()
817            .overflow_hidden()
818            .child(
819                div()
820                    .w_full()
821                    .p_2()
822                    .border_b_1()
823                    .border_color(cx.theme().colors().border_variant)
824                    .child(self.repo_input.clone()),
825            )
826            .child(
827                h_flex()
828                    .w_full()
829                    .p_2()
830                    .gap_0p5()
831                    .rounded_b_sm()
832                    .bg(cx.theme().colors().editor_background)
833                    .child(
834                        Label::new("Clone a repository from GitHub or other sources.")
835                            .color(Color::Muted)
836                            .size(LabelSize::Small),
837                    )
838                    .child(
839                        Button::new("learn-more", "Learn More")
840                            .label_size(LabelSize::Small)
841                            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall))
842                            .on_click(|_, _, cx| {
843                                cx.open_url("https://github.com/git-guides/git-clone");
844                            }),
845                    ),
846            )
847            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
848                cx.emit(DismissEvent);
849            }))
850            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
851                let repo = this.repo_input.read(cx).text(cx);
852                this.panel.update(cx, |panel, cx| {
853                    panel.git_clone(repo, window, cx);
854                });
855                cx.emit(DismissEvent);
856            }))
857    }
858}
859
860impl EventEmitter<DismissEvent> for GitCloneModal {}
861
862impl ModalView for GitCloneModal {}