@@ -1,5 +1,4 @@
use crate::git_panel_settings::StatusStyle;
-use crate::project_diff::Diff;
use crate::repository_selector::RepositorySelectorPopoverMenu;
use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
@@ -11,21 +10,27 @@ use editor::{
scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
ShowScrollbar,
};
-use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode, UpstreamTracking};
+use git::repository::{
+ Branch, CommitDetails, CommitSummary, PushOptions, Remote, ResetMode, Upstream,
+ UpstreamTracking, UpstreamTrackingStatus,
+};
use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
-use git::{Push, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
+use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
use gpui::*;
use itertools::Itertools;
use language::{Buffer, File};
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
use multi_buffer::ExcerptInfo;
-use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
+use panel::{
+ panel_editor_container, panel_editor_style, panel_filled_button, panel_icon_button, PanelHeader,
+};
use project::{
git::{GitEvent, Repository},
Fs, Project, ProjectPath,
};
use serde::{Deserialize, Serialize};
use settings::Settings as _;
+use smallvec::smallvec;
use std::cell::RefCell;
use std::future::Future;
use std::rc::Rc;
@@ -33,8 +38,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize};
use strum::{IntoEnumIterator, VariantNames};
use time::OffsetDateTime;
use ui::{
- prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
- ListItemSpacing, PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
+ prelude::*, ButtonLike, Checkbox, ContextMenu, ElevationIndex, ListItem, ListItemSpacing,
+ PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
};
use util::{maybe, post_inc, ResultExt, TryFutureExt};
use workspace::{
@@ -70,6 +75,19 @@ enum TrashCancel {
Cancel,
}
+fn git_panel_context_menu(window: &mut Window, cx: &mut App) -> Entity<ContextMenu> {
+ ContextMenu::build(window, cx, |context_menu, _, _| {
+ context_menu
+ .action("Stage All", StageAll.boxed_clone())
+ .action("Unstage All", UnstageAll.boxed_clone())
+ .separator()
+ .action("Open Diff", project_diff::Diff.boxed_clone())
+ .separator()
+ .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
+ .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
+ })
+}
+
const GIT_PANEL_KEY: &str = "GitPanel";
const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
@@ -1779,88 +1797,7 @@ impl GitPanel {
});
}
- pub fn panel_button(
- &self,
- id: impl Into<SharedString>,
- label: impl Into<SharedString>,
- ) -> Button {
- let id = id.into().clone();
- let label = label.into().clone();
-
- Button::new(id, label)
- .label_size(LabelSize::Small)
- .layer(ElevationIndex::ElevatedSurface)
- .size(ButtonSize::Compact)
- .style(ButtonStyle::Filled)
- }
-
- pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
- Checkbox::container_size(cx).to_pixels(window.rem_size())
- }
-
- pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
- h_flex()
- .items_center()
- .h(px(8.))
- .child(Divider::horizontal_dashed().color(DividerColor::Border))
- }
-
- pub fn render_panel_header(
- &self,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Option<impl IntoElement> {
- let all_repositories = self
- .project
- .read(cx)
- .git_store()
- .read(cx)
- .all_repositories();
-
- let has_repo_above = all_repositories.iter().any(|repo| {
- repo.read(cx)
- .repository_entry
- .work_directory
- .is_above_project()
- });
-
- let has_visible_repo = all_repositories.len() > 0 || has_repo_above;
-
- if has_visible_repo {
- Some(
- self.panel_header_container(window, cx)
- .child(
- Label::new("Repository")
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- .child(self.render_repository_selector(cx))
- .child(div().flex_grow()) // spacer
- .child(
- div()
- .h_flex()
- .gap_1()
- .children(self.render_spinner(cx))
- .children(self.render_sync_button(cx))
- .children(self.render_pull_button(cx))
- .child(
- Button::new("diff", "+/-")
- .tooltip(Tooltip::for_action_title("Open diff", &Diff))
- .on_click(|_, _, cx| {
- cx.defer(|cx| {
- cx.dispatch_action(&Diff);
- })
- }),
- )
- .child(self.render_overflow_menu()),
- ),
- )
- } else {
- None
- }
- }
-
- pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
+ fn render_spinner(&self) -> Option<impl IntoElement> {
(!self.pending_remote_operations.borrow().is_empty()).then(|| {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
@@ -1874,83 +1811,6 @@ impl GitPanel {
})
}
- pub fn render_overflow_menu(&self) -> impl IntoElement {
- PopoverMenu::new("overflow-menu")
- .trigger(IconButton::new("overflow-menu-trigger", IconName::Ellipsis))
- .menu(move |window, cx| Some(Self::panel_context_menu(window, cx)))
- .anchor(Corner::TopRight)
- }
-
- pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
- let active_repository = self.project.read(cx).active_repository(cx);
- active_repository.as_ref().map(|_| {
- panel_filled_button("Fetch")
- .icon(IconName::ArrowCircle)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .icon_position(IconPosition::Start)
- .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
- .on_click(
- cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
- )
- .into_any_element()
- })
- }
-
- pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
- let active_repository = self.project.read(cx).active_repository(cx);
- active_repository
- .as_ref()
- .and_then(|repo| repo.read(cx).current_branch())
- .and_then(|branch| {
- branch.upstream.as_ref().map(|upstream| {
- let status = &upstream.tracking;
-
- let disabled = status.is_gone();
-
- panel_filled_button(match status {
- git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
- format!("Pull ({})", status.behind)
- }
- _ => "Pull".to_string(),
- })
- .icon(IconName::ArrowDown)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .icon_position(IconPosition::Start)
- .disabled(status.is_gone())
- .tooltip(move |window, cx| {
- if disabled {
- Tooltip::simple("Upstream is gone", cx)
- } else {
- // TODO: Add <origin> and <branch> argument substitutions to this
- Tooltip::for_action("git pull", &git::Pull, window, cx)
- }
- })
- .on_click(
- cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
- )
- .into_any_element()
- })
- })
- }
-
- pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
- let active_repository = self.project.read(cx).active_repository(cx);
- let repository_display_name = active_repository
- .as_ref()
- .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
- .unwrap_or_default();
-
- RepositorySelectorPopoverMenu::new(
- self.repository_selector.clone(),
- ButtonLike::new("active-repository")
- .style(ButtonStyle::Subtle)
- .child(Label::new(repository_display_name).size(LabelSize::Small)),
- Tooltip::text("Select a repository"),
- )
- }
-
pub fn can_commit(&self) -> bool {
(self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
}
@@ -1997,103 +1857,131 @@ impl GitPanel {
}
}
- pub fn render_commit_editor(
+ pub fn render_footer(
&self,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> impl IntoElement {
- let editor = self.commit_editor.clone();
- let can_commit = self.can_commit()
- && self.pending_commit.is_none()
- && !editor.read(cx).is_empty(cx)
- && self.has_write_access(cx);
-
+ ) -> Option<impl IntoElement> {
+ let project = self.project.clone().read(cx);
+ let active_repository = self.active_repository.clone();
let panel_editor_style = panel_editor_style(true, window, cx);
- let enable_coauthors = self.render_co_authors(cx);
- let tooltip = if self.has_staged_changes() {
- "git commit"
- } else {
- "git commit --all"
- };
- let title = if self.has_staged_changes() {
- "Commit"
- } else {
- "Commit Tracked"
- };
- let editor_focus_handle = self.commit_editor.focus_handle(cx);
+ if let Some(active_repo) = active_repository {
+ let editor = self.commit_editor.clone();
+ let can_commit = self.can_commit()
+ && self.pending_commit.is_none()
+ && !editor.read(cx).is_empty(cx)
+ && self.has_write_access(cx);
- let commit_button = panel_filled_button(title)
- .tooltip(move |window, cx| {
- Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
- })
- .disabled(!can_commit)
- .on_click({
- cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
- });
+ let enable_coauthors = self.render_co_authors(cx);
- let branch = self
- .active_repository
- .as_ref()
- .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
- .unwrap_or_else(|| "<no branch>".into());
-
- let branch_selector = Button::new("branch-selector", branch)
- .color(Color::Muted)
- .style(ButtonStyle::Subtle)
- .icon(IconName::GitBranch)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .size(ButtonSize::Compact)
- .icon_position(IconPosition::Start)
- .tooltip(Tooltip::for_action_title(
- "Switch Branch",
- &zed_actions::git::Branch,
- ))
- .on_click(cx.listener(|_, _, window, cx| {
- window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
- }))
- .style(ButtonStyle::Transparent);
+ let tooltip = if self.has_staged_changes() {
+ "git commit"
+ } else {
+ "git commit --all"
+ };
+ let title = if self.has_staged_changes() {
+ "Commit"
+ } else {
+ "Commit Tracked"
+ };
+ let editor_focus_handle = self.commit_editor.focus_handle(cx);
- let footer_size = px(32.);
- let gap = px(16.0);
+ let branch = active_repo.read(cx).current_branch()?;
- let max_height = window.line_height() * 6. + gap + footer_size;
+ let footer_size = px(32.);
+ let gap = px(8.0);
- panel_editor_container(window, cx)
- .id("commit-editor-container")
- .relative()
- .h(max_height)
- .w_full()
- .border_t_1()
- .border_color(cx.theme().colors().border)
- .bg(cx.theme().colors().editor_background)
- .cursor_text()
- .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
- window.focus(&this.commit_editor.focus_handle(cx));
- }))
- .when(!self.modal_open, |el| {
- el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
- .child(
- h_flex()
- .absolute()
- .bottom_0()
- .left_2()
- .h(footer_size)
- .flex_none()
- .child(branch_selector),
- )
- .child(
- h_flex()
- .absolute()
- .bottom_0()
- .right_2()
- .h(footer_size)
- .flex_none()
- .children(enable_coauthors)
- .child(commit_button),
- )
- })
+ let max_height = window.line_height() * 5. + gap + footer_size;
+
+ let expand_button_size = px(16.);
+
+ let git_panel = cx.entity().clone();
+ let display_name = SharedString::from(Arc::from(
+ active_repo
+ .read(cx)
+ .display_name(project, cx)
+ .trim_end_matches("/"),
+ ));
+ let footer = v_flex()
+ .child(PanelRepoHeader::new(
+ "header-button",
+ display_name,
+ Some(branch.clone()),
+ Some(git_panel),
+ ))
+ .child(
+ panel_editor_container(window, cx)
+ .id("commit-editor-container")
+ .relative()
+ .h(max_height)
+ // .w_full()
+ // .border_t_1()
+ // .border_color(cx.theme().colors().border)
+ .bg(cx.theme().colors().editor_background)
+ .cursor_text()
+ .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
+ window.focus(&this.commit_editor.focus_handle(cx));
+ }))
+ .child(
+ h_flex()
+ .id("commit-footer")
+ .absolute()
+ .bottom_0()
+ .right_2()
+ .h(footer_size)
+ .flex_none()
+ .children(enable_coauthors)
+ .child(
+ panel_filled_button(title)
+ .tooltip(move |window, cx| {
+ Tooltip::for_action_in(
+ tooltip,
+ &Commit,
+ &editor_focus_handle,
+ window,
+ cx,
+ )
+ })
+ .disabled(!can_commit || self.modal_open)
+ .on_click({
+ cx.listener(move |this, _: &ClickEvent, window, cx| {
+ this.commit_changes(window, cx)
+ })
+ }),
+ ),
+ )
+ // .when(!self.modal_open, |el| {
+ .child(EditorElement::new(&self.commit_editor, panel_editor_style))
+ .child(
+ div()
+ .absolute()
+ .top_1()
+ .right_2()
+ .opacity(0.5)
+ .hover(|this| this.opacity(1.0))
+ .w(expand_button_size)
+ .child(
+ panel_icon_button("expand-commit-editor", IconName::Maximize)
+ .icon_size(IconSize::Small)
+ .style(ButtonStyle::Transparent)
+ .width(expand_button_size.into())
+ .on_click(cx.listener({
+ move |_, _, window, cx| {
+ window.dispatch_action(
+ git::ExpandCommitEditor.boxed_clone(),
+ cx,
+ )
+ }
+ })),
+ ),
+ ),
+ );
+
+ Some(footer)
+ } else {
+ None
+ }
}
fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
@@ -2105,10 +1993,10 @@ impl GitPanel {
Some(
h_flex()
.items_center()
- .py_1p5()
+ .py_2()
.px(px(8.))
- .bg(cx.theme().colors().background)
- .border_t_1()
+ // .bg(cx.theme().colors().background)
+ // .border_t_1()
.border_color(cx.theme().colors().border)
.gap_1p5()
.child(
@@ -2135,11 +2023,9 @@ impl GitPanel {
)
.child(div().flex_1())
.child(
- panel_filled_button("Uncommit")
- .icon(IconName::Undo)
+ panel_icon_button("undo", IconName::Undo)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
- .icon_position(IconPosition::Start)
.tooltip(Tooltip::for_action_title(
if self.has_staged_changes() {
"git reset HEAD^ --soft"
@@ -2149,8 +2035,7 @@ impl GitPanel {
&git::Uncommit,
))
.on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
- )
- .child(self.render_push_button(branch, cx)),
+ ),
)
}
@@ -2228,7 +2113,7 @@ impl GitPanel {
)
}
- pub fn render_buffer_header_controls(
+ fn render_buffer_header_controls(
&self,
entity: &Entity<Self>,
file: &Arc<dyn File>,
@@ -2402,26 +2287,13 @@ impl GitPanel {
self.set_context_menu(context_menu, position, window, cx);
}
- fn panel_context_menu(window: &mut Window, cx: &mut App) -> Entity<ContextMenu> {
- ContextMenu::build(window, cx, |context_menu, _, _| {
- context_menu
- .action("Stage All", StageAll.boxed_clone())
- .action("Unstage All", UnstageAll.boxed_clone())
- .separator()
- .action("Open Diff", project_diff::Diff.boxed_clone())
- .separator()
- .action("Restore Tracked Files", RestoreTrackedFiles.boxed_clone())
- .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
- })
- }
-
fn deploy_panel_context_menu(
&mut self,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
- let context_menu = Self::panel_context_menu(window, cx);
+ let context_menu = git_panel_context_menu(window, cx);
self.set_context_menu(context_menu, position, window, cx);
}
@@ -2584,69 +2456,6 @@ impl GitPanel {
.into_any_element()
}
- fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
- let mut disabled = false;
-
- // TODO: Add <origin> and <branch> argument substitutions to this
- let button: SharedString;
- let tooltip: SharedString;
- let action: Option<Push>;
- if let Some(upstream) = &branch.upstream {
- match upstream.tracking {
- UpstreamTracking::Gone => {
- button = "Republish".into();
- tooltip = "git push --set-upstream".into();
- action = Some(git::Push {
- options: Some(PushOptions::SetUpstream),
- });
- }
- UpstreamTracking::Tracked(tracking) => {
- if tracking.behind > 0 {
- disabled = true;
- button = "Push".into();
- tooltip = "Upstream is ahead of local branch".into();
- action = None;
- } else if tracking.ahead > 0 {
- button = format!("Push ({})", tracking.ahead).into();
- tooltip = "git push".into();
- action = Some(git::Push { options: None });
- } else {
- disabled = true;
- button = "Push".into();
- tooltip = "Upstream matches local branch".into();
- action = None;
- }
- }
- }
- } else {
- button = "Publish".into();
- tooltip = "git push --set-upstream".into();
- action = Some(git::Push {
- options: Some(PushOptions::SetUpstream),
- });
- };
-
- panel_filled_button(button)
- .icon(IconName::ArrowUp)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .icon_position(IconPosition::Start)
- .disabled(disabled)
- .when_some(action, |this, action| {
- this.on_click(
- cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
- )
- })
- .tooltip(move |window, cx| {
- if let Some(action) = action.as_ref() {
- Tooltip::for_action(tooltip.clone(), action, window, cx)
- } else {
- Tooltip::simple(tooltip.clone(), cx)
- }
- })
- .into_any_element()
- }
-
fn has_write_access(&self, cx: &App) -> bool {
!self.project.read(cx).is_read_only(cx)
}
@@ -2718,7 +2527,6 @@ impl Render for GitPanel {
.child(
v_flex()
.size_full()
- .children(self.render_panel_header(window, cx))
.map(|this| {
if has_entries {
this.child(self.render_entries(has_write_access, window, cx))
@@ -2726,8 +2534,8 @@ impl Render for GitPanel {
this.child(self.render_empty_state(cx).into_any_element())
}
})
+ .children(self.render_footer(window, cx))
.children(self.render_previous_commit(cx))
- .child(self.render_commit_editor(window, cx))
.into_any_element(),
)
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
@@ -2881,3 +2689,618 @@ impl Render for GitPanelMessageTooltip {
}
}
}
+
+fn git_action_tooltip(
+ label: impl Into<SharedString>,
+ action: &dyn Action,
+ command: impl Into<SharedString>,
+ focus_handle: Option<FocusHandle>,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyView {
+ let label = label.into();
+ let command = command.into();
+
+ if let Some(handle) = focus_handle {
+ Tooltip::with_meta_in(
+ label.clone(),
+ Some(action),
+ command.clone(),
+ &handle,
+ window,
+ cx,
+ )
+ } else {
+ Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
+ }
+}
+
+#[derive(IntoElement)]
+struct SplitButton {
+ pub left: ButtonLike,
+ pub right: AnyElement,
+}
+
+impl SplitButton {
+ fn new(
+ id: impl Into<SharedString>,
+ left_label: impl Into<SharedString>,
+ ahead_count: usize,
+ behind_count: usize,
+ left_icon: Option<IconName>,
+ left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
+ ) -> Self {
+ let id = id.into();
+
+ fn count(count: usize) -> impl IntoElement {
+ h_flex()
+ .ml_neg_px()
+ .h(rems(0.875))
+ .items_center()
+ .overflow_hidden()
+ .px_0p5()
+ .child(
+ Label::new(count.to_string())
+ .size(LabelSize::XSmall)
+ .line_height_style(LineHeightStyle::UiLabel),
+ )
+ }
+
+ let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
+
+ let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
+ format!("split-button-left-{}", id).into(),
+ ))
+ .layer(ui::ElevationIndex::ModalSurface)
+ .size(ui::ButtonSize::Compact)
+ .when(should_render_counts, |this| {
+ this.child(
+ h_flex()
+ .ml_neg_0p5()
+ .mr_1()
+ .when(behind_count > 0, |this| {
+ this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
+ .child(count(behind_count))
+ })
+ .when(ahead_count > 0, |this| {
+ this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
+ .child(count(ahead_count))
+ }),
+ )
+ })
+ .when_some(left_icon, |this, left_icon| {
+ this.child(
+ h_flex()
+ .ml_neg_0p5()
+ .mr_1()
+ .child(Icon::new(left_icon).size(IconSize::XSmall)),
+ )
+ })
+ .child(
+ div()
+ .child(Label::new(left_label).size(LabelSize::Small))
+ .mr_0p5(),
+ )
+ .on_click(left_on_click)
+ .tooltip(tooltip);
+
+ let right =
+ render_git_action_menu(ElementId::Name(format!("split-button-right-{}", id).into()))
+ .into_any_element();
+ // .on_click(right_on_click);
+
+ Self { left, right }
+ }
+}
+
+impl RenderOnce for SplitButton {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ h_flex()
+ .rounded_md()
+ .border_1()
+ .border_color(cx.theme().colors().text_muted.alpha(0.12))
+ .child(self.left)
+ .child(
+ div()
+ .h_full()
+ .w_px()
+ .bg(cx.theme().colors().text_muted.alpha(0.16)),
+ )
+ .child(self.right)
+ .bg(ElevationIndex::Surface.on_elevation_bg(cx))
+ .shadow(smallvec![BoxShadow {
+ color: hsla(0.0, 0.0, 0.0, 0.16),
+ offset: point(px(0.), px(1.)),
+ blur_radius: px(0.),
+ spread_radius: px(0.),
+ }])
+ }
+}
+
+fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
+ PopoverMenu::new(id.into())
+ .trigger(
+ ui::ButtonLike::new_rounded_right("split-button-right")
+ .layer(ui::ElevationIndex::ModalSurface)
+ .size(ui::ButtonSize::None)
+ .child(
+ div()
+ .px_1()
+ .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+ ),
+ )
+ .menu(move |window, cx| {
+ Some(ContextMenu::build(window, cx, |context_menu, _, _| {
+ context_menu
+ .action("Fetch", git::Fetch.boxed_clone())
+ .action("Pull", git::Pull.boxed_clone())
+ .separator()
+ .action("Push", git::Push { options: None }.boxed_clone())
+ .action(
+ "Force Push",
+ git::Push {
+ options: Some(PushOptions::Force),
+ }
+ .boxed_clone(),
+ )
+ }))
+ })
+ .anchor(Corner::TopRight)
+}
+
+#[derive(IntoElement, IntoComponent)]
+#[component(scope = "git_panel")]
+pub struct PanelRepoHeader {
+ id: SharedString,
+ active_repository: SharedString,
+ branch: Option<Branch>,
+ // Getting a GitPanel in previews will be difficult.
+ //
+ // For now just take an option here, and we won't bind handlers to buttons in previews.
+ git_panel: Option<Entity<GitPanel>>,
+}
+
+impl PanelRepoHeader {
+ pub fn new(
+ id: impl Into<SharedString>,
+ active_repository: SharedString,
+ branch: Option<Branch>,
+ git_panel: Option<Entity<GitPanel>>,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ active_repository,
+ branch,
+ git_panel,
+ }
+ }
+
+ pub fn new_preview(
+ id: impl Into<SharedString>,
+ active_repository: SharedString,
+ branch: Option<Branch>,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ active_repository,
+ branch,
+ git_panel: None,
+ }
+ }
+
+ fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
+ PopoverMenu::new(id.into())
+ .trigger(
+ IconButton::new("overflow-menu-trigger", IconName::EllipsisVertical)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted),
+ )
+ .menu(move |window, cx| Some(git_panel_context_menu(window, cx)))
+ .anchor(Corner::TopRight)
+ }
+
+ fn panel_focus_handle(&self, cx: &App) -> Option<FocusHandle> {
+ if let Some(git_panel) = self.git_panel.clone() {
+ Some(git_panel.focus_handle(cx))
+ } else {
+ None
+ }
+ }
+
+ fn render_push_button(&self, id: SharedString, ahead: u32, cx: &mut App) -> SplitButton {
+ let panel_focus_handle = self.panel_focus_handle(cx);
+
+ SplitButton::new(
+ id,
+ "Push",
+ ahead as usize,
+ 0,
+ None,
+ |_, _, cx| cx.dispatch_action(&git::Push { options: None }),
+ move |window, cx| {
+ git_action_tooltip(
+ "Push committed changes to remote",
+ &git::Push { options: None },
+ "git push",
+ panel_focus_handle.clone(),
+ window,
+ cx,
+ )
+ },
+ )
+ }
+
+ fn render_pull_button(
+ &self,
+ id: SharedString,
+ ahead: u32,
+ behind: u32,
+ cx: &mut App,
+ ) -> SplitButton {
+ let panel_focus_handle = self.panel_focus_handle(cx);
+
+ SplitButton::new(
+ id,
+ "Pull",
+ ahead as usize,
+ behind as usize,
+ None,
+ |_, _, cx| cx.dispatch_action(&git::Pull),
+ move |window, cx| {
+ git_action_tooltip(
+ "Pull",
+ &git::Pull,
+ "git pull",
+ panel_focus_handle.clone(),
+ window,
+ cx,
+ )
+ },
+ )
+ }
+
+ fn render_fetch_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
+ let panel_focus_handle = self.panel_focus_handle(cx);
+
+ SplitButton::new(
+ id,
+ "Fetch",
+ 0,
+ 0,
+ Some(IconName::ArrowCircle),
+ |_, _, cx| cx.dispatch_action(&git::Fetch),
+ move |window, cx| {
+ git_action_tooltip(
+ "Fetch updates from remote",
+ &git::Fetch,
+ "git fetch",
+ panel_focus_handle.clone(),
+ window,
+ cx,
+ )
+ },
+ )
+ }
+
+ fn render_publish_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
+ let panel_focus_handle = self.panel_focus_handle(cx);
+
+ SplitButton::new(
+ id,
+ "Publish",
+ 0,
+ 0,
+ Some(IconName::ArrowUpFromLine),
+ |_, _, cx| {
+ cx.dispatch_action(&git::Push {
+ options: Some(PushOptions::SetUpstream),
+ })
+ },
+ move |window, cx| {
+ git_action_tooltip(
+ "Publish branch to remote",
+ &git::Push {
+ options: Some(PushOptions::SetUpstream),
+ },
+ "git push --set-upstream",
+ panel_focus_handle.clone(),
+ window,
+ cx,
+ )
+ },
+ )
+ }
+
+ fn render_republish_button(&self, id: SharedString, cx: &mut App) -> SplitButton {
+ let panel_focus_handle = self.panel_focus_handle(cx);
+
+ SplitButton::new(
+ id,
+ "Republish",
+ 0,
+ 0,
+ Some(IconName::ArrowUpFromLine),
+ |_, _, cx| {
+ cx.dispatch_action(&git::Push {
+ options: Some(PushOptions::SetUpstream),
+ })
+ },
+ move |window, cx| {
+ git_action_tooltip(
+ "Re-publish branch to remote",
+ &git::Push {
+ options: Some(PushOptions::SetUpstream),
+ },
+ "git push --set-upstream",
+ panel_focus_handle.clone(),
+ window,
+ cx,
+ )
+ },
+ )
+ }
+
+ fn render_relevant_button(
+ &self,
+ id: impl Into<SharedString>,
+ branch: &Branch,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ let id = id.into();
+ let upstream = branch.upstream.as_ref();
+ match upstream {
+ Some(Upstream {
+ tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
+ ..
+ }) => match (*ahead, *behind) {
+ (0, 0) => self.render_fetch_button(id, cx),
+ (ahead, 0) => self.render_push_button(id, ahead, cx),
+ (ahead, behind) => self.render_pull_button(id, ahead, behind, cx),
+ },
+ Some(Upstream {
+ tracking: UpstreamTracking::Gone,
+ ..
+ }) => self.render_republish_button(id, cx),
+ None => self.render_publish_button(id, cx),
+ }
+ }
+}
+
+impl RenderOnce for PanelRepoHeader {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let active_repo = self.active_repository.clone();
+ let overflow_menu_id: SharedString = format!("overflow-menu-{}", active_repo).into();
+
+ let repo_selector = if let Some(panel) = self.git_panel.clone() {
+ RepositorySelectorPopoverMenu::new(
+ panel.read(cx).repository_selector.clone(),
+ Button::new("repo-selector", active_repo.clone())
+ .style(ButtonStyle::Transparent)
+ .size(ButtonSize::None)
+ .label_size(LabelSize::Small)
+ .color(Color::Muted),
+ Tooltip::text("Choose a repository"),
+ )
+ .into_any_element()
+ } else {
+ Button::new("repo-selector", active_repo.clone())
+ .style(ButtonStyle::Transparent)
+ .size(ButtonSize::None)
+ .label_size(LabelSize::Small)
+ .color(Color::Muted)
+ .into_any_element()
+ };
+
+ let branch = self.branch.clone();
+ let branch_name = branch
+ .as_ref()
+ .map_or("<no branch>".into(), |branch| branch.name.clone());
+
+ let branch_selector = Button::new("branch-selector", branch_name)
+ .style(ButtonStyle::Transparent)
+ .size(ButtonSize::None)
+ .label_size(LabelSize::Small)
+ .tooltip(Tooltip::for_action_title(
+ "Switch Branch",
+ &zed_actions::git::Branch,
+ ))
+ .on_click(|_, window, cx| {
+ window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+ });
+
+ let spinner = self
+ .git_panel
+ .as_ref()
+ .and_then(|git_panel| git_panel.read(cx).render_spinner());
+
+ h_flex()
+ .w_full()
+ .px_2()
+ .h(px(36.))
+ .items_center()
+ .justify_between()
+ .child(
+ h_flex()
+ .relative()
+ .items_center()
+ .gap_0p5()
+ .child(
+ div()
+ // .when(repo_or_branch_has_uppercase, |this| {
+ // this.relative().pt(px(2.))
+ // })
+ .child(
+ Icon::new(IconName::GitBranchSmall)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child(
+ h_flex()
+ .gap_0p5()
+ .child(repo_selector)
+ .child(
+ div()
+ .text_color(cx.theme().colors().text_muted)
+ .text_sm()
+ .child("/"),
+ )
+ .child(branch_selector),
+ ),
+ )
+ .child(
+ h_flex()
+ .gap_1()
+ .children(spinner)
+ .child(self.render_overflow_menu(overflow_menu_id))
+ .when_some(branch, |this, branch| {
+ let button = self.render_relevant_button(self.id.clone(), &branch, cx);
+ this.child(button)
+ }),
+ )
+ }
+}
+
+impl ComponentPreview for PanelRepoHeader {
+ fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
+ let unknown_upstream = None;
+ let no_remote_upstream = Some(UpstreamTracking::Gone);
+ let ahead_of_upstream = Some(
+ UpstreamTrackingStatus {
+ ahead: 2,
+ behind: 0,
+ }
+ .into(),
+ );
+ let behind_upstream = Some(
+ UpstreamTrackingStatus {
+ ahead: 0,
+ behind: 2,
+ }
+ .into(),
+ );
+ let ahead_and_behind_upstream = Some(
+ UpstreamTrackingStatus {
+ ahead: 3,
+ behind: 1,
+ }
+ .into(),
+ );
+
+ let not_ahead_or_behind_upstream = Some(
+ UpstreamTrackingStatus {
+ ahead: 0,
+ behind: 0,
+ }
+ .into(),
+ );
+
+ fn branch(upstream: Option<UpstreamTracking>) -> Branch {
+ Branch {
+ is_head: true,
+ name: "some-branch".into(),
+ upstream: upstream.map(|tracking| Upstream {
+ ref_name: "origin/some-branch".into(),
+ tracking,
+ }),
+ most_recent_commit: Some(CommitSummary {
+ sha: "abc123".into(),
+ subject: "Modify stuff".into(),
+ commit_timestamp: 1710932954,
+ }),
+ }
+ }
+
+ fn active_repository(id: usize) -> SharedString {
+ format!("repo-{}", id).into()
+ }
+
+ v_flex()
+ .gap_6()
+ .children(vec![example_group_with_title(
+ "Action Button States",
+ vec![
+ single_example(
+ "No Branch",
+ div()
+ .w(px(180.))
+ .child(PanelRepoHeader::new_preview(
+ "no-branch",
+ active_repository(1).clone(),
+ None,
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "Remote status unknown",
+ div()
+ .w(px(180.))
+ .child(PanelRepoHeader::new_preview(
+ "unknown-upstream",
+ active_repository(2).clone(),
+ Some(branch(unknown_upstream)),
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "No Remote Upstream",
+ div()
+ .w(px(180.))
+ .child(PanelRepoHeader::new_preview(
+ "no-remote-upstream",
+ active_repository(3).clone(),
+ Some(branch(no_remote_upstream)),
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "Not Ahead or Behind",
+ div()
+ .w(px(180.))
+ .child(PanelRepoHeader::new_preview(
+ "not-ahead-or-behind",
+ active_repository(4).clone(),
+ Some(branch(not_ahead_or_behind_upstream)),
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "Behind remote",
+ div()
+ .w(px(180.))
+ .child(PanelRepoHeader::new_preview(
+ "behind-remote",
+ active_repository(5).clone(),
+ Some(branch(behind_upstream)),
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "Ahead of remote",
+ div()
+ .w(px(180.))
+ .child(PanelRepoHeader::new_preview(
+ "ahead-of-remote",
+ active_repository(6).clone(),
+ Some(branch(ahead_of_upstream)),
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "Ahead and behind remote",
+ div()
+ .w(px(180.))
+ .child(PanelRepoHeader::new_preview(
+ "ahead-and-behind",
+ active_repository(7).clone(),
+ Some(branch(ahead_and_behind_upstream)),
+ ))
+ .into_any_element(),
+ ),
+ ],
+ )
+ .vertical()])
+ .into_any_element()
+ }
+}