git_ui.rs

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