Follow up to https://github.com/zed-industries/zed/pull/49807 Previously, when there were multiple conflicts across the codebase, we would pop a toast at the bottom right corner of the UI. A toast seemed like a functional idea because it'd be visible from any state of the app and thus it'd be a good place to expose the button that allows you to quickly prompt the agent to resolve all conflicts, as opposed to creating a thread for each individual one. However, the toast was met with some negative (and correct) feedback, mostly because it is interruptive, and thus can sometimes block very relevant surfaces, like either the agent panel itself or the Git commit area. Therefore, in this PR, I'm removing the toast and adding a button in the status bar instead; a bit more minimal, not interruptive, and a common place for other items that might require your attention. The status bar can be quite busy these days, though; we can display diagnostics, LSP status, and file names in there; conscious of that. But it felt like it could work given this button is such a transient one that you can either easily manually dismiss or wait for it to be auto-dismissed as you or the agent resolves the merge conflicts. <img width="500" height="864" alt="Screenshot 2026-04-02 at 9 15@2x" src="https://github.com/user-attachments/assets/4412a05c-77d0-4391-8ea1-25d1749b5e20" /> Release Notes: - Git: Improved how we surface the affordance to resolve codebase-wide merge conflicts with the agent in the UI. - Agent: Added a setting to control whether or not the button to resolve merge conflicts with the agent should be displayed.
899 lines
30 KiB
Rust
899 lines
30 KiB
Rust
use anyhow::anyhow;
|
|
use commit_modal::CommitModal;
|
|
use editor::{Editor, actions::DiffClipboardWithSelectionData};
|
|
|
|
use project::ProjectPath;
|
|
use ui::{
|
|
Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled,
|
|
StyledExt, div, h_flex, rems, v_flex,
|
|
};
|
|
|
|
mod blame_ui;
|
|
pub mod clone;
|
|
|
|
use git::{
|
|
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
|
|
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
|
|
};
|
|
use gpui::{
|
|
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, Window,
|
|
};
|
|
use menu::{Cancel, Confirm};
|
|
use project::git_store::Repository;
|
|
use project_diff::ProjectDiff;
|
|
use ui::prelude::*;
|
|
use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
|
|
use zed_actions;
|
|
|
|
use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
|
|
|
|
mod askpass_modal;
|
|
pub mod branch_picker;
|
|
mod commit_modal;
|
|
pub mod commit_tooltip;
|
|
pub mod commit_view;
|
|
mod conflict_view;
|
|
pub mod file_diff_view;
|
|
pub mod file_history_view;
|
|
pub mod git_panel;
|
|
mod git_panel_settings;
|
|
pub mod git_picker;
|
|
pub mod multi_diff_view;
|
|
pub mod picker_prompt;
|
|
pub mod project_diff;
|
|
pub(crate) mod remote_output;
|
|
pub mod repository_selector;
|
|
pub mod stash_picker;
|
|
pub mod text_diff_view;
|
|
pub mod worktree_picker;
|
|
|
|
pub use conflict_view::MergeConflictIndicator;
|
|
|
|
pub fn init(cx: &mut App) {
|
|
editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
|
|
commit_view::init(cx);
|
|
|
|
cx.observe_new(|editor: &mut Editor, _, cx| {
|
|
conflict_view::register_editor(editor, editor.buffer().clone(), cx);
|
|
})
|
|
.detach();
|
|
|
|
cx.observe_new(|workspace: &mut Workspace, _, cx| {
|
|
ProjectDiff::register(workspace, cx);
|
|
CommitModal::register(workspace);
|
|
git_panel::register(workspace);
|
|
repository_selector::register(workspace);
|
|
git_picker::register(workspace);
|
|
|
|
let project = workspace.project().read(cx);
|
|
if project.is_read_only(cx) {
|
|
return;
|
|
}
|
|
if !project.is_via_collab() {
|
|
workspace.register_action(
|
|
|workspace, _: &zed_actions::git::CreatePullRequest, window, cx| {
|
|
if let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) {
|
|
panel.update(cx, |panel, cx| {
|
|
panel.create_pull_request(window, cx);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.fetch(true, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.fetch(false, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::Push, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.push(false, false, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.push(false, true, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.push(true, false, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::Pull, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.pull(false, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::PullRebase, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.pull(true, window, cx);
|
|
});
|
|
});
|
|
}
|
|
workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.stash_all(action, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.stash_pop(action, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, action: &git::StashApply, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.stash_apply(action, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.stage_all(action, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.unstage_all(action, window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::Uncommit, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.uncommit(window, cx);
|
|
})
|
|
});
|
|
workspace.register_action(|workspace, _action: &git::Init, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
panel.update(cx, |panel, cx| {
|
|
panel.git_init(window, cx);
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
|
|
workspace.toggle_modal(window, cx, |window, cx| {
|
|
GitCloneModal::show(panel, window, cx)
|
|
});
|
|
});
|
|
workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
|
|
open_modified_files(workspace, window, cx);
|
|
});
|
|
workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| {
|
|
rename_current_branch(workspace, window, cx);
|
|
});
|
|
workspace.register_action(
|
|
|workspace, action: &DiffClipboardWithSelectionData, window, cx| {
|
|
if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
|
|
task.detach();
|
|
};
|
|
},
|
|
);
|
|
workspace.register_action(|workspace, _: &git::FileHistory, window, cx| {
|
|
let Some(active_item) = workspace.active_item(cx) else {
|
|
return;
|
|
};
|
|
let Some(editor) = active_item.downcast::<Editor>() else {
|
|
return;
|
|
};
|
|
let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
|
|
return;
|
|
};
|
|
let Some(file) = buffer.read(cx).file() else {
|
|
return;
|
|
};
|
|
let worktree_id = file.worktree_id(cx);
|
|
let project_path = ProjectPath {
|
|
worktree_id,
|
|
path: file.path().clone(),
|
|
};
|
|
let project = workspace.project();
|
|
let git_store = project.read(cx).git_store();
|
|
let Some((repo, repo_path)) = git_store
|
|
.read(cx)
|
|
.repository_and_path_for_project_path(&project_path, cx)
|
|
else {
|
|
return;
|
|
};
|
|
file_history_view::FileHistoryView::open(
|
|
repo_path,
|
|
git_store.downgrade(),
|
|
repo.downgrade(),
|
|
workspace.weak_handle(),
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn open_modified_files(
|
|
workspace: &mut Workspace,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
|
|
let Some(repo) = panel.active_repository.as_ref() else {
|
|
return Vec::new();
|
|
};
|
|
let repo = repo.read(cx);
|
|
repo.cached_status()
|
|
.filter_map(|entry| {
|
|
if entry.status.is_modified() {
|
|
repo.repo_path_to_project_path(&entry.repo_path, cx)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
});
|
|
for path in modified_paths {
|
|
workspace.open_path(path, None, true, window, cx).detach();
|
|
}
|
|
}
|
|
|
|
/// Resolves the repository for git operations, respecting the workspace's
|
|
/// active worktree override from the project dropdown.
|
|
pub fn resolve_active_repository(workspace: &Workspace, cx: &App) -> Option<Entity<Repository>> {
|
|
let project = workspace.project().read(cx);
|
|
workspace
|
|
.active_worktree_override()
|
|
.and_then(|override_id| {
|
|
project
|
|
.worktree_for_id(override_id, cx)
|
|
.and_then(|worktree| {
|
|
let worktree_abs_path = worktree.read(cx).abs_path();
|
|
let git_store = project.git_store().read(cx);
|
|
git_store
|
|
.repositories()
|
|
.values()
|
|
.filter(|repo| {
|
|
let repo_path = &repo.read(cx).work_directory_abs_path;
|
|
*repo_path == worktree_abs_path
|
|
|| worktree_abs_path.starts_with(repo_path.as_ref())
|
|
})
|
|
.max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
|
|
.cloned()
|
|
})
|
|
})
|
|
.or_else(|| project.active_repository(cx))
|
|
}
|
|
|
|
pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
|
|
GitStatusIcon::new(status)
|
|
}
|
|
|
|
struct RenameBranchModal {
|
|
current_branch: SharedString,
|
|
editor: Entity<Editor>,
|
|
repo: Entity<Repository>,
|
|
}
|
|
|
|
impl RenameBranchModal {
|
|
fn new(
|
|
current_branch: String,
|
|
repo: Entity<Repository>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let editor = cx.new(|cx| {
|
|
let mut editor = Editor::single_line(window, cx);
|
|
editor.set_text(current_branch.clone(), window, cx);
|
|
editor
|
|
});
|
|
Self {
|
|
current_branch: current_branch.into(),
|
|
editor,
|
|
repo,
|
|
}
|
|
}
|
|
|
|
fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
|
|
cx.emit(DismissEvent);
|
|
}
|
|
|
|
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
|
let new_name = self.editor.read(cx).text(cx);
|
|
if new_name.is_empty() || new_name == self.current_branch.as_ref() {
|
|
cx.emit(DismissEvent);
|
|
return;
|
|
}
|
|
|
|
let repo = self.repo.clone();
|
|
let current_branch = self.current_branch.to_string();
|
|
cx.spawn(async move |_, cx| {
|
|
match repo
|
|
.update(cx, |repo, _| {
|
|
repo.rename_branch(current_branch, new_name.clone())
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(_)) => Ok(()),
|
|
Ok(Err(error)) => Err(error),
|
|
Err(_) => Err(anyhow!("Operation was canceled")),
|
|
}
|
|
})
|
|
.detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
|
|
cx.emit(DismissEvent);
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<DismissEvent> for RenameBranchModal {}
|
|
impl ModalView for RenameBranchModal {}
|
|
impl Focusable for RenameBranchModal {
|
|
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
self.editor.focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl Render for RenameBranchModal {
|
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
v_flex()
|
|
.key_context("RenameBranchModal")
|
|
.on_action(cx.listener(Self::cancel))
|
|
.on_action(cx.listener(Self::confirm))
|
|
.elevation_2(cx)
|
|
.w(rems(34.))
|
|
.child(
|
|
h_flex()
|
|
.px_3()
|
|
.pt_2()
|
|
.pb_1()
|
|
.w_full()
|
|
.gap_1p5()
|
|
.child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
|
|
.child(
|
|
Headline::new(format!("Rename Branch ({})", self.current_branch))
|
|
.size(HeadlineSize::XSmall),
|
|
),
|
|
)
|
|
.child(div().px_3().pb_3().w_full().child(self.editor.clone()))
|
|
}
|
|
}
|
|
|
|
fn rename_current_branch(
|
|
workspace: &mut Workspace,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) {
|
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
|
return;
|
|
};
|
|
let current_branch: Option<String> = panel.update(cx, |panel, cx| {
|
|
let repo = panel.active_repository.as_ref()?;
|
|
let repo = repo.read(cx);
|
|
repo.branch.as_ref().map(|branch| branch.name().to_string())
|
|
});
|
|
|
|
let Some(current_branch_name) = current_branch else {
|
|
return;
|
|
};
|
|
|
|
let repo = panel.read(cx).active_repository.clone();
|
|
let Some(repo) = repo else {
|
|
return;
|
|
};
|
|
|
|
workspace.toggle_modal(window, cx, |window, cx| {
|
|
RenameBranchModal::new(current_branch_name, repo, window, cx)
|
|
});
|
|
}
|
|
|
|
fn render_remote_button(
|
|
id: impl Into<SharedString>,
|
|
branch: &Branch,
|
|
keybinding_target: Option<FocusHandle>,
|
|
show_fetch_button: bool,
|
|
) -> Option<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) if show_fetch_button => {
|
|
Some(remote_button::render_fetch_button(keybinding_target, id))
|
|
}
|
|
(0, 0) => None,
|
|
(ahead, 0) => Some(remote_button::render_push_button(
|
|
keybinding_target,
|
|
id,
|
|
ahead,
|
|
)),
|
|
(ahead, behind) => Some(remote_button::render_pull_button(
|
|
keybinding_target,
|
|
id,
|
|
ahead,
|
|
behind,
|
|
)),
|
|
},
|
|
Some(Upstream {
|
|
tracking: UpstreamTracking::Gone,
|
|
..
|
|
}) => Some(remote_button::render_republish_button(
|
|
keybinding_target,
|
|
id,
|
|
)),
|
|
None => Some(remote_button::render_publish_button(keybinding_target, id)),
|
|
}
|
|
}
|
|
|
|
mod remote_button {
|
|
use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
|
|
use ui::{
|
|
App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
|
|
IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
|
|
PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
|
|
};
|
|
|
|
pub fn render_fetch_button(
|
|
keybinding_target: Option<FocusHandle>,
|
|
id: SharedString,
|
|
) -> SplitButton {
|
|
split_button(
|
|
id,
|
|
"Fetch",
|
|
0,
|
|
0,
|
|
Some(IconName::ArrowCircle),
|
|
keybinding_target.clone(),
|
|
move |_, window, cx| {
|
|
window.dispatch_action(Box::new(git::Fetch), cx);
|
|
},
|
|
move |_window, cx| {
|
|
git_action_tooltip(
|
|
"Fetch updates from remote",
|
|
&git::Fetch,
|
|
"git fetch",
|
|
keybinding_target.clone(),
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn render_push_button(
|
|
keybinding_target: Option<FocusHandle>,
|
|
id: SharedString,
|
|
ahead: u32,
|
|
) -> SplitButton {
|
|
split_button(
|
|
id,
|
|
"Push",
|
|
ahead as usize,
|
|
0,
|
|
None,
|
|
keybinding_target.clone(),
|
|
move |_, window, cx| {
|
|
window.dispatch_action(Box::new(git::Push), cx);
|
|
},
|
|
move |_window, cx| {
|
|
git_action_tooltip(
|
|
"Push committed changes to remote",
|
|
&git::Push,
|
|
"git push",
|
|
keybinding_target.clone(),
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn render_pull_button(
|
|
keybinding_target: Option<FocusHandle>,
|
|
id: SharedString,
|
|
ahead: u32,
|
|
behind: u32,
|
|
) -> SplitButton {
|
|
split_button(
|
|
id,
|
|
"Pull",
|
|
ahead as usize,
|
|
behind as usize,
|
|
None,
|
|
keybinding_target.clone(),
|
|
move |_, window, cx| {
|
|
window.dispatch_action(Box::new(git::Pull), cx);
|
|
},
|
|
move |_window, cx| {
|
|
git_action_tooltip(
|
|
"Pull",
|
|
&git::Pull,
|
|
"git pull",
|
|
keybinding_target.clone(),
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn render_publish_button(
|
|
keybinding_target: Option<FocusHandle>,
|
|
id: SharedString,
|
|
) -> SplitButton {
|
|
split_button(
|
|
id,
|
|
"Publish",
|
|
0,
|
|
0,
|
|
Some(IconName::ExpandUp),
|
|
keybinding_target.clone(),
|
|
move |_, window, cx| {
|
|
window.dispatch_action(Box::new(git::Push), cx);
|
|
},
|
|
move |_window, cx| {
|
|
git_action_tooltip(
|
|
"Publish branch to remote",
|
|
&git::Push,
|
|
"git push --set-upstream",
|
|
keybinding_target.clone(),
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn render_republish_button(
|
|
keybinding_target: Option<FocusHandle>,
|
|
id: SharedString,
|
|
) -> SplitButton {
|
|
split_button(
|
|
id,
|
|
"Republish",
|
|
0,
|
|
0,
|
|
Some(IconName::ExpandUp),
|
|
keybinding_target.clone(),
|
|
move |_, window, cx| {
|
|
window.dispatch_action(Box::new(git::Push), cx);
|
|
},
|
|
move |_window, cx| {
|
|
git_action_tooltip(
|
|
"Re-publish branch to remote",
|
|
&git::Push,
|
|
"git push --set-upstream",
|
|
keybinding_target.clone(),
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
fn git_action_tooltip(
|
|
label: impl Into<SharedString>,
|
|
action: &dyn Action,
|
|
command: impl Into<SharedString>,
|
|
focus_handle: Option<FocusHandle>,
|
|
cx: &mut App,
|
|
) -> AnyView {
|
|
let label = label.into();
|
|
let command = command.into();
|
|
|
|
if let Some(handle) = focus_handle {
|
|
Tooltip::with_meta_in(label, Some(action), command, &handle, cx)
|
|
} else {
|
|
Tooltip::with_meta(label, Some(action), command, cx)
|
|
}
|
|
}
|
|
|
|
fn render_git_action_menu(
|
|
id: impl Into<ElementId>,
|
|
keybinding_target: Option<FocusHandle>,
|
|
) -> 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::ChevronDown).size(IconSize::XSmall)),
|
|
),
|
|
)
|
|
.menu(move |window, cx| {
|
|
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
|
|
context_menu
|
|
.when_some(keybinding_target.clone(), |el, keybinding_target| {
|
|
el.context(keybinding_target)
|
|
})
|
|
.action("Fetch", git::Fetch.boxed_clone())
|
|
.action("Fetch From", git::FetchFrom.boxed_clone())
|
|
.action("Pull", git::Pull.boxed_clone())
|
|
.action("Pull (Rebase)", git::PullRebase.boxed_clone())
|
|
.separator()
|
|
.action("Push", git::Push.boxed_clone())
|
|
.action("Push To", git::PushTo.boxed_clone())
|
|
.action("Force Push", git::ForcePush.boxed_clone())
|
|
}))
|
|
})
|
|
.anchor(Corner::TopRight)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn split_button(
|
|
id: SharedString,
|
|
left_label: impl Into<SharedString>,
|
|
ahead_count: usize,
|
|
behind_count: usize,
|
|
left_icon: Option<IconName>,
|
|
keybinding_target: Option<FocusHandle>,
|
|
left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
|
tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
|
|
) -> SplitButton {
|
|
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()
|
|
.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()
|
|
.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()),
|
|
keybinding_target,
|
|
)
|
|
.into_any_element();
|
|
|
|
SplitButton::new(left, right)
|
|
}
|
|
}
|
|
|
|
/// A visual representation of a file's Git status.
|
|
#[derive(IntoElement, RegisterComponent)]
|
|
pub struct GitStatusIcon {
|
|
status: FileStatus,
|
|
}
|
|
|
|
impl GitStatusIcon {
|
|
pub fn new(status: FileStatus) -> Self {
|
|
Self { status }
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for GitStatusIcon {
|
|
fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
|
|
let status = self.status;
|
|
|
|
let (icon_name, color) = if status.is_conflicted() {
|
|
(
|
|
IconName::Warning,
|
|
cx.theme().colors().version_control_conflict,
|
|
)
|
|
} else if status.is_deleted() {
|
|
(
|
|
IconName::SquareMinus,
|
|
cx.theme().colors().version_control_deleted,
|
|
)
|
|
} else if status.is_modified() {
|
|
(
|
|
IconName::SquareDot,
|
|
cx.theme().colors().version_control_modified,
|
|
)
|
|
} else {
|
|
(
|
|
IconName::SquarePlus,
|
|
cx.theme().colors().version_control_added,
|
|
)
|
|
};
|
|
|
|
Icon::new(icon_name).color(Color::Custom(color))
|
|
}
|
|
}
|
|
|
|
// View this component preview using `workspace: open component-preview`
|
|
impl Component for GitStatusIcon {
|
|
fn scope() -> ComponentScope {
|
|
ComponentScope::VersionControl
|
|
}
|
|
|
|
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
|
|
fn tracked_file_status(code: StatusCode) -> FileStatus {
|
|
FileStatus::Tracked(git::status::TrackedStatus {
|
|
index_status: code,
|
|
worktree_status: code,
|
|
})
|
|
}
|
|
|
|
let modified = tracked_file_status(StatusCode::Modified);
|
|
let added = tracked_file_status(StatusCode::Added);
|
|
let deleted = tracked_file_status(StatusCode::Deleted);
|
|
let conflict = UnmergedStatus {
|
|
first_head: UnmergedStatusCode::Updated,
|
|
second_head: UnmergedStatusCode::Updated,
|
|
}
|
|
.into();
|
|
|
|
Some(
|
|
v_flex()
|
|
.gap_6()
|
|
.children(vec![example_group(vec![
|
|
single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
|
|
single_example("Added", GitStatusIcon::new(added).into_any_element()),
|
|
single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
|
|
single_example(
|
|
"Conflicted",
|
|
GitStatusIcon::new(conflict).into_any_element(),
|
|
),
|
|
])])
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
}
|
|
|
|
struct GitCloneModal {
|
|
panel: Entity<GitPanel>,
|
|
repo_input: Entity<Editor>,
|
|
focus_handle: FocusHandle,
|
|
}
|
|
|
|
impl GitCloneModal {
|
|
pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
let repo_input = cx.new(|cx| {
|
|
let mut editor = Editor::single_line(window, cx);
|
|
editor.set_placeholder_text("Enter repository URL…", window, cx);
|
|
editor
|
|
});
|
|
let focus_handle = repo_input.focus_handle(cx);
|
|
|
|
window.focus(&focus_handle, cx);
|
|
|
|
Self {
|
|
panel,
|
|
repo_input,
|
|
focus_handle,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Focusable for GitCloneModal {
|
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl Render for GitCloneModal {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
div()
|
|
.elevation_3(cx)
|
|
.w(rems(34.))
|
|
.flex_1()
|
|
.overflow_hidden()
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.p_2()
|
|
.border_b_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.child(self.repo_input.clone()),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.w_full()
|
|
.p_2()
|
|
.gap_0p5()
|
|
.rounded_b_sm()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.child(
|
|
Label::new("Clone a repository from GitHub or other sources.")
|
|
.color(Color::Muted)
|
|
.size(LabelSize::Small),
|
|
)
|
|
.child(
|
|
Button::new("learn-more", "Learn More")
|
|
.label_size(LabelSize::Small)
|
|
.end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall))
|
|
.on_click(|_, _, cx| {
|
|
cx.open_url("https://github.com/git-guides/git-clone");
|
|
}),
|
|
),
|
|
)
|
|
.on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
|
|
cx.emit(DismissEvent);
|
|
}))
|
|
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
|
let repo = this.repo_input.read(cx).text(cx);
|
|
this.panel.update(cx, |panel, cx| {
|
|
panel.git_clone(repo, window, cx);
|
|
});
|
|
cx.emit(DismissEvent);
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<DismissEvent> for GitCloneModal {}
|
|
|
|
impl ModalView for GitCloneModal {}
|