
This PR identifies automatic configuration options that users can select from the agent panel. If no default provider is set in their settings, the PR defaults to the first recommended option. Additionally, it updates the selected provider for a thread when a user changes the default provider through the settings file, if the thread hasn't had any queries yet. Release Notes: - agent: automatically select a language model provider if there's no user set provider. --------- Co-authored-by: Michael Sloan <michael@zed.dev>
5502 lines
196 KiB
Rust
5502 lines
196 KiB
Rust
use crate::askpass_modal::AskPassModal;
|
|
use crate::commit_modal::CommitModal;
|
|
use crate::commit_tooltip::CommitTooltip;
|
|
use crate::commit_view::CommitView;
|
|
use crate::git_panel_settings::StatusStyle;
|
|
use crate::project_diff::{self, Diff, ProjectDiff};
|
|
use crate::remote_output::{self, RemoteAction, SuccessMessage};
|
|
use crate::{branch_picker, picker_prompt, render_remote_button};
|
|
use crate::{
|
|
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
|
|
};
|
|
use agent_settings::AgentSettings;
|
|
use anyhow::Context as _;
|
|
use askpass::AskPassDelegate;
|
|
use db::kvp::KEY_VALUE_STORE;
|
|
use editor::{
|
|
Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar,
|
|
scroll::ScrollbarAutoHide,
|
|
};
|
|
use futures::StreamExt as _;
|
|
use git::blame::ParsedCommitMessage;
|
|
use git::repository::{
|
|
Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
|
|
PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
|
|
UpstreamTrackingStatus, get_git_committer,
|
|
};
|
|
use git::status::StageStatus;
|
|
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
|
|
use git::{
|
|
ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashPop, TrashUntrackedFiles,
|
|
UnstageAll,
|
|
};
|
|
use gpui::{
|
|
Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
|
|
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
|
|
ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point,
|
|
PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
|
|
WeakEntity, actions, anchored, deferred, percentage, uniform_list,
|
|
};
|
|
use itertools::Itertools;
|
|
use language::{Buffer, File};
|
|
use language_model::{
|
|
ConfiguredModel, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
|
|
LanguageModelRequestMessage, Role,
|
|
};
|
|
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
|
|
use multi_buffer::ExcerptInfo;
|
|
use notifications::status_toast::{StatusToast, ToastIcon};
|
|
use panel::{
|
|
PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
|
|
panel_icon_button,
|
|
};
|
|
use project::{
|
|
DisableAiSettings, Fs, Project, ProjectPath,
|
|
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::{Settings, SettingsStore};
|
|
use std::future::Future;
|
|
use std::ops::Range;
|
|
use std::path::{Path, PathBuf};
|
|
use std::{collections::HashSet, sync::Arc, time::Duration, usize};
|
|
use strum::{IntoEnumIterator, VariantNames};
|
|
use time::OffsetDateTime;
|
|
use ui::{
|
|
Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
|
|
ScrollbarState, SplitButton, Tooltip, prelude::*,
|
|
};
|
|
use util::{ResultExt, TryFutureExt, maybe};
|
|
use workspace::SERIALIZATION_THROTTLE_TIME;
|
|
|
|
use cloud_llm_client::CompletionIntent;
|
|
use workspace::{
|
|
Workspace,
|
|
dock::{DockPosition, Panel, PanelEvent},
|
|
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId},
|
|
};
|
|
|
|
actions!(
|
|
git_panel,
|
|
[
|
|
/// Closes the git panel.
|
|
Close,
|
|
/// Toggles focus on the git panel.
|
|
ToggleFocus,
|
|
/// Opens the git panel menu.
|
|
OpenMenu,
|
|
/// Focuses on the commit message editor.
|
|
FocusEditor,
|
|
/// Focuses on the changes list.
|
|
FocusChanges,
|
|
/// Toggles automatic co-author suggestions.
|
|
ToggleFillCoAuthors,
|
|
]
|
|
);
|
|
|
|
fn prompt<T>(
|
|
msg: &str,
|
|
detail: Option<&str>,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Task<anyhow::Result<T>>
|
|
where
|
|
T: IntoEnumIterator + VariantNames + 'static,
|
|
{
|
|
let rx = window.prompt(PromptLevel::Info, msg, detail, T::VARIANTS, cx);
|
|
cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
|
|
}
|
|
|
|
#[derive(strum::EnumIter, strum::VariantNames)]
|
|
#[strum(serialize_all = "title_case")]
|
|
enum TrashCancel {
|
|
Trash,
|
|
Cancel,
|
|
}
|
|
|
|
struct GitMenuState {
|
|
has_tracked_changes: bool,
|
|
has_staged_changes: bool,
|
|
has_unstaged_changes: bool,
|
|
has_new_changes: bool,
|
|
}
|
|
|
|
fn git_panel_context_menu(
|
|
focus_handle: FocusHandle,
|
|
state: GitMenuState,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Entity<ContextMenu> {
|
|
ContextMenu::build(window, cx, move |context_menu, _, _| {
|
|
context_menu
|
|
.context(focus_handle)
|
|
.action_disabled_when(
|
|
!state.has_unstaged_changes,
|
|
"Stage All",
|
|
StageAll.boxed_clone(),
|
|
)
|
|
.action_disabled_when(
|
|
!state.has_staged_changes,
|
|
"Unstage All",
|
|
UnstageAll.boxed_clone(),
|
|
)
|
|
.separator()
|
|
.action_disabled_when(
|
|
!(state.has_new_changes || state.has_tracked_changes),
|
|
"Stash All",
|
|
StashAll.boxed_clone(),
|
|
)
|
|
.action("Stash Pop", StashPop.boxed_clone())
|
|
.separator()
|
|
.action("Open Diff", project_diff::Diff.boxed_clone())
|
|
.separator()
|
|
.action_disabled_when(
|
|
!state.has_tracked_changes,
|
|
"Discard Tracked Changes",
|
|
RestoreTrackedFiles.boxed_clone(),
|
|
)
|
|
.action_disabled_when(
|
|
!state.has_new_changes,
|
|
"Trash Untracked Files",
|
|
TrashUntrackedFiles.boxed_clone(),
|
|
)
|
|
})
|
|
}
|
|
|
|
const GIT_PANEL_KEY: &str = "GitPanel";
|
|
|
|
const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
|
|
|
|
pub fn register(workspace: &mut Workspace) {
|
|
workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
|
|
workspace.toggle_panel_focus::<GitPanel>(window, cx);
|
|
});
|
|
workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| {
|
|
CommitModal::toggle(workspace, None, window, cx)
|
|
});
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Focus,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct SerializedGitPanel {
|
|
width: Option<Pixels>,
|
|
#[serde(default)]
|
|
amend_pending: bool,
|
|
#[serde(default)]
|
|
signoff_enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
enum Section {
|
|
Conflict,
|
|
Tracked,
|
|
New,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
|
struct GitHeaderEntry {
|
|
header: Section,
|
|
}
|
|
|
|
impl GitHeaderEntry {
|
|
pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
|
|
let this = &self.header;
|
|
let status = status_entry.status;
|
|
match this {
|
|
Section::Conflict => {
|
|
repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
|
|
}
|
|
Section::Tracked => !status.is_created(),
|
|
Section::New => status.is_created(),
|
|
}
|
|
}
|
|
pub fn title(&self) -> &'static str {
|
|
match self.header {
|
|
Section::Conflict => "Conflicts",
|
|
Section::Tracked => "Tracked",
|
|
Section::New => "Untracked",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
|
enum GitListEntry {
|
|
Status(GitStatusEntry),
|
|
Header(GitHeaderEntry),
|
|
}
|
|
|
|
impl GitListEntry {
|
|
fn status_entry(&self) -> Option<&GitStatusEntry> {
|
|
match self {
|
|
GitListEntry::Status(entry) => Some(entry),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
|
pub struct GitStatusEntry {
|
|
pub(crate) repo_path: RepoPath,
|
|
pub(crate) abs_path: PathBuf,
|
|
pub(crate) status: FileStatus,
|
|
pub(crate) staging: StageStatus,
|
|
}
|
|
|
|
impl GitStatusEntry {
|
|
fn display_name(&self) -> String {
|
|
self.repo_path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| self.repo_path.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn parent_dir(&self) -> Option<String> {
|
|
self.repo_path
|
|
.parent()
|
|
.map(|parent| parent.to_string_lossy().into_owned())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum TargetStatus {
|
|
Staged,
|
|
Unstaged,
|
|
Reverted,
|
|
Unchanged,
|
|
}
|
|
|
|
struct PendingOperation {
|
|
finished: bool,
|
|
target_status: TargetStatus,
|
|
entries: Vec<GitStatusEntry>,
|
|
op_id: usize,
|
|
}
|
|
|
|
// computed state related to how to render scrollbars
|
|
// one per axis
|
|
// on render we just read this off the panel
|
|
// we update it when
|
|
// - settings change
|
|
// - on focus in, on focus out, on hover, etc.
|
|
#[derive(Debug)]
|
|
struct ScrollbarProperties {
|
|
axis: Axis,
|
|
show_scrollbar: bool,
|
|
show_track: bool,
|
|
auto_hide: bool,
|
|
hide_task: Option<Task<()>>,
|
|
state: ScrollbarState,
|
|
}
|
|
|
|
impl ScrollbarProperties {
|
|
// Shows the scrollbar and cancels any pending hide task
|
|
fn show(&mut self, cx: &mut Context<GitPanel>) {
|
|
if !self.auto_hide {
|
|
return;
|
|
}
|
|
self.show_scrollbar = true;
|
|
self.hide_task.take();
|
|
cx.notify();
|
|
}
|
|
|
|
fn hide(&mut self, window: &mut Window, cx: &mut Context<GitPanel>) {
|
|
const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
|
|
|
|
if !self.auto_hide {
|
|
return;
|
|
}
|
|
|
|
let axis = self.axis;
|
|
self.hide_task = Some(cx.spawn_in(window, async move |panel, cx| {
|
|
cx.background_executor()
|
|
.timer(SCROLLBAR_SHOW_INTERVAL)
|
|
.await;
|
|
|
|
if let Some(panel) = panel.upgrade() {
|
|
panel
|
|
.update(cx, |panel, cx| {
|
|
match axis {
|
|
Axis::Vertical => panel.vertical_scrollbar.show_scrollbar = false,
|
|
Axis::Horizontal => panel.horizontal_scrollbar.show_scrollbar = false,
|
|
}
|
|
cx.notify();
|
|
})
|
|
.log_err();
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
pub struct GitPanel {
|
|
pub(crate) active_repository: Option<Entity<Repository>>,
|
|
pub(crate) commit_editor: Entity<Editor>,
|
|
conflicted_count: usize,
|
|
conflicted_staged_count: usize,
|
|
add_coauthors: bool,
|
|
generate_commit_message_task: Option<Task<Option<()>>>,
|
|
entries: Vec<GitListEntry>,
|
|
single_staged_entry: Option<GitStatusEntry>,
|
|
single_tracked_entry: Option<GitStatusEntry>,
|
|
focus_handle: FocusHandle,
|
|
fs: Arc<dyn Fs>,
|
|
horizontal_scrollbar: ScrollbarProperties,
|
|
vertical_scrollbar: ScrollbarProperties,
|
|
new_count: usize,
|
|
entry_count: usize,
|
|
new_staged_count: usize,
|
|
pending: Vec<PendingOperation>,
|
|
pending_commit: Option<Task<()>>,
|
|
amend_pending: bool,
|
|
signoff_enabled: bool,
|
|
pending_serialization: Task<()>,
|
|
pub(crate) project: Entity<Project>,
|
|
scroll_handle: UniformListScrollHandle,
|
|
max_width_item_index: Option<usize>,
|
|
selected_entry: Option<usize>,
|
|
marked_entries: Vec<usize>,
|
|
tracked_count: usize,
|
|
tracked_staged_count: usize,
|
|
update_visible_entries_task: Task<()>,
|
|
width: Option<Pixels>,
|
|
workspace: WeakEntity<Workspace>,
|
|
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
|
|
modal_open: bool,
|
|
show_placeholders: bool,
|
|
local_committer: Option<GitCommitter>,
|
|
local_committer_task: Option<Task<()>>,
|
|
bulk_staging: Option<BulkStaging>,
|
|
_settings_subscription: Subscription,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct BulkStaging {
|
|
repo_id: RepositoryId,
|
|
anchor: RepoPath,
|
|
}
|
|
|
|
const MAX_PANEL_EDITOR_LINES: usize = 6;
|
|
|
|
pub(crate) fn commit_message_editor(
|
|
commit_message_buffer: Entity<Buffer>,
|
|
placeholder: Option<SharedString>,
|
|
project: Entity<Project>,
|
|
in_panel: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) -> Editor {
|
|
project.update(cx, |this, cx| {
|
|
this.mark_buffer_as_non_searchable(commit_message_buffer.read(cx).remote_id(), cx);
|
|
});
|
|
let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
|
|
let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
|
|
let mut commit_editor = Editor::new(
|
|
EditorMode::AutoHeight {
|
|
min_lines: 1,
|
|
max_lines: Some(max_lines),
|
|
},
|
|
buffer,
|
|
None,
|
|
window,
|
|
cx,
|
|
);
|
|
commit_editor.set_collaboration_hub(Box::new(project));
|
|
commit_editor.set_use_autoclose(false);
|
|
commit_editor.set_show_gutter(false, cx);
|
|
commit_editor.set_use_modal_editing(true);
|
|
commit_editor.set_show_wrap_guides(false, cx);
|
|
commit_editor.set_show_indent_guides(false, cx);
|
|
let placeholder = placeholder.unwrap_or("Enter commit message".into());
|
|
commit_editor.set_placeholder_text(placeholder, cx);
|
|
commit_editor
|
|
}
|
|
|
|
impl GitPanel {
|
|
fn new(
|
|
workspace: &mut Workspace,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) -> Entity<Self> {
|
|
let project = workspace.project().clone();
|
|
let app_state = workspace.app_state().clone();
|
|
let fs = app_state.fs.clone();
|
|
let git_store = project.read(cx).git_store().clone();
|
|
let active_repository = project.read(cx).active_repository(cx);
|
|
|
|
cx.new(|cx| {
|
|
let focus_handle = cx.focus_handle();
|
|
cx.on_focus(&focus_handle, window, Self::focus_in).detach();
|
|
cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
|
|
this.hide_scrollbars(window, cx);
|
|
})
|
|
.detach();
|
|
|
|
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
|
|
cx.observe_global::<SettingsStore>(move |this, cx| {
|
|
let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
|
|
if is_sort_by_path != was_sort_by_path {
|
|
this.update_visible_entries(cx);
|
|
}
|
|
was_sort_by_path = is_sort_by_path
|
|
})
|
|
.detach();
|
|
|
|
// just to let us render a placeholder editor.
|
|
// Once the active git repo is set, this buffer will be replaced.
|
|
let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
|
|
let commit_editor = cx.new(|cx| {
|
|
commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
|
|
});
|
|
|
|
commit_editor.update(cx, |editor, cx| {
|
|
editor.clear(window, cx);
|
|
});
|
|
|
|
let scroll_handle = UniformListScrollHandle::new();
|
|
|
|
let vertical_scrollbar = ScrollbarProperties {
|
|
axis: Axis::Vertical,
|
|
state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
|
|
show_scrollbar: false,
|
|
show_track: false,
|
|
auto_hide: false,
|
|
hide_task: None,
|
|
};
|
|
|
|
let horizontal_scrollbar = ScrollbarProperties {
|
|
axis: Axis::Horizontal,
|
|
state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
|
|
show_scrollbar: false,
|
|
show_track: false,
|
|
auto_hide: false,
|
|
hide_task: None,
|
|
};
|
|
|
|
let mut assistant_enabled = AgentSettings::get_global(cx).enabled;
|
|
let mut was_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
|
|
let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
|
|
let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
|
|
if assistant_enabled != AgentSettings::get_global(cx).enabled
|
|
|| was_ai_disabled != is_ai_disabled
|
|
{
|
|
assistant_enabled = AgentSettings::get_global(cx).enabled;
|
|
was_ai_disabled = is_ai_disabled;
|
|
cx.notify();
|
|
}
|
|
});
|
|
|
|
cx.subscribe_in(
|
|
&git_store,
|
|
window,
|
|
move |this, _git_store, event, window, cx| match event {
|
|
GitStoreEvent::ActiveRepositoryChanged(_) => {
|
|
this.active_repository = this.project.read(cx).active_repository(cx);
|
|
this.schedule_update(true, window, cx);
|
|
}
|
|
GitStoreEvent::RepositoryUpdated(
|
|
_,
|
|
RepositoryEvent::Updated { full_scan, .. },
|
|
true,
|
|
) => {
|
|
this.schedule_update(*full_scan, window, cx);
|
|
}
|
|
|
|
GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
|
|
this.schedule_update(false, window, cx);
|
|
}
|
|
GitStoreEvent::IndexWriteError(error) => {
|
|
this.workspace
|
|
.update(cx, |workspace, cx| {
|
|
workspace.show_error(error, cx);
|
|
})
|
|
.ok();
|
|
}
|
|
GitStoreEvent::RepositoryUpdated(_, _, _) => {}
|
|
GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
|
|
},
|
|
)
|
|
.detach();
|
|
|
|
let mut this = Self {
|
|
active_repository,
|
|
commit_editor,
|
|
conflicted_count: 0,
|
|
conflicted_staged_count: 0,
|
|
add_coauthors: true,
|
|
generate_commit_message_task: None,
|
|
entries: Vec::new(),
|
|
focus_handle: cx.focus_handle(),
|
|
fs,
|
|
new_count: 0,
|
|
new_staged_count: 0,
|
|
pending: Vec::new(),
|
|
pending_commit: None,
|
|
amend_pending: false,
|
|
signoff_enabled: false,
|
|
pending_serialization: Task::ready(()),
|
|
single_staged_entry: None,
|
|
single_tracked_entry: None,
|
|
project,
|
|
scroll_handle,
|
|
max_width_item_index: None,
|
|
selected_entry: None,
|
|
marked_entries: Vec::new(),
|
|
tracked_count: 0,
|
|
tracked_staged_count: 0,
|
|
update_visible_entries_task: Task::ready(()),
|
|
width: None,
|
|
show_placeholders: false,
|
|
local_committer: None,
|
|
local_committer_task: None,
|
|
context_menu: None,
|
|
workspace: workspace.weak_handle(),
|
|
modal_open: false,
|
|
entry_count: 0,
|
|
horizontal_scrollbar,
|
|
vertical_scrollbar,
|
|
bulk_staging: None,
|
|
_settings_subscription,
|
|
};
|
|
|
|
this.schedule_update(false, window, cx);
|
|
this
|
|
})
|
|
}
|
|
|
|
fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.horizontal_scrollbar.hide(window, cx);
|
|
self.vertical_scrollbar.hide(window, cx);
|
|
}
|
|
|
|
fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
// TODO: This PR should have defined Editor's `scrollbar.axis`
|
|
// as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
|
|
// `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
|
|
//
|
|
// Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
|
|
// so we can show each axis based on the settings.
|
|
//
|
|
// We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
|
|
|
|
let show_setting = GitPanelSettings::get_global(cx)
|
|
.scrollbar
|
|
.show
|
|
.unwrap_or(EditorSettings::get_global(cx).scrollbar.show);
|
|
|
|
let scroll_handle = self.scroll_handle.0.borrow();
|
|
|
|
let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
|
|
ShowScrollbar::Auto => true,
|
|
ShowScrollbar::System => cx
|
|
.try_global::<ScrollbarAutoHide>()
|
|
.map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
|
|
ShowScrollbar::Always => false,
|
|
ShowScrollbar::Never => false,
|
|
};
|
|
|
|
let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
|
|
(size.contents.width > size.item.width).then_some(size.contents.width)
|
|
});
|
|
|
|
// is there an item long enough that we should show a horizontal scrollbar?
|
|
let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
|
|
longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
|
|
} else {
|
|
true
|
|
};
|
|
|
|
let show_horizontal = match (show_setting, item_wider_than_container) {
|
|
(_, false) => false,
|
|
(ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always, true) => true,
|
|
(ShowScrollbar::Never, true) => false,
|
|
};
|
|
|
|
let show_vertical = match show_setting {
|
|
ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
|
|
ShowScrollbar::Never => false,
|
|
};
|
|
|
|
let show_horizontal_track =
|
|
show_horizontal && matches!(show_setting, ShowScrollbar::Always);
|
|
|
|
// TODO: we probably should hide the scroll track when the list doesn't need to scroll
|
|
let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
|
|
|
|
self.vertical_scrollbar = ScrollbarProperties {
|
|
axis: self.vertical_scrollbar.axis,
|
|
state: self.vertical_scrollbar.state.clone(),
|
|
show_scrollbar: show_vertical,
|
|
show_track: show_vertical_track,
|
|
auto_hide: autohide(show_setting, cx),
|
|
hide_task: None,
|
|
};
|
|
|
|
self.horizontal_scrollbar = ScrollbarProperties {
|
|
axis: self.horizontal_scrollbar.axis,
|
|
state: self.horizontal_scrollbar.state.clone(),
|
|
show_scrollbar: show_horizontal,
|
|
show_track: show_horizontal_track,
|
|
auto_hide: autohide(show_setting, cx),
|
|
hide_task: None,
|
|
};
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
|
|
if GitPanelSettings::get_global(cx).sort_by_path {
|
|
return self
|
|
.entries
|
|
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
|
|
.ok();
|
|
}
|
|
|
|
if self.conflicted_count > 0 {
|
|
let conflicted_start = 1;
|
|
if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count]
|
|
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
|
|
{
|
|
return Some(conflicted_start + ix);
|
|
}
|
|
}
|
|
if self.tracked_count > 0 {
|
|
let tracked_start = if self.conflicted_count > 0 {
|
|
1 + self.conflicted_count
|
|
} else {
|
|
0
|
|
} + 1;
|
|
if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count]
|
|
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
|
|
{
|
|
return Some(tracked_start + ix);
|
|
}
|
|
}
|
|
if self.new_count > 0 {
|
|
let untracked_start = if self.conflicted_count > 0 {
|
|
1 + self.conflicted_count
|
|
} else {
|
|
0
|
|
} + if self.tracked_count > 0 {
|
|
1 + self.tracked_count
|
|
} else {
|
|
0
|
|
} + 1;
|
|
if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count]
|
|
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
|
|
{
|
|
return Some(untracked_start + ix);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn select_entry_by_path(
|
|
&mut self,
|
|
path: ProjectPath,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(git_repo) = self.active_repository.as_ref() else {
|
|
return;
|
|
};
|
|
let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
|
|
return;
|
|
};
|
|
let Some(ix) = self.entry_by_path(&repo_path, cx) else {
|
|
return;
|
|
};
|
|
self.selected_entry = Some(ix);
|
|
cx.notify();
|
|
}
|
|
|
|
fn serialization_key(workspace: &Workspace) -> Option<String> {
|
|
workspace
|
|
.database_id()
|
|
.map(|id| i64::from(id).to_string())
|
|
.or(workspace.session_id())
|
|
.map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
|
|
}
|
|
|
|
fn serialize(&mut self, cx: &mut Context<Self>) {
|
|
let width = self.width;
|
|
let amend_pending = self.amend_pending;
|
|
let signoff_enabled = self.signoff_enabled;
|
|
|
|
self.pending_serialization = cx.spawn(async move |git_panel, cx| {
|
|
cx.background_executor()
|
|
.timer(SERIALIZATION_THROTTLE_TIME)
|
|
.await;
|
|
let Some(serialization_key) = git_panel
|
|
.update(cx, |git_panel, cx| {
|
|
git_panel
|
|
.workspace
|
|
.read_with(cx, |workspace, _| Self::serialization_key(workspace))
|
|
.ok()
|
|
.flatten()
|
|
})
|
|
.ok()
|
|
.flatten()
|
|
else {
|
|
return;
|
|
};
|
|
cx.background_spawn(
|
|
async move {
|
|
KEY_VALUE_STORE
|
|
.write_kvp(
|
|
serialization_key,
|
|
serde_json::to_string(&SerializedGitPanel {
|
|
width,
|
|
amend_pending,
|
|
signoff_enabled,
|
|
})?,
|
|
)
|
|
.await?;
|
|
anyhow::Ok(())
|
|
}
|
|
.log_err(),
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
|
|
self.modal_open = open;
|
|
cx.notify();
|
|
}
|
|
|
|
fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
|
|
let mut dispatch_context = KeyContext::new_with_defaults();
|
|
dispatch_context.add("GitPanel");
|
|
|
|
if window
|
|
.focused(cx)
|
|
.is_some_and(|focused| self.focus_handle == focused)
|
|
{
|
|
dispatch_context.add("menu");
|
|
dispatch_context.add("ChangesList");
|
|
}
|
|
|
|
if self.commit_editor.read(cx).is_focused(window) {
|
|
dispatch_context.add("CommitEditor");
|
|
}
|
|
|
|
dispatch_context
|
|
}
|
|
|
|
fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
|
|
cx.emit(PanelEvent::Close);
|
|
}
|
|
|
|
fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if !self.focus_handle.contains_focused(window, cx) {
|
|
cx.emit(Event::Focus);
|
|
}
|
|
}
|
|
|
|
fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
|
|
if let Some(selected_entry) = self.selected_entry {
|
|
self.scroll_handle
|
|
.scroll_to_item(selected_entry, ScrollStrategy::Center);
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
|
|
if !self.entries.is_empty() {
|
|
self.selected_entry = Some(1);
|
|
self.scroll_to_selected_entry(cx);
|
|
}
|
|
}
|
|
|
|
fn select_previous(
|
|
&mut self,
|
|
_: &SelectPrevious,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let item_count = self.entries.len();
|
|
if item_count == 0 {
|
|
return;
|
|
}
|
|
|
|
if let Some(selected_entry) = self.selected_entry {
|
|
let new_selected_entry = if selected_entry > 0 {
|
|
selected_entry - 1
|
|
} else {
|
|
selected_entry
|
|
};
|
|
|
|
if matches!(
|
|
self.entries.get(new_selected_entry),
|
|
Some(GitListEntry::Header(..))
|
|
) {
|
|
if new_selected_entry > 0 {
|
|
self.selected_entry = Some(new_selected_entry - 1)
|
|
}
|
|
} else {
|
|
self.selected_entry = Some(new_selected_entry);
|
|
}
|
|
|
|
self.scroll_to_selected_entry(cx);
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let item_count = self.entries.len();
|
|
if item_count == 0 {
|
|
return;
|
|
}
|
|
|
|
if let Some(selected_entry) = self.selected_entry {
|
|
let new_selected_entry = if selected_entry < item_count - 1 {
|
|
selected_entry + 1
|
|
} else {
|
|
selected_entry
|
|
};
|
|
if matches!(
|
|
self.entries.get(new_selected_entry),
|
|
Some(GitListEntry::Header(..))
|
|
) {
|
|
self.selected_entry = Some(new_selected_entry + 1);
|
|
} else {
|
|
self.selected_entry = Some(new_selected_entry);
|
|
}
|
|
|
|
self.scroll_to_selected_entry(cx);
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.entries.last().is_some() {
|
|
self.selected_entry = Some(self.entries.len() - 1);
|
|
self.scroll_to_selected_entry(cx);
|
|
}
|
|
}
|
|
|
|
fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.commit_editor.update(cx, |editor, cx| {
|
|
window.focus(&editor.focus_handle(cx));
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
|
|
let have_entries = self
|
|
.active_repository
|
|
.as_ref()
|
|
.is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
|
|
if have_entries && self.selected_entry.is_none() {
|
|
self.selected_entry = Some(1);
|
|
self.scroll_to_selected_entry(cx);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn focus_changes_list(
|
|
&mut self,
|
|
_: &FocusChanges,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.select_first_entry_if_none(cx);
|
|
|
|
cx.focus_self(window);
|
|
cx.notify();
|
|
}
|
|
|
|
fn get_selected_entry(&self) -> Option<&GitListEntry> {
|
|
self.selected_entry.and_then(|i| self.entries.get(i))
|
|
}
|
|
|
|
fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
|
maybe!({
|
|
let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
|
|
let workspace = self.workspace.upgrade()?;
|
|
let git_repo = self.active_repository.as_ref()?;
|
|
|
|
if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
|
|
&& let Some(project_path) = project_diff.read(cx).active_path(cx)
|
|
&& Some(&entry.repo_path)
|
|
== git_repo
|
|
.read(cx)
|
|
.project_path_to_repo_path(&project_path, cx)
|
|
.as_ref()
|
|
{
|
|
project_diff.focus_handle(cx).focus(window);
|
|
project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
|
|
return None;
|
|
};
|
|
|
|
self.workspace
|
|
.update(cx, |workspace, cx| {
|
|
ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
|
|
})
|
|
.ok();
|
|
self.focus_handle.focus(window);
|
|
|
|
Some(())
|
|
});
|
|
}
|
|
|
|
fn open_file(
|
|
&mut self,
|
|
_: &menu::SecondaryConfirm,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
maybe!({
|
|
let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
|
|
let active_repo = self.active_repository.as_ref()?;
|
|
let path = active_repo
|
|
.read(cx)
|
|
.repo_path_to_project_path(&entry.repo_path, cx)?;
|
|
if entry.status.is_deleted() {
|
|
return None;
|
|
}
|
|
|
|
self.workspace
|
|
.update(cx, |workspace, cx| {
|
|
workspace
|
|
.open_path_preview(path, None, false, false, true, window, cx)
|
|
.detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
|
|
Some(format!("{e}"))
|
|
});
|
|
})
|
|
.ok()
|
|
});
|
|
}
|
|
|
|
fn revert_selected(
|
|
&mut self,
|
|
action: &git::RestoreFile,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
maybe!({
|
|
let list_entry = self.entries.get(self.selected_entry?)?.clone();
|
|
let entry = list_entry.status_entry()?.to_owned();
|
|
let skip_prompt = action.skip_prompt || entry.status.is_created();
|
|
|
|
let prompt = if skip_prompt {
|
|
Task::ready(Ok(0))
|
|
} else {
|
|
let prompt = window.prompt(
|
|
PromptLevel::Warning,
|
|
&format!(
|
|
"Are you sure you want to restore {}?",
|
|
entry
|
|
.repo_path
|
|
.file_name()
|
|
.unwrap_or(entry.repo_path.as_os_str())
|
|
.to_string_lossy()
|
|
),
|
|
None,
|
|
&["Restore", "Cancel"],
|
|
cx,
|
|
);
|
|
cx.background_spawn(prompt)
|
|
};
|
|
|
|
let this = cx.weak_entity();
|
|
window
|
|
.spawn(cx, async move |cx| {
|
|
if prompt.await? != 0 {
|
|
return anyhow::Ok(());
|
|
}
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.revert_entry(&entry, window, cx);
|
|
})?;
|
|
|
|
Ok(())
|
|
})
|
|
.detach();
|
|
Some(())
|
|
});
|
|
}
|
|
|
|
fn revert_entry(
|
|
&mut self,
|
|
entry: &GitStatusEntry,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
maybe!({
|
|
let active_repo = self.active_repository.clone()?;
|
|
let path = active_repo
|
|
.read(cx)
|
|
.repo_path_to_project_path(&entry.repo_path, cx)?;
|
|
let workspace = self.workspace.clone();
|
|
|
|
if entry.status.staging().has_staged() {
|
|
self.change_file_stage(false, vec![entry.clone()], cx);
|
|
}
|
|
let filename = path.path.file_name()?.to_string_lossy();
|
|
|
|
if !entry.status.is_created() {
|
|
self.perform_checkout(vec![entry.clone()], cx);
|
|
} else {
|
|
let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
|
|
cx.spawn_in(window, async move |_, cx| {
|
|
match prompt.await? {
|
|
TrashCancel::Trash => {}
|
|
TrashCancel::Cancel => return Ok(()),
|
|
}
|
|
let task = workspace.update(cx, |workspace, cx| {
|
|
workspace
|
|
.project()
|
|
.update(cx, |project, cx| project.delete_file(path, true, cx))
|
|
})?;
|
|
if let Some(task) = task {
|
|
task.await?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.detach_and_prompt_err(
|
|
"Failed to trash file",
|
|
window,
|
|
cx,
|
|
|e, _, _| Some(format!("{e}")),
|
|
);
|
|
}
|
|
Some(())
|
|
});
|
|
}
|
|
|
|
fn perform_checkout(&mut self, entries: Vec<GitStatusEntry>, cx: &mut Context<Self>) {
|
|
let workspace = self.workspace.clone();
|
|
let Some(active_repository) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
|
|
let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
|
|
self.pending.push(PendingOperation {
|
|
op_id,
|
|
target_status: TargetStatus::Reverted,
|
|
entries: entries.clone(),
|
|
finished: false,
|
|
});
|
|
self.update_visible_entries(cx);
|
|
let task = cx.spawn(async move |_, cx| {
|
|
let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
|
|
workspace.project().update(cx, |project, cx| {
|
|
entries
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
let path = active_repository
|
|
.read(cx)
|
|
.repo_path_to_project_path(&entry.repo_path, cx)?;
|
|
Some(project.open_buffer(path, cx))
|
|
})
|
|
.collect()
|
|
})
|
|
})?;
|
|
|
|
let buffers = futures::future::join_all(tasks).await;
|
|
|
|
active_repository
|
|
.update(cx, |repo, cx| {
|
|
repo.checkout_files(
|
|
"HEAD",
|
|
entries
|
|
.into_iter()
|
|
.map(|entries| entries.repo_path)
|
|
.collect(),
|
|
cx,
|
|
)
|
|
})?
|
|
.await??;
|
|
|
|
let tasks: Vec<_> = cx.update(|cx| {
|
|
buffers
|
|
.iter()
|
|
.filter_map(|buffer| {
|
|
buffer.as_ref().ok()?.update(cx, |buffer, cx| {
|
|
buffer.is_dirty().then(|| buffer.reload(cx))
|
|
})
|
|
})
|
|
.collect()
|
|
})?;
|
|
|
|
futures::future::join_all(tasks).await;
|
|
|
|
Ok(())
|
|
});
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
let result = task.await;
|
|
|
|
this.update(cx, |this, cx| {
|
|
for pending in this.pending.iter_mut() {
|
|
if pending.op_id == op_id {
|
|
pending.finished = true;
|
|
if result.is_err() {
|
|
pending.target_status = TargetStatus::Unchanged;
|
|
this.update_visible_entries(cx);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
result
|
|
.map_err(|e| {
|
|
this.show_error_toast("checkout", e, cx);
|
|
})
|
|
.ok();
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn restore_tracked_files(
|
|
&mut self,
|
|
_: &RestoreTrackedFiles,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let entries = self
|
|
.entries
|
|
.iter()
|
|
.filter_map(|entry| entry.status_entry().cloned())
|
|
.filter(|status_entry| !status_entry.status.is_created())
|
|
.collect::<Vec<_>>();
|
|
|
|
match entries.len() {
|
|
0 => return,
|
|
1 => return self.revert_entry(&entries[0], window, cx),
|
|
_ => {}
|
|
}
|
|
let mut details = entries
|
|
.iter()
|
|
.filter_map(|entry| entry.repo_path.0.file_name())
|
|
.map(|filename| filename.to_string_lossy())
|
|
.take(5)
|
|
.join("\n");
|
|
if entries.len() > 5 {
|
|
details.push_str(&format!("\nand {} more…", entries.len() - 5))
|
|
}
|
|
|
|
#[derive(strum::EnumIter, strum::VariantNames)]
|
|
#[strum(serialize_all = "title_case")]
|
|
enum RestoreCancel {
|
|
RestoreTrackedFiles,
|
|
Cancel,
|
|
}
|
|
let prompt = prompt(
|
|
"Discard changes to these files?",
|
|
Some(&details),
|
|
window,
|
|
cx,
|
|
);
|
|
cx.spawn(async move |this, cx| {
|
|
if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await {
|
|
this.update(cx, |this, cx| {
|
|
this.perform_checkout(entries, cx);
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
|
|
let workspace = self.workspace.clone();
|
|
let Some(active_repo) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
let to_delete = self
|
|
.entries
|
|
.iter()
|
|
.filter_map(|entry| entry.status_entry())
|
|
.filter(|status_entry| status_entry.status.is_created())
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
match to_delete.len() {
|
|
0 => return,
|
|
1 => return self.revert_entry(&to_delete[0], window, cx),
|
|
_ => {}
|
|
};
|
|
|
|
let mut details = to_delete
|
|
.iter()
|
|
.map(|entry| {
|
|
entry
|
|
.repo_path
|
|
.0
|
|
.file_name()
|
|
.map(|f| f.to_string_lossy())
|
|
.unwrap_or_default()
|
|
})
|
|
.take(5)
|
|
.join("\n");
|
|
|
|
if to_delete.len() > 5 {
|
|
details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
|
|
}
|
|
|
|
let prompt = prompt("Trash these files?", Some(&details), window, cx);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
match prompt.await? {
|
|
TrashCancel::Trash => {}
|
|
TrashCancel::Cancel => return Ok(()),
|
|
}
|
|
let tasks = workspace.update(cx, |workspace, cx| {
|
|
to_delete
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
workspace.project().update(cx, |project, cx| {
|
|
let project_path = active_repo
|
|
.read(cx)
|
|
.repo_path_to_project_path(&entry.repo_path, cx)?;
|
|
project.delete_file(project_path, true, cx)
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})?;
|
|
let to_unstage = to_delete
|
|
.into_iter()
|
|
.filter(|entry| !entry.status.staging().is_fully_unstaged())
|
|
.collect();
|
|
this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
|
|
for task in tasks {
|
|
task.await?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
|
|
Some(format!("{e}"))
|
|
});
|
|
}
|
|
|
|
pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let entries = self
|
|
.entries
|
|
.iter()
|
|
.filter_map(|entry| entry.status_entry())
|
|
.filter(|status_entry| status_entry.staging.has_unstaged())
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
self.change_file_stage(true, entries, cx);
|
|
}
|
|
|
|
pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let entries = self
|
|
.entries
|
|
.iter()
|
|
.filter_map(|entry| entry.status_entry())
|
|
.filter(|status_entry| status_entry.staging.has_staged())
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
self.change_file_stage(false, entries, cx);
|
|
}
|
|
|
|
fn toggle_staged_for_entry(
|
|
&mut self,
|
|
entry: &GitListEntry,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(active_repository) = self.active_repository.as_ref() else {
|
|
return;
|
|
};
|
|
let (stage, repo_paths) = match entry {
|
|
GitListEntry::Status(status_entry) => {
|
|
if status_entry.status.staging().is_fully_staged() {
|
|
if let Some(op) = self.bulk_staging.clone()
|
|
&& op.anchor == status_entry.repo_path
|
|
{
|
|
self.bulk_staging = None;
|
|
}
|
|
|
|
(false, vec![status_entry.clone()])
|
|
} else {
|
|
self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx);
|
|
|
|
(true, vec![status_entry.clone()])
|
|
}
|
|
}
|
|
GitListEntry::Header(section) => {
|
|
let goal_staged_state = !self.header_state(section.header).selected();
|
|
let repository = active_repository.read(cx);
|
|
let entries = self
|
|
.entries
|
|
.iter()
|
|
.filter_map(|entry| entry.status_entry())
|
|
.filter(|status_entry| {
|
|
section.contains(status_entry, repository)
|
|
&& status_entry.staging.as_bool() != Some(goal_staged_state)
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
(goal_staged_state, entries)
|
|
}
|
|
};
|
|
self.change_file_stage(stage, repo_paths, cx);
|
|
}
|
|
|
|
fn change_file_stage(
|
|
&mut self,
|
|
stage: bool,
|
|
entries: Vec<GitStatusEntry>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(active_repository) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
|
|
self.pending.push(PendingOperation {
|
|
op_id,
|
|
target_status: if stage {
|
|
TargetStatus::Staged
|
|
} else {
|
|
TargetStatus::Unstaged
|
|
},
|
|
entries: entries.clone(),
|
|
finished: false,
|
|
});
|
|
let repository = active_repository.read(cx);
|
|
self.update_counts(repository);
|
|
cx.notify();
|
|
|
|
cx.spawn({
|
|
async move |this, cx| {
|
|
let result = cx
|
|
.update(|cx| {
|
|
if stage {
|
|
active_repository.update(cx, |repo, cx| {
|
|
let repo_paths = entries
|
|
.iter()
|
|
.map(|entry| entry.repo_path.clone())
|
|
.collect();
|
|
repo.stage_entries(repo_paths, cx)
|
|
})
|
|
} else {
|
|
active_repository.update(cx, |repo, cx| {
|
|
let repo_paths = entries
|
|
.iter()
|
|
.map(|entry| entry.repo_path.clone())
|
|
.collect();
|
|
repo.unstage_entries(repo_paths, cx)
|
|
})
|
|
}
|
|
})?
|
|
.await;
|
|
|
|
this.update(cx, |this, cx| {
|
|
for pending in this.pending.iter_mut() {
|
|
if pending.op_id == op_id {
|
|
pending.finished = true
|
|
}
|
|
}
|
|
result
|
|
.map_err(|e| {
|
|
this.show_error_toast(if stage { "add" } else { "reset" }, e, cx);
|
|
})
|
|
.ok();
|
|
cx.notify();
|
|
})
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub fn total_staged_count(&self) -> usize {
|
|
self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
|
|
}
|
|
|
|
pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(active_repository) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
|
|
cx.spawn({
|
|
async move |this, cx| {
|
|
let stash_task = active_repository
|
|
.update(cx, |repo, cx| repo.stash_pop(cx))?
|
|
.await;
|
|
this.update(cx, |this, cx| {
|
|
stash_task
|
|
.map_err(|e| {
|
|
this.show_error_toast("stash pop", e, cx);
|
|
})
|
|
.ok();
|
|
cx.notify();
|
|
})
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(active_repository) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
|
|
cx.spawn({
|
|
async move |this, cx| {
|
|
let stash_task = active_repository
|
|
.update(cx, |repo, cx| repo.stash_all(cx))?
|
|
.await;
|
|
this.update(cx, |this, cx| {
|
|
stash_task
|
|
.map_err(|e| {
|
|
this.show_error_toast("stash", e, cx);
|
|
})
|
|
.ok();
|
|
cx.notify();
|
|
})
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
|
|
self.commit_editor
|
|
.read(cx)
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap()
|
|
}
|
|
|
|
fn toggle_staged_for_selected(
|
|
&mut self,
|
|
_: &git::ToggleStaged,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(selected_entry) = self.get_selected_entry().cloned() {
|
|
self.toggle_staged_for_entry(&selected_entry, window, cx);
|
|
}
|
|
}
|
|
|
|
fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(index) = self.selected_entry else {
|
|
return;
|
|
};
|
|
self.stage_bulk(index, cx);
|
|
}
|
|
|
|
fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(selected_entry) = self.get_selected_entry() else {
|
|
return;
|
|
};
|
|
let Some(status_entry) = selected_entry.status_entry() else {
|
|
return;
|
|
};
|
|
if status_entry.staging != StageStatus::Staged {
|
|
self.change_file_stage(true, vec![status_entry.clone()], cx);
|
|
}
|
|
}
|
|
|
|
fn unstage_selected(
|
|
&mut self,
|
|
_: &git::UnstageFile,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(selected_entry) = self.get_selected_entry() else {
|
|
return;
|
|
};
|
|
let Some(status_entry) = selected_entry.status_entry() else {
|
|
return;
|
|
};
|
|
if status_entry.staging != StageStatus::Unstaged {
|
|
self.change_file_stage(false, vec![status_entry.clone()], cx);
|
|
}
|
|
}
|
|
|
|
fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.amend_pending {
|
|
return;
|
|
}
|
|
if self
|
|
.commit_editor
|
|
.focus_handle(cx)
|
|
.contains_focused(window, cx)
|
|
{
|
|
telemetry::event!("Git Committed", source = "Git Panel");
|
|
self.commit_changes(
|
|
CommitOptions {
|
|
amend: false,
|
|
signoff: self.signoff_enabled,
|
|
},
|
|
window,
|
|
cx,
|
|
)
|
|
} else {
|
|
cx.propagate();
|
|
}
|
|
}
|
|
|
|
fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self
|
|
.commit_editor
|
|
.focus_handle(cx)
|
|
.contains_focused(window, cx)
|
|
{
|
|
if self.head_commit(cx).is_some() {
|
|
if !self.amend_pending {
|
|
self.set_amend_pending(true, cx);
|
|
self.load_last_commit_message_if_empty(cx);
|
|
} else {
|
|
telemetry::event!("Git Amended", source = "Git Panel");
|
|
self.set_amend_pending(false, cx);
|
|
self.commit_changes(
|
|
CommitOptions {
|
|
amend: true,
|
|
signoff: self.signoff_enabled,
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
cx.propagate();
|
|
}
|
|
}
|
|
|
|
pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
|
|
self.active_repository
|
|
.as_ref()
|
|
.and_then(|repo| repo.read(cx).head_commit.as_ref())
|
|
.cloned()
|
|
}
|
|
|
|
pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context<Self>) {
|
|
if !self.commit_editor.read(cx).is_empty(cx) {
|
|
return;
|
|
}
|
|
let Some(head_commit) = self.head_commit(cx) else {
|
|
return;
|
|
};
|
|
let recent_sha = head_commit.sha.to_string();
|
|
let detail_task = self.load_commit_details(recent_sha, cx);
|
|
cx.spawn(async move |this, cx| {
|
|
if let Ok(message) = detail_task.await.map(|detail| detail.message) {
|
|
this.update(cx, |this, cx| {
|
|
this.commit_message_buffer(cx).update(cx, |buffer, cx| {
|
|
let start = buffer.anchor_before(0);
|
|
let end = buffer.anchor_after(buffer.len());
|
|
buffer.edit([(start..end, message)], None, cx);
|
|
});
|
|
})
|
|
.log_err();
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn custom_or_suggested_commit_message(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<String> {
|
|
let git_commit_language = self.commit_editor.read(cx).language_at(0, cx);
|
|
let message = self.commit_editor.read(cx).text(cx);
|
|
if message.is_empty() {
|
|
return self
|
|
.suggest_commit_message(cx)
|
|
.filter(|message| !message.trim().is_empty());
|
|
} else if message.trim().is_empty() {
|
|
return None;
|
|
}
|
|
let buffer = cx.new(|cx| {
|
|
let mut buffer = Buffer::local(message, cx);
|
|
buffer.set_language(git_commit_language, cx);
|
|
buffer
|
|
});
|
|
let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
|
|
let wrapped_message = editor.update(cx, |editor, cx| {
|
|
editor.select_all(&Default::default(), window, cx);
|
|
editor.rewrap(&Default::default(), window, cx);
|
|
editor.text(cx)
|
|
});
|
|
if wrapped_message.trim().is_empty() {
|
|
return None;
|
|
}
|
|
Some(wrapped_message)
|
|
}
|
|
|
|
fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
|
|
let text = self.commit_editor.read(cx).text(cx);
|
|
if !text.trim().is_empty() {
|
|
true
|
|
} else if text.is_empty() {
|
|
self.suggest_commit_message(cx)
|
|
.is_some_and(|text| !text.trim().is_empty())
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub(crate) fn commit_changes(
|
|
&mut self,
|
|
options: CommitOptions,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(active_repository) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
let error_spawn = |message, window: &mut Window, cx: &mut App| {
|
|
let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
|
|
cx.spawn(async move |_| {
|
|
prompt.await.ok();
|
|
})
|
|
.detach();
|
|
};
|
|
|
|
if self.has_unstaged_conflicts() {
|
|
error_spawn(
|
|
"There are still conflicts. You must stage these before committing",
|
|
window,
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let commit_message = self.custom_or_suggested_commit_message(window, cx);
|
|
|
|
let Some(mut message) = commit_message else {
|
|
self.commit_editor.read(cx).focus_handle(cx).focus(window);
|
|
return;
|
|
};
|
|
|
|
if self.add_coauthors {
|
|
self.fill_co_authors(&mut message, cx);
|
|
}
|
|
|
|
let task = if self.has_staged_changes() {
|
|
// Repository serializes all git operations, so we can just send a commit immediately
|
|
let commit_task = active_repository.update(cx, |repo, cx| {
|
|
repo.commit(message.into(), None, options, cx)
|
|
});
|
|
cx.background_spawn(async move { commit_task.await? })
|
|
} else {
|
|
let changed_files = self
|
|
.entries
|
|
.iter()
|
|
.filter_map(|entry| entry.status_entry())
|
|
.filter(|status_entry| !status_entry.status.is_created())
|
|
.map(|status_entry| status_entry.repo_path.clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
if changed_files.is_empty() {
|
|
error_spawn("No changes to commit", window, cx);
|
|
return;
|
|
}
|
|
|
|
let stage_task =
|
|
active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
|
|
cx.spawn(async move |_, cx| {
|
|
stage_task.await?;
|
|
let commit_task = active_repository.update(cx, |repo, cx| {
|
|
repo.commit(message.into(), None, options, cx)
|
|
})?;
|
|
commit_task.await?
|
|
})
|
|
};
|
|
let task = cx.spawn_in(window, async move |this, cx| {
|
|
let result = task.await;
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.pending_commit.take();
|
|
match result {
|
|
Ok(()) => {
|
|
this.commit_editor
|
|
.update(cx, |editor, cx| editor.clear(window, cx));
|
|
}
|
|
Err(e) => this.show_error_toast("commit", e, cx),
|
|
}
|
|
})
|
|
.ok();
|
|
});
|
|
|
|
self.pending_commit = Some(task);
|
|
}
|
|
|
|
fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(repo) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
telemetry::event!("Git Uncommitted");
|
|
|
|
let confirmation = self.check_for_pushed_commits(window, cx);
|
|
let prior_head = self.load_commit_details("HEAD".to_string(), cx);
|
|
|
|
let task = cx.spawn_in(window, async move |this, cx| {
|
|
let result = maybe!(async {
|
|
if let Ok(true) = confirmation.await {
|
|
let prior_head = prior_head.await?;
|
|
|
|
repo.update(cx, |repo, cx| {
|
|
repo.reset("HEAD^".to_string(), ResetMode::Soft, cx)
|
|
})?
|
|
.await??;
|
|
|
|
Ok(Some(prior_head))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
})
|
|
.await;
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.pending_commit.take();
|
|
match result {
|
|
Ok(None) => {}
|
|
Ok(Some(prior_commit)) => {
|
|
this.commit_editor.update(cx, |editor, cx| {
|
|
editor.set_text(prior_commit.message, window, cx)
|
|
});
|
|
}
|
|
Err(e) => this.show_error_toast("reset", e, cx),
|
|
}
|
|
})
|
|
.ok();
|
|
});
|
|
|
|
self.pending_commit = Some(task);
|
|
}
|
|
|
|
fn check_for_pushed_commits(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> impl Future<Output = anyhow::Result<bool>> + use<> {
|
|
let repo = self.active_repository.clone();
|
|
let mut cx = window.to_async(cx);
|
|
|
|
async move {
|
|
let repo = repo.context("No active repository")?;
|
|
|
|
let pushed_to: Vec<SharedString> = repo
|
|
.update(&mut cx, |repo, _| repo.check_for_pushed_commits())?
|
|
.await??;
|
|
|
|
if pushed_to.is_empty() {
|
|
Ok(true)
|
|
} else {
|
|
#[derive(strum::EnumIter, strum::VariantNames)]
|
|
#[strum(serialize_all = "title_case")]
|
|
enum CancelUncommit {
|
|
Uncommit,
|
|
Cancel,
|
|
}
|
|
let detail = format!(
|
|
"This commit was already pushed to {}.",
|
|
pushed_to.into_iter().join(", ")
|
|
);
|
|
let result = cx
|
|
.update(|window, cx| prompt("Are you sure?", Some(&detail), window, cx))?
|
|
.await?;
|
|
|
|
match result {
|
|
CancelUncommit::Cancel => Ok(false),
|
|
CancelUncommit::Uncommit => Ok(true),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Suggests a commit message based on the changed files and their statuses
|
|
pub fn suggest_commit_message(&self, cx: &App) -> Option<String> {
|
|
if let Some(merge_message) = self
|
|
.active_repository
|
|
.as_ref()
|
|
.and_then(|repo| repo.read(cx).merge.message.as_ref())
|
|
{
|
|
return Some(merge_message.to_string());
|
|
}
|
|
|
|
let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
|
|
Some(staged_entry)
|
|
} else if self.total_staged_count() == 0
|
|
&& let Some(single_tracked_entry) = &self.single_tracked_entry
|
|
{
|
|
Some(single_tracked_entry)
|
|
} else {
|
|
None
|
|
}?;
|
|
|
|
let action_text = if git_status_entry.status.is_deleted() {
|
|
Some("Delete")
|
|
} else if git_status_entry.status.is_created() {
|
|
Some("Create")
|
|
} else if git_status_entry.status.is_modified() {
|
|
Some("Update")
|
|
} else {
|
|
None
|
|
}?;
|
|
|
|
let file_name = git_status_entry
|
|
.repo_path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy();
|
|
|
|
Some(format!("{} {}", action_text, file_name))
|
|
}
|
|
|
|
fn generate_commit_message_action(
|
|
&mut self,
|
|
_: &git::GenerateCommitMessage,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.generate_commit_message(cx);
|
|
}
|
|
|
|
/// Generates a commit message using an LLM.
|
|
pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
|
|
if !self.can_commit() || DisableAiSettings::get_global(cx).disable_ai {
|
|
return;
|
|
}
|
|
|
|
let model = match current_language_model(cx) {
|
|
Some(value) => value,
|
|
None => return,
|
|
};
|
|
|
|
let Some(repo) = self.active_repository.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
telemetry::event!("Git Commit Message Generated");
|
|
|
|
let diff = repo.update(cx, |repo, cx| {
|
|
if self.has_staged_changes() {
|
|
repo.diff(DiffType::HeadToIndex, cx)
|
|
} else {
|
|
repo.diff(DiffType::HeadToWorktree, cx)
|
|
}
|
|
});
|
|
|
|
let temperature = AgentSettings::temperature_for_model(&model, cx);
|
|
|
|
self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| {
|
|
async move {
|
|
let _defer = cx.on_drop(&this, |this, _cx| {
|
|
this.generate_commit_message_task.take();
|
|
});
|
|
|
|
let mut diff_text = match diff.await {
|
|
Ok(result) => match result {
|
|
Ok(text) => text,
|
|
Err(e) => {
|
|
Self::show_commit_message_error(&this, &e, cx);
|
|
return anyhow::Ok(());
|
|
}
|
|
},
|
|
Err(e) => {
|
|
Self::show_commit_message_error(&this, &e, cx);
|
|
return anyhow::Ok(());
|
|
}
|
|
};
|
|
|
|
const ONE_MB: usize = 1_000_000;
|
|
if diff_text.len() > ONE_MB {
|
|
diff_text = diff_text.chars().take(ONE_MB).collect()
|
|
}
|
|
|
|
let subject = this.update(cx, |this, cx| {
|
|
this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
|
|
})?;
|
|
|
|
let text_empty = subject.trim().is_empty();
|
|
|
|
let content = if text_empty {
|
|
format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}")
|
|
} else {
|
|
format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n")
|
|
};
|
|
|
|
const PROMPT: &str = include_str!("commit_message_prompt.txt");
|
|
|
|
let request = LanguageModelRequest {
|
|
thread_id: None,
|
|
prompt_id: None,
|
|
intent: Some(CompletionIntent::GenerateGitCommitMessage),
|
|
mode: None,
|
|
messages: vec![LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: vec![content.into()],
|
|
cache: false,
|
|
}],
|
|
tools: Vec::new(),
|
|
tool_choice: None,
|
|
stop: Vec::new(),
|
|
temperature,
|
|
thinking_allowed: false,
|
|
};
|
|
|
|
let stream = model.stream_completion_text(request, cx);
|
|
match stream.await {
|
|
Ok(mut messages) => {
|
|
if !text_empty {
|
|
this.update(cx, |this, cx| {
|
|
this.commit_message_buffer(cx).update(cx, |buffer, cx| {
|
|
let insert_position = buffer.anchor_before(buffer.len());
|
|
buffer.edit([(insert_position..insert_position, "\n")], None, cx)
|
|
});
|
|
})?;
|
|
}
|
|
|
|
while let Some(message) = messages.stream.next().await {
|
|
match message {
|
|
Ok(text) => {
|
|
this.update(cx, |this, cx| {
|
|
this.commit_message_buffer(cx).update(cx, |buffer, cx| {
|
|
let insert_position = buffer.anchor_before(buffer.len());
|
|
buffer.edit([(insert_position..insert_position, text)], None, cx);
|
|
});
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
Self::show_commit_message_error(&this, &e, cx);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
Self::show_commit_message_error(&this, &e, cx);
|
|
}
|
|
}
|
|
|
|
anyhow::Ok(())
|
|
}
|
|
.log_err().await
|
|
}));
|
|
}
|
|
|
|
fn get_fetch_options(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Option<FetchOptions>> {
|
|
let repo = self.active_repository.clone();
|
|
let workspace = self.workspace.clone();
|
|
|
|
cx.spawn_in(window, async move |_, cx| {
|
|
let repo = repo?;
|
|
let remotes = repo
|
|
.update(cx, |repo, _| repo.get_remotes(None))
|
|
.ok()?
|
|
.await
|
|
.ok()?
|
|
.log_err()?;
|
|
|
|
let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
|
|
if remotes.len() > 1 {
|
|
remotes.push(FetchOptions::All);
|
|
}
|
|
let selection = cx
|
|
.update(|window, cx| {
|
|
picker_prompt::prompt(
|
|
"Pick which remote to fetch",
|
|
remotes.iter().map(|r| r.name()).collect(),
|
|
workspace,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.ok()?
|
|
.await?;
|
|
remotes.get(selection).cloned()
|
|
})
|
|
}
|
|
|
|
pub(crate) fn fetch(
|
|
&mut self,
|
|
is_fetch_all: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if !self.can_push_and_pull(cx) {
|
|
return;
|
|
}
|
|
|
|
let Some(repo) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
telemetry::event!("Git Fetched");
|
|
let askpass = self.askpass_delegate("git fetch", window, cx);
|
|
let this = cx.weak_entity();
|
|
|
|
let fetch_options = if is_fetch_all {
|
|
Task::ready(Some(FetchOptions::All))
|
|
} else {
|
|
self.get_fetch_options(window, cx)
|
|
};
|
|
|
|
window
|
|
.spawn(cx, async move |cx| {
|
|
let Some(fetch_options) = fetch_options.await else {
|
|
return Ok(());
|
|
};
|
|
let fetch = repo.update(cx, |repo, cx| {
|
|
repo.fetch(fetch_options.clone(), askpass, cx)
|
|
})?;
|
|
|
|
let remote_message = fetch.await?;
|
|
this.update(cx, |this, cx| {
|
|
let action = match fetch_options {
|
|
FetchOptions::All => RemoteAction::Fetch(None),
|
|
FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
|
|
};
|
|
match remote_message {
|
|
Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
|
|
Err(e) => {
|
|
log::error!("Error while fetching {:?}", e);
|
|
this.show_error_toast(action.name(), e, cx)
|
|
}
|
|
}
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.ok();
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
let path = cx.prompt_for_paths(gpui::PathPromptOptions {
|
|
files: false,
|
|
directories: true,
|
|
multiple: false,
|
|
prompt: Some("Select as Repository Destination".into()),
|
|
});
|
|
|
|
let workspace = self.workspace.clone();
|
|
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let mut paths = path.await.ok()?.ok()??;
|
|
let mut path = paths.pop()?;
|
|
let repo_name = repo
|
|
.split(std::path::MAIN_SEPARATOR_STR)
|
|
.last()?
|
|
.strip_suffix(".git")?
|
|
.to_owned();
|
|
|
|
let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
|
|
|
|
let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
|
|
Ok(_) => cx.update(|window, cx| {
|
|
window.prompt(
|
|
PromptLevel::Info,
|
|
&format!("Git Clone: {}", repo_name),
|
|
None,
|
|
&["Add repo to project", "Open repo in new project"],
|
|
cx,
|
|
)
|
|
}),
|
|
Err(e) => {
|
|
this.update(cx, |this: &mut GitPanel, cx| {
|
|
let toast = StatusToast::new(e.to_string(), cx, |this, _| {
|
|
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
|
|
.dismiss_button(true)
|
|
});
|
|
|
|
this.workspace
|
|
.update(cx, |workspace, cx| {
|
|
workspace.toggle_status_toast(toast, cx);
|
|
})
|
|
.ok();
|
|
})
|
|
.ok()?;
|
|
|
|
return None;
|
|
}
|
|
}
|
|
.ok()?;
|
|
|
|
path.push(repo_name);
|
|
match prompt_answer.await.ok()? {
|
|
0 => {
|
|
workspace
|
|
.update(cx, |workspace, cx| {
|
|
workspace
|
|
.project()
|
|
.update(cx, |project, cx| {
|
|
project.create_worktree(path.as_path(), true, cx)
|
|
})
|
|
.detach();
|
|
})
|
|
.ok();
|
|
}
|
|
1 => {
|
|
workspace
|
|
.update(cx, move |workspace, cx| {
|
|
workspace::open_new(
|
|
Default::default(),
|
|
workspace.app_state().clone(),
|
|
cx,
|
|
move |workspace, _, cx| {
|
|
cx.activate(true);
|
|
workspace
|
|
.project()
|
|
.update(cx, |project, cx| {
|
|
project.create_worktree(&path, true, cx)
|
|
})
|
|
.detach();
|
|
},
|
|
)
|
|
.detach();
|
|
})
|
|
.ok();
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
Some(())
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let worktrees = self
|
|
.project
|
|
.read(cx)
|
|
.visible_worktrees(cx)
|
|
.collect::<Vec<_>>();
|
|
|
|
let worktree = if worktrees.len() == 1 {
|
|
Task::ready(Some(worktrees.first().unwrap().clone()))
|
|
} else if worktrees.is_empty() {
|
|
let result = window.prompt(
|
|
PromptLevel::Warning,
|
|
"Unable to initialize a git repository",
|
|
Some("Open a directory first"),
|
|
&["Ok"],
|
|
cx,
|
|
);
|
|
cx.background_executor()
|
|
.spawn(async move {
|
|
result.await.ok();
|
|
})
|
|
.detach();
|
|
return;
|
|
} else {
|
|
let worktree_directories = worktrees
|
|
.iter()
|
|
.map(|worktree| worktree.read(cx).abs_path())
|
|
.map(|worktree_abs_path| {
|
|
if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
|
|
Path::new("~")
|
|
.join(path)
|
|
.to_string_lossy()
|
|
.to_string()
|
|
.into()
|
|
} else {
|
|
worktree_abs_path.to_string_lossy().to_string().into()
|
|
}
|
|
})
|
|
.collect_vec();
|
|
let prompt = picker_prompt::prompt(
|
|
"Where would you like to initialize this git repository?",
|
|
worktree_directories,
|
|
self.workspace.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
|
|
cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
|
|
};
|
|
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let worktree = match worktree.await {
|
|
Some(worktree) => worktree,
|
|
None => {
|
|
return;
|
|
}
|
|
};
|
|
|
|
let Ok(result) = this.update(cx, |this, cx| {
|
|
let fallback_branch_name = GitPanelSettings::get_global(cx)
|
|
.fallback_branch_name
|
|
.clone();
|
|
this.project.read(cx).git_init(
|
|
worktree.read(cx).abs_path(),
|
|
fallback_branch_name,
|
|
cx,
|
|
)
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
let result = result.await;
|
|
|
|
this.update_in(cx, |this, _, cx| match result {
|
|
Ok(()) => {}
|
|
Err(e) => this.show_error_toast("init", e, cx),
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if !self.can_push_and_pull(cx) {
|
|
return;
|
|
}
|
|
let Some(repo) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
let Some(branch) = repo.read(cx).branch.as_ref() else {
|
|
return;
|
|
};
|
|
telemetry::event!("Git Pulled");
|
|
let branch = branch.clone();
|
|
let remote = self.get_remote(false, window, cx);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let remote = match remote.await {
|
|
Ok(Some(remote)) => remote,
|
|
Ok(None) => {
|
|
return Ok(());
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to get current remote: {}", e);
|
|
this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
|
|
.ok();
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let askpass = this.update_in(cx, |this, window, cx| {
|
|
this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
|
|
})?;
|
|
|
|
let pull = repo.update(cx, |repo, cx| {
|
|
repo.pull(
|
|
branch.name().to_owned().into(),
|
|
remote.name.clone(),
|
|
askpass,
|
|
cx,
|
|
)
|
|
})?;
|
|
|
|
let remote_message = pull.await?;
|
|
|
|
let action = RemoteAction::Pull(remote);
|
|
this.update(cx, |this, cx| match remote_message {
|
|
Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
|
|
Err(e) => {
|
|
log::error!("Error while pulling {:?}", e);
|
|
this.show_error_toast(action.name(), e, cx)
|
|
}
|
|
})
|
|
.ok();
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
pub(crate) fn push(
|
|
&mut self,
|
|
force_push: bool,
|
|
select_remote: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if !self.can_push_and_pull(cx) {
|
|
return;
|
|
}
|
|
let Some(repo) = self.active_repository.clone() else {
|
|
return;
|
|
};
|
|
let Some(branch) = repo.read(cx).branch.as_ref() else {
|
|
return;
|
|
};
|
|
telemetry::event!("Git Pushed");
|
|
let branch = branch.clone();
|
|
|
|
let options = if force_push {
|
|
Some(PushOptions::Force)
|
|
} else {
|
|
match branch.upstream {
|
|
Some(Upstream {
|
|
tracking: UpstreamTracking::Gone,
|
|
..
|
|
})
|
|
| None => Some(PushOptions::SetUpstream),
|
|
_ => None,
|
|
}
|
|
};
|
|
let remote = self.get_remote(select_remote, window, cx);
|
|
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let remote = match remote.await {
|
|
Ok(Some(remote)) => remote,
|
|
Ok(None) => {
|
|
return Ok(());
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to get current remote: {}", e);
|
|
this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
|
|
.ok();
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let askpass_delegate = this.update_in(cx, |this, window, cx| {
|
|
this.askpass_delegate(format!("git push {}", remote.name), window, cx)
|
|
})?;
|
|
|
|
let push = repo.update(cx, |repo, cx| {
|
|
repo.push(
|
|
branch.name().to_owned().into(),
|
|
remote.name.clone(),
|
|
options,
|
|
askpass_delegate,
|
|
cx,
|
|
)
|
|
})?;
|
|
|
|
let remote_output = push.await?;
|
|
|
|
let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
|
|
this.update(cx, |this, cx| match remote_output {
|
|
Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
|
|
Err(e) => {
|
|
log::error!("Error while pushing {:?}", e);
|
|
this.show_error_toast(action.name(), e, cx)
|
|
}
|
|
})?;
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn askpass_delegate(
|
|
&self,
|
|
operation: impl Into<SharedString>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> AskPassDelegate {
|
|
let this = cx.weak_entity();
|
|
let operation = operation.into();
|
|
let window = window.window_handle();
|
|
AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
|
|
window
|
|
.update(cx, |_, window, cx| {
|
|
this.update(cx, |this, cx| {
|
|
this.workspace.update(cx, |workspace, cx| {
|
|
workspace.toggle_modal(window, cx, |window, cx| {
|
|
AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
|
|
});
|
|
})
|
|
})
|
|
})
|
|
.ok();
|
|
})
|
|
}
|
|
|
|
fn can_push_and_pull(&self, cx: &App) -> bool {
|
|
!self.project.read(cx).is_via_collab()
|
|
}
|
|
|
|
fn get_remote(
|
|
&mut self,
|
|
always_select: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
|
|
let repo = self.active_repository.clone();
|
|
let workspace = self.workspace.clone();
|
|
let mut cx = window.to_async(cx);
|
|
|
|
async move {
|
|
let repo = repo.context("No active repository")?;
|
|
let current_remotes: Vec<Remote> = repo
|
|
.update(&mut cx, |repo, _| {
|
|
let current_branch = if always_select {
|
|
None
|
|
} else {
|
|
let current_branch = repo.branch.as_ref().context("No active branch")?;
|
|
Some(current_branch.name().to_string())
|
|
};
|
|
anyhow::Ok(repo.get_remotes(current_branch))
|
|
})??
|
|
.await??;
|
|
|
|
let current_remotes: Vec<_> = current_remotes
|
|
.into_iter()
|
|
.map(|remotes| remotes.name)
|
|
.collect();
|
|
let selection = cx
|
|
.update(|window, cx| {
|
|
picker_prompt::prompt(
|
|
"Pick which remote to push to",
|
|
current_remotes.clone(),
|
|
workspace,
|
|
window,
|
|
cx,
|
|
)
|
|
})?
|
|
.await;
|
|
|
|
Ok(selection.map(|selection| Remote {
|
|
name: current_remotes[selection].clone(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
pub fn load_local_committer(&mut self, cx: &Context<Self>) {
|
|
if self.local_committer_task.is_none() {
|
|
self.local_committer_task = Some(cx.spawn(async move |this, cx| {
|
|
let committer = get_git_committer(cx).await;
|
|
this.update(cx, |this, cx| {
|
|
this.local_committer = Some(committer);
|
|
cx.notify()
|
|
})
|
|
.ok();
|
|
}));
|
|
}
|
|
}
|
|
|
|
fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
|
|
let mut new_co_authors = Vec::new();
|
|
let project = self.project.read(cx);
|
|
|
|
let Some(room) = self
|
|
.workspace
|
|
.upgrade()
|
|
.and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
|
|
else {
|
|
return Vec::default();
|
|
};
|
|
|
|
let room = room.read(cx);
|
|
|
|
for (peer_id, collaborator) in project.collaborators() {
|
|
if collaborator.is_host {
|
|
continue;
|
|
}
|
|
|
|
let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
|
|
continue;
|
|
};
|
|
if !participant.can_write() {
|
|
continue;
|
|
}
|
|
if let Some(email) = &collaborator.committer_email {
|
|
let name = collaborator
|
|
.committer_name
|
|
.clone()
|
|
.or_else(|| participant.user.name.clone())
|
|
.unwrap_or_else(|| participant.user.github_login.clone().to_string());
|
|
new_co_authors.push((name.clone(), email.clone()))
|
|
}
|
|
}
|
|
if !project.is_local()
|
|
&& !project.is_read_only(cx)
|
|
&& let Some(local_committer) = self.local_committer(room, cx)
|
|
{
|
|
new_co_authors.push(local_committer);
|
|
}
|
|
new_co_authors
|
|
}
|
|
|
|
fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
|
|
let user = room.local_participant_user(cx)?;
|
|
let committer = self.local_committer.as_ref()?;
|
|
let email = committer.email.clone()?;
|
|
let name = committer
|
|
.name
|
|
.clone()
|
|
.or_else(|| user.name.clone())
|
|
.unwrap_or_else(|| user.github_login.clone().to_string());
|
|
Some((name, email))
|
|
}
|
|
|
|
fn toggle_fill_co_authors(
|
|
&mut self,
|
|
_: &ToggleFillCoAuthors,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.add_coauthors = !self.add_coauthors;
|
|
cx.notify();
|
|
}
|
|
|
|
fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
|
|
const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
|
|
|
|
let existing_text = message.to_ascii_lowercase();
|
|
let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
|
|
let mut ends_with_co_authors = false;
|
|
let existing_co_authors = existing_text
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let line = line.trim();
|
|
if line.starts_with(&lowercase_co_author_prefix) {
|
|
ends_with_co_authors = true;
|
|
Some(line)
|
|
} else {
|
|
ends_with_co_authors = false;
|
|
None
|
|
}
|
|
})
|
|
.collect::<HashSet<_>>();
|
|
|
|
let new_co_authors = self
|
|
.potential_co_authors(cx)
|
|
.into_iter()
|
|
.filter(|(_, email)| {
|
|
!existing_co_authors
|
|
.iter()
|
|
.any(|existing| existing.contains(email.as_str()))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if new_co_authors.is_empty() {
|
|
return;
|
|
}
|
|
|
|
if !ends_with_co_authors {
|
|
message.push('\n');
|
|
}
|
|
for (name, email) in new_co_authors {
|
|
message.push('\n');
|
|
message.push_str(CO_AUTHOR_PREFIX);
|
|
message.push_str(&name);
|
|
message.push_str(" <");
|
|
message.push_str(&email);
|
|
message.push('>');
|
|
}
|
|
message.push('\n');
|
|
}
|
|
|
|
fn schedule_update(
|
|
&mut self,
|
|
clear_pending: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let handle = cx.entity().downgrade();
|
|
self.reopen_commit_buffer(window, cx);
|
|
self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
|
|
cx.background_executor().timer(UPDATE_DEBOUNCE).await;
|
|
if let Some(git_panel) = handle.upgrade() {
|
|
git_panel
|
|
.update_in(cx, |git_panel, window, cx| {
|
|
if clear_pending {
|
|
git_panel.clear_pending();
|
|
}
|
|
git_panel.update_visible_entries(cx);
|
|
git_panel.update_scrollbar_properties(window, cx);
|
|
})
|
|
.ok();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(active_repo) = self.active_repository.as_ref() else {
|
|
return;
|
|
};
|
|
let load_buffer = active_repo.update(cx, |active_repo, cx| {
|
|
let project = self.project.read(cx);
|
|
active_repo.open_commit_buffer(
|
|
Some(project.languages().clone()),
|
|
project.buffer_store().clone(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
cx.spawn_in(window, async move |git_panel, cx| {
|
|
let buffer = load_buffer.await?;
|
|
git_panel.update_in(cx, |git_panel, window, cx| {
|
|
if git_panel
|
|
.commit_editor
|
|
.read(cx)
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.as_ref()
|
|
!= Some(&buffer)
|
|
{
|
|
git_panel.commit_editor = cx.new(|cx| {
|
|
commit_message_editor(
|
|
buffer,
|
|
git_panel.suggest_commit_message(cx).map(SharedString::from),
|
|
git_panel.project.clone(),
|
|
true,
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
}
|
|
})
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn clear_pending(&mut self) {
|
|
self.pending.retain(|v| !v.finished)
|
|
}
|
|
|
|
fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
|
|
let bulk_staging = self.bulk_staging.take();
|
|
let last_staged_path_prev_index = bulk_staging
|
|
.as_ref()
|
|
.and_then(|op| self.entry_by_path(&op.anchor, cx));
|
|
|
|
self.entries.clear();
|
|
self.single_staged_entry.take();
|
|
self.single_tracked_entry.take();
|
|
self.conflicted_count = 0;
|
|
self.conflicted_staged_count = 0;
|
|
self.new_count = 0;
|
|
self.tracked_count = 0;
|
|
self.new_staged_count = 0;
|
|
self.tracked_staged_count = 0;
|
|
self.entry_count = 0;
|
|
|
|
let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
|
|
|
|
let mut changed_entries = Vec::new();
|
|
let mut new_entries = Vec::new();
|
|
let mut conflict_entries = Vec::new();
|
|
let mut single_staged_entry = None;
|
|
let mut staged_count = 0;
|
|
let mut max_width_item: Option<(RepoPath, usize)> = None;
|
|
|
|
let Some(repo) = self.active_repository.as_ref() else {
|
|
// Just clear entries if no repository is active.
|
|
cx.notify();
|
|
return;
|
|
};
|
|
|
|
let repo = repo.read(cx);
|
|
|
|
for entry in repo.cached_status() {
|
|
let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
|
|
let is_new = entry.status.is_created();
|
|
let staging = entry.status.staging();
|
|
|
|
if self.pending.iter().any(|pending| {
|
|
pending.target_status == TargetStatus::Reverted
|
|
&& !pending.finished
|
|
&& pending
|
|
.entries
|
|
.iter()
|
|
.any(|pending| pending.repo_path == entry.repo_path)
|
|
}) {
|
|
continue;
|
|
}
|
|
|
|
let abs_path = repo.work_directory_abs_path.join(&entry.repo_path.0);
|
|
let entry = GitStatusEntry {
|
|
repo_path: entry.repo_path.clone(),
|
|
abs_path,
|
|
status: entry.status,
|
|
staging,
|
|
};
|
|
|
|
if staging.has_staged() {
|
|
staged_count += 1;
|
|
single_staged_entry = Some(entry.clone());
|
|
}
|
|
|
|
let width_estimate = Self::item_width_estimate(
|
|
entry.parent_dir().map(|s| s.len()).unwrap_or(0),
|
|
entry.display_name().len(),
|
|
);
|
|
|
|
match max_width_item.as_mut() {
|
|
Some((repo_path, estimate)) => {
|
|
if width_estimate > *estimate {
|
|
*repo_path = entry.repo_path.clone();
|
|
*estimate = width_estimate;
|
|
}
|
|
}
|
|
None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
|
|
}
|
|
|
|
if sort_by_path {
|
|
changed_entries.push(entry);
|
|
} else if is_conflict {
|
|
conflict_entries.push(entry);
|
|
} else if is_new {
|
|
new_entries.push(entry);
|
|
} else {
|
|
changed_entries.push(entry);
|
|
}
|
|
}
|
|
|
|
let mut pending_staged_count = 0;
|
|
let mut last_pending_staged = None;
|
|
let mut pending_status_for_single_staged = None;
|
|
for pending in self.pending.iter() {
|
|
if pending.target_status == TargetStatus::Staged {
|
|
pending_staged_count += pending.entries.len();
|
|
last_pending_staged = pending.entries.first().cloned();
|
|
}
|
|
if let Some(single_staged) = &single_staged_entry
|
|
&& pending
|
|
.entries
|
|
.iter()
|
|
.any(|entry| entry.repo_path == single_staged.repo_path)
|
|
{
|
|
pending_status_for_single_staged = Some(pending.target_status);
|
|
}
|
|
}
|
|
|
|
if conflict_entries.is_empty() && staged_count == 1 && pending_staged_count == 0 {
|
|
match pending_status_for_single_staged {
|
|
Some(TargetStatus::Staged) | None => {
|
|
self.single_staged_entry = single_staged_entry;
|
|
}
|
|
_ => {}
|
|
}
|
|
} else if conflict_entries.is_empty() && pending_staged_count == 1 {
|
|
self.single_staged_entry = last_pending_staged;
|
|
}
|
|
|
|
if conflict_entries.is_empty() && changed_entries.len() == 1 {
|
|
self.single_tracked_entry = changed_entries.first().cloned();
|
|
}
|
|
|
|
if !conflict_entries.is_empty() {
|
|
self.entries.push(GitListEntry::Header(GitHeaderEntry {
|
|
header: Section::Conflict,
|
|
}));
|
|
self.entries
|
|
.extend(conflict_entries.into_iter().map(GitListEntry::Status));
|
|
}
|
|
|
|
if !changed_entries.is_empty() {
|
|
if !sort_by_path {
|
|
self.entries.push(GitListEntry::Header(GitHeaderEntry {
|
|
header: Section::Tracked,
|
|
}));
|
|
}
|
|
self.entries
|
|
.extend(changed_entries.into_iter().map(GitListEntry::Status));
|
|
}
|
|
if !new_entries.is_empty() {
|
|
self.entries.push(GitListEntry::Header(GitHeaderEntry {
|
|
header: Section::New,
|
|
}));
|
|
self.entries
|
|
.extend(new_entries.into_iter().map(GitListEntry::Status));
|
|
}
|
|
|
|
if let Some((repo_path, _)) = max_width_item {
|
|
self.max_width_item_index = self.entries.iter().position(|entry| match entry {
|
|
GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path,
|
|
GitListEntry::Header(_) => false,
|
|
});
|
|
}
|
|
|
|
self.update_counts(repo);
|
|
|
|
let bulk_staging_anchor_new_index = bulk_staging
|
|
.as_ref()
|
|
.filter(|op| op.repo_id == repo.id)
|
|
.and_then(|op| self.entry_by_path(&op.anchor, cx));
|
|
if bulk_staging_anchor_new_index == last_staged_path_prev_index
|
|
&& let Some(index) = bulk_staging_anchor_new_index
|
|
&& let Some(entry) = self.entries.get(index)
|
|
&& let Some(entry) = entry.status_entry()
|
|
&& self.entry_staging(entry) == StageStatus::Staged
|
|
{
|
|
self.bulk_staging = bulk_staging;
|
|
}
|
|
|
|
self.select_first_entry_if_none(cx);
|
|
|
|
let suggested_commit_message = self.suggest_commit_message(cx);
|
|
let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
|
|
|
|
self.commit_editor.update(cx, |editor, cx| {
|
|
editor.set_placeholder_text(Arc::from(placeholder_text), cx)
|
|
});
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
fn header_state(&self, header_type: Section) -> ToggleState {
|
|
let (staged_count, count) = match header_type {
|
|
Section::New => (self.new_staged_count, self.new_count),
|
|
Section::Tracked => (self.tracked_staged_count, self.tracked_count),
|
|
Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
|
|
};
|
|
if staged_count == 0 {
|
|
ToggleState::Unselected
|
|
} else if count == staged_count {
|
|
ToggleState::Selected
|
|
} else {
|
|
ToggleState::Indeterminate
|
|
}
|
|
}
|
|
|
|
fn update_counts(&mut self, repo: &Repository) {
|
|
self.show_placeholders = false;
|
|
self.conflicted_count = 0;
|
|
self.conflicted_staged_count = 0;
|
|
self.new_count = 0;
|
|
self.tracked_count = 0;
|
|
self.new_staged_count = 0;
|
|
self.tracked_staged_count = 0;
|
|
self.entry_count = 0;
|
|
for entry in &self.entries {
|
|
let Some(status_entry) = entry.status_entry() else {
|
|
continue;
|
|
};
|
|
self.entry_count += 1;
|
|
if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
|
|
self.conflicted_count += 1;
|
|
if self.entry_staging(status_entry).has_staged() {
|
|
self.conflicted_staged_count += 1;
|
|
}
|
|
} else if status_entry.status.is_created() {
|
|
self.new_count += 1;
|
|
if self.entry_staging(status_entry).has_staged() {
|
|
self.new_staged_count += 1;
|
|
}
|
|
} else {
|
|
self.tracked_count += 1;
|
|
if self.entry_staging(status_entry).has_staged() {
|
|
self.tracked_staged_count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn entry_staging(&self, entry: &GitStatusEntry) -> StageStatus {
|
|
for pending in self.pending.iter().rev() {
|
|
if pending
|
|
.entries
|
|
.iter()
|
|
.any(|pending_entry| pending_entry.repo_path == entry.repo_path)
|
|
{
|
|
match pending.target_status {
|
|
TargetStatus::Staged => return StageStatus::Staged,
|
|
TargetStatus::Unstaged => return StageStatus::Unstaged,
|
|
TargetStatus::Reverted => continue,
|
|
TargetStatus::Unchanged => continue,
|
|
}
|
|
}
|
|
}
|
|
entry.staging
|
|
}
|
|
|
|
pub(crate) fn has_staged_changes(&self) -> bool {
|
|
self.tracked_staged_count > 0
|
|
|| self.new_staged_count > 0
|
|
|| self.conflicted_staged_count > 0
|
|
}
|
|
|
|
pub(crate) fn has_unstaged_changes(&self) -> bool {
|
|
self.tracked_count > self.tracked_staged_count
|
|
|| self.new_count > self.new_staged_count
|
|
|| self.conflicted_count > self.conflicted_staged_count
|
|
}
|
|
|
|
fn has_tracked_changes(&self) -> bool {
|
|
self.tracked_count > 0
|
|
}
|
|
|
|
pub fn has_unstaged_conflicts(&self) -> bool {
|
|
self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
|
|
}
|
|
|
|
fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
|
|
let action = action.into();
|
|
let Some(workspace) = self.workspace.upgrade() else {
|
|
return;
|
|
};
|
|
|
|
let message = e.to_string().trim().to_string();
|
|
if message
|
|
.matches(git::repository::REMOTE_CANCELLED_BY_USER)
|
|
.next()
|
|
.is_some()
|
|
{ // Hide the cancelled by user message
|
|
} else {
|
|
workspace.update(cx, |workspace, cx| {
|
|
let workspace_weak = cx.weak_entity();
|
|
let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
|
|
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
|
|
.action("View Log", move |window, cx| {
|
|
let message = message.clone();
|
|
let action = action.clone();
|
|
workspace_weak
|
|
.update(cx, move |workspace, cx| {
|
|
Self::open_output(action, workspace, &message, window, cx)
|
|
})
|
|
.ok();
|
|
})
|
|
});
|
|
workspace.toggle_status_toast(toast, cx)
|
|
});
|
|
}
|
|
}
|
|
|
|
fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
|
|
where
|
|
E: std::fmt::Debug + std::fmt::Display,
|
|
{
|
|
if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
|
|
let _ = workspace.update(cx, |workspace, cx| {
|
|
struct CommitMessageError;
|
|
let notification_id = NotificationId::unique::<CommitMessageError>();
|
|
workspace.show_notification(notification_id, cx, |cx| {
|
|
cx.new(|cx| {
|
|
ErrorMessagePrompt::new(
|
|
format!("Failed to generate commit message: {err}"),
|
|
cx,
|
|
)
|
|
})
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
|
|
let Some(workspace) = self.workspace.upgrade() else {
|
|
return;
|
|
};
|
|
|
|
workspace.update(cx, |workspace, cx| {
|
|
let SuccessMessage { message, style } = remote_output::format_output(&action, info);
|
|
let workspace_weak = cx.weak_entity();
|
|
let operation = action.name();
|
|
|
|
let status_toast = StatusToast::new(message, cx, move |this, _cx| {
|
|
use remote_output::SuccessStyle::*;
|
|
match style {
|
|
Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
|
|
ToastWithLog { output } => this
|
|
.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
|
|
.action("View Log", move |window, cx| {
|
|
let output = output.clone();
|
|
let output =
|
|
format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
|
|
workspace_weak
|
|
.update(cx, move |workspace, cx| {
|
|
Self::open_output(operation, workspace, &output, window, cx)
|
|
})
|
|
.ok();
|
|
}),
|
|
PushPrLink { text, link } => this
|
|
.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
|
|
.action(text, move |_, cx| cx.open_url(&link)),
|
|
}
|
|
});
|
|
workspace.toggle_status_toast(status_toast, cx)
|
|
});
|
|
}
|
|
|
|
fn open_output(
|
|
operation: impl Into<SharedString>,
|
|
workspace: &mut Workspace,
|
|
output: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) {
|
|
let operation = operation.into();
|
|
let buffer = cx.new(|cx| Buffer::local(output, cx));
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.set_capability(language::Capability::ReadOnly, cx);
|
|
});
|
|
let editor = cx.new(|cx| {
|
|
let mut editor = Editor::for_buffer(buffer, None, window, cx);
|
|
editor.buffer().update(cx, |buffer, cx| {
|
|
buffer.set_title(format!("Output from git {operation}"), cx);
|
|
});
|
|
editor.set_read_only(true);
|
|
editor
|
|
});
|
|
|
|
workspace.add_item_to_center(Box::new(editor), window, cx);
|
|
}
|
|
|
|
pub fn can_commit(&self) -> bool {
|
|
(self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
|
|
}
|
|
|
|
pub fn can_stage_all(&self) -> bool {
|
|
self.has_unstaged_changes()
|
|
}
|
|
|
|
pub fn can_unstage_all(&self) -> bool {
|
|
self.has_staged_changes()
|
|
}
|
|
|
|
// eventually we'll need to take depth into account here
|
|
// if we add a tree view
|
|
fn item_width_estimate(path: usize, file_name: usize) -> usize {
|
|
path + file_name
|
|
}
|
|
|
|
fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
|
|
let focus_handle = self.focus_handle.clone();
|
|
let has_tracked_changes = self.has_tracked_changes();
|
|
let has_staged_changes = self.has_staged_changes();
|
|
let has_unstaged_changes = self.has_unstaged_changes();
|
|
let has_new_changes = self.new_count > 0;
|
|
|
|
PopoverMenu::new(id.into())
|
|
.trigger(
|
|
IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
|
|
.icon_size(IconSize::Small)
|
|
.icon_color(Color::Muted),
|
|
)
|
|
.menu(move |window, cx| {
|
|
Some(git_panel_context_menu(
|
|
focus_handle.clone(),
|
|
GitMenuState {
|
|
has_tracked_changes,
|
|
has_staged_changes,
|
|
has_unstaged_changes,
|
|
has_new_changes,
|
|
},
|
|
window,
|
|
cx,
|
|
))
|
|
})
|
|
.anchor(Corner::TopRight)
|
|
}
|
|
|
|
pub(crate) fn render_generate_commit_message_button(
|
|
&self,
|
|
cx: &Context<Self>,
|
|
) -> Option<AnyElement> {
|
|
current_language_model(cx).is_some().then(|| {
|
|
if self.generate_commit_message_task.is_some() {
|
|
return h_flex()
|
|
.gap_1()
|
|
.child(
|
|
Icon::new(IconName::ArrowCircle)
|
|
.size(IconSize::XSmall)
|
|
.color(Color::Info)
|
|
.with_animation(
|
|
"arrow-circle",
|
|
Animation::new(Duration::from_secs(2)).repeat(),
|
|
|icon, delta| {
|
|
icon.transform(Transformation::rotate(percentage(delta)))
|
|
},
|
|
),
|
|
)
|
|
.child(
|
|
Label::new("Generating Commit...")
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted),
|
|
)
|
|
.into_any_element();
|
|
}
|
|
|
|
let can_commit = self.can_commit();
|
|
let editor_focus_handle = self.commit_editor.focus_handle(cx);
|
|
IconButton::new("generate-commit-message", IconName::AiEdit)
|
|
.shape(ui::IconButtonShape::Square)
|
|
.icon_color(Color::Muted)
|
|
.tooltip(move |window, cx| {
|
|
if can_commit {
|
|
Tooltip::for_action_in(
|
|
"Generate Commit Message",
|
|
&git::GenerateCommitMessage,
|
|
&editor_focus_handle,
|
|
window,
|
|
cx,
|
|
)
|
|
} else {
|
|
Tooltip::simple("No changes to commit", cx)
|
|
}
|
|
})
|
|
.disabled(!can_commit)
|
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
|
this.generate_commit_message(cx);
|
|
}))
|
|
.into_any_element()
|
|
})
|
|
}
|
|
|
|
pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
|
let potential_co_authors = self.potential_co_authors(cx);
|
|
|
|
let (tooltip_label, icon) = if self.add_coauthors {
|
|
("Remove co-authored-by", IconName::Person)
|
|
} else {
|
|
("Add co-authored-by", IconName::UserCheck)
|
|
};
|
|
|
|
if potential_co_authors.is_empty() {
|
|
None
|
|
} else {
|
|
Some(
|
|
IconButton::new("co-authors", icon)
|
|
.shape(ui::IconButtonShape::Square)
|
|
.icon_color(Color::Disabled)
|
|
.selected_icon_color(Color::Selected)
|
|
.toggle_state(self.add_coauthors)
|
|
.tooltip(move |_, cx| {
|
|
let title = format!(
|
|
"{}:{}{}",
|
|
tooltip_label,
|
|
if potential_co_authors.len() == 1 {
|
|
""
|
|
} else {
|
|
"\n"
|
|
},
|
|
potential_co_authors
|
|
.iter()
|
|
.map(|(name, email)| format!(" {} <{}>", name, email))
|
|
.join("\n")
|
|
);
|
|
Tooltip::simple(title, cx)
|
|
})
|
|
.on_click(cx.listener(|this, _, _, cx| {
|
|
this.add_coauthors = !this.add_coauthors;
|
|
cx.notify();
|
|
}))
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn render_git_commit_menu(
|
|
&self,
|
|
id: impl Into<ElementId>,
|
|
keybinding_target: Option<FocusHandle>,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
PopoverMenu::new(id.into())
|
|
.trigger(
|
|
ui::ButtonLike::new_rounded_right("commit-split-button-right")
|
|
.layer(ui::ElevationIndex::ModalSurface)
|
|
.size(ButtonSize::None)
|
|
.child(
|
|
h_flex()
|
|
.px_1()
|
|
.h_full()
|
|
.justify_center()
|
|
.border_l_1()
|
|
.border_color(cx.theme().colors().border)
|
|
.child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
|
|
),
|
|
)
|
|
.menu({
|
|
let git_panel = cx.entity();
|
|
let has_previous_commit = self.head_commit(cx).is_some();
|
|
let amend = self.amend_pending();
|
|
let signoff = self.signoff_enabled;
|
|
|
|
move |window, cx| {
|
|
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
|
|
context_menu
|
|
.when_some(keybinding_target.clone(), |el, keybinding_target| {
|
|
el.context(keybinding_target)
|
|
})
|
|
.when(has_previous_commit, |this| {
|
|
this.toggleable_entry(
|
|
"Amend",
|
|
amend,
|
|
IconPosition::Start,
|
|
Some(Box::new(Amend)),
|
|
{
|
|
let git_panel = git_panel.downgrade();
|
|
move |_, cx| {
|
|
git_panel
|
|
.update(cx, |git_panel, cx| {
|
|
git_panel.toggle_amend_pending(cx);
|
|
})
|
|
.ok();
|
|
}
|
|
},
|
|
)
|
|
})
|
|
.toggleable_entry(
|
|
"Signoff",
|
|
signoff,
|
|
IconPosition::Start,
|
|
Some(Box::new(Signoff)),
|
|
move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
|
|
)
|
|
}))
|
|
}
|
|
})
|
|
.anchor(Corner::TopRight)
|
|
}
|
|
|
|
pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
|
|
if self.has_unstaged_conflicts() {
|
|
(false, "You must resolve conflicts before committing")
|
|
} else if !self.has_staged_changes() && !self.has_tracked_changes() {
|
|
(false, "No changes to commit")
|
|
} else if self.pending_commit.is_some() {
|
|
(false, "Commit in progress")
|
|
} else if !self.has_commit_message(cx) {
|
|
(false, "No commit message")
|
|
} else if !self.has_write_access(cx) {
|
|
(false, "You do not have write access to this project")
|
|
} else {
|
|
(true, self.commit_button_title())
|
|
}
|
|
}
|
|
|
|
pub fn commit_button_title(&self) -> &'static str {
|
|
if self.amend_pending {
|
|
if self.has_staged_changes() {
|
|
"Amend"
|
|
} else {
|
|
"Amend Tracked"
|
|
}
|
|
} else if self.has_staged_changes() {
|
|
"Commit"
|
|
} else {
|
|
"Commit Tracked"
|
|
}
|
|
}
|
|
|
|
fn expand_commit_editor(
|
|
&mut self,
|
|
_: &git::ExpandCommitEditor,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let workspace = self.workspace.clone();
|
|
window.defer(cx, move |window, cx| {
|
|
workspace
|
|
.update(cx, |workspace, cx| {
|
|
CommitModal::toggle(workspace, None, window, cx)
|
|
})
|
|
.ok();
|
|
})
|
|
}
|
|
|
|
fn render_panel_header(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<impl IntoElement> {
|
|
self.active_repository.as_ref()?;
|
|
|
|
let text;
|
|
let action;
|
|
let tooltip;
|
|
if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
|
|
text = "Unstage All";
|
|
action = git::UnstageAll.boxed_clone();
|
|
tooltip = "git reset";
|
|
} else {
|
|
text = "Stage All";
|
|
action = git::StageAll.boxed_clone();
|
|
tooltip = "git add --all ."
|
|
}
|
|
|
|
let change_string = match self.entry_count {
|
|
0 => "No Changes".to_string(),
|
|
1 => "1 Change".to_string(),
|
|
_ => format!("{} Changes", self.entry_count),
|
|
};
|
|
|
|
Some(
|
|
self.panel_header_container(window, cx)
|
|
.px_2()
|
|
.justify_between()
|
|
.child(
|
|
panel_button(change_string)
|
|
.color(Color::Muted)
|
|
.tooltip(Tooltip::for_action_title_in(
|
|
"Open Diff",
|
|
&Diff,
|
|
&self.focus_handle,
|
|
))
|
|
.on_click(|_, _, cx| {
|
|
cx.defer(|cx| {
|
|
cx.dispatch_action(&Diff);
|
|
})
|
|
}),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.child(self.render_overflow_menu("overflow_menu"))
|
|
.child(
|
|
panel_filled_button(text)
|
|
.tooltip(Tooltip::for_action_title_in(
|
|
tooltip,
|
|
action.as_ref(),
|
|
&self.focus_handle,
|
|
))
|
|
.disabled(self.entry_count == 0)
|
|
.on_click(move |_, _, cx| {
|
|
let action = action.boxed_clone();
|
|
cx.defer(move |cx| {
|
|
cx.dispatch_action(action.as_ref());
|
|
})
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
|
let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
|
|
if !self.can_push_and_pull(cx) {
|
|
return None;
|
|
}
|
|
Some(
|
|
h_flex()
|
|
.gap_1()
|
|
.flex_shrink_0()
|
|
.when_some(branch, |this, branch| {
|
|
let focus_handle = Some(self.focus_handle(cx));
|
|
|
|
this.children(render_remote_button(
|
|
"remote-button",
|
|
&branch,
|
|
focus_handle,
|
|
true,
|
|
))
|
|
})
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
|
|
pub fn render_footer(
|
|
&self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<impl IntoElement> {
|
|
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 editor_focus_handle = self.commit_editor.focus_handle(cx);
|
|
let expand_tooltip_focus_handle = editor_focus_handle;
|
|
|
|
let branch = active_repository.read(cx).branch.clone();
|
|
let head_commit = active_repository.read(cx).head_commit.clone();
|
|
|
|
let footer_size = px(32.);
|
|
let gap = px(9.0);
|
|
let max_height = panel_editor_style
|
|
.text
|
|
.line_height_in_pixels(window.rem_size())
|
|
* MAX_PANEL_EDITOR_LINES
|
|
+ gap;
|
|
|
|
let git_panel = cx.entity();
|
|
let display_name = SharedString::from(Arc::from(
|
|
active_repository
|
|
.read(cx)
|
|
.display_name()
|
|
.trim_end_matches("/"),
|
|
));
|
|
let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
|
|
editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
|
|
});
|
|
|
|
let footer = v_flex()
|
|
.child(PanelRepoFooter::new(
|
|
display_name,
|
|
branch,
|
|
head_commit,
|
|
Some(git_panel),
|
|
))
|
|
.child(
|
|
panel_editor_container(window, cx)
|
|
.id("commit-editor-container")
|
|
.relative()
|
|
.w_full()
|
|
.h(max_height + footer_size)
|
|
.border_t_1()
|
|
.border_color(cx.theme().colors().border)
|
|
.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")
|
|
.border_t_1()
|
|
.when(editor_is_long, |el| {
|
|
el.border_color(cx.theme().colors().border_variant)
|
|
})
|
|
.absolute()
|
|
.bottom_0()
|
|
.left_0()
|
|
.w_full()
|
|
.px_2()
|
|
.h(footer_size)
|
|
.flex_none()
|
|
.justify_between()
|
|
.child(
|
|
self.render_generate_commit_message_button(cx)
|
|
.unwrap_or_else(|| div().into_any_element()),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.gap_0p5()
|
|
.children(enable_coauthors)
|
|
.child(self.render_commit_button(cx)),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.pr_2p5()
|
|
.on_action(|&editor::actions::MoveUp, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.on_action(|&editor::actions::MoveDown, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.child(EditorElement::new(&self.commit_editor, panel_editor_style)),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.absolute()
|
|
.top_2()
|
|
.right_2()
|
|
.opacity(0.5)
|
|
.hover(|this| this.opacity(1.0))
|
|
.child(
|
|
panel_icon_button("expand-commit-editor", IconName::Maximize)
|
|
.icon_size(IconSize::Small)
|
|
.size(ui::ButtonSize::Default)
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::for_action_in(
|
|
"Open Commit Modal",
|
|
&git::ExpandCommitEditor,
|
|
&expand_tooltip_focus_handle,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(cx.listener({
|
|
move |_, _, window, cx| {
|
|
window.dispatch_action(
|
|
git::ExpandCommitEditor.boxed_clone(),
|
|
cx,
|
|
)
|
|
}
|
|
})),
|
|
),
|
|
),
|
|
);
|
|
|
|
Some(footer)
|
|
}
|
|
|
|
fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let (can_commit, tooltip) = self.configure_commit_button(cx);
|
|
let title = self.commit_button_title();
|
|
let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
|
|
let amend = self.amend_pending();
|
|
let signoff = self.signoff_enabled;
|
|
|
|
div()
|
|
.id("commit-wrapper")
|
|
.on_hover(cx.listener(move |this, hovered, _, cx| {
|
|
this.show_placeholders =
|
|
*hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
|
|
cx.notify()
|
|
}))
|
|
.child(SplitButton::new(
|
|
ui::ButtonLike::new_rounded_left(ElementId::Name(
|
|
format!("split-button-left-{}", title).into(),
|
|
))
|
|
.layer(ui::ElevationIndex::ModalSurface)
|
|
.size(ui::ButtonSize::Compact)
|
|
.child(
|
|
div()
|
|
.child(Label::new(title).size(LabelSize::Small))
|
|
.mr_0p5(),
|
|
)
|
|
.on_click({
|
|
let git_panel = cx.weak_entity();
|
|
move |_, window, cx| {
|
|
telemetry::event!("Git Committed", source = "Git Panel");
|
|
git_panel
|
|
.update(cx, |git_panel, cx| {
|
|
git_panel.set_amend_pending(false, cx);
|
|
git_panel.commit_changes(
|
|
CommitOptions { amend, signoff },
|
|
window,
|
|
cx,
|
|
);
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.disabled(!can_commit || self.modal_open)
|
|
.tooltip({
|
|
let handle = commit_tooltip_focus_handle.clone();
|
|
move |window, cx| {
|
|
if can_commit {
|
|
Tooltip::with_meta_in(
|
|
tooltip,
|
|
Some(&git::Commit),
|
|
format!(
|
|
"git commit{}{}",
|
|
if amend { " --amend" } else { "" },
|
|
if signoff { " --signoff" } else { "" }
|
|
),
|
|
&handle.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
} else {
|
|
Tooltip::simple(tooltip, cx)
|
|
}
|
|
}
|
|
}),
|
|
self.render_git_commit_menu(
|
|
ElementId::Name(format!("split-button-right-{}", title).into()),
|
|
Some(commit_tooltip_focus_handle),
|
|
cx,
|
|
)
|
|
.into_any_element(),
|
|
))
|
|
}
|
|
|
|
fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
h_flex()
|
|
.py_1p5()
|
|
.px_2()
|
|
.gap_1p5()
|
|
.justify_between()
|
|
.border_t_1()
|
|
.border_color(cx.theme().colors().border.opacity(0.8))
|
|
.child(
|
|
div()
|
|
.flex_grow()
|
|
.overflow_hidden()
|
|
.max_w(relative(0.85))
|
|
.child(
|
|
Label::new("This will update your most recent commit.")
|
|
.size(LabelSize::Small)
|
|
.truncate(),
|
|
),
|
|
)
|
|
.child(
|
|
panel_button("Cancel")
|
|
.size(ButtonSize::Default)
|
|
.on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
|
|
)
|
|
}
|
|
|
|
fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
|
let active_repository = self.active_repository.as_ref()?;
|
|
let branch = active_repository.read(cx).branch.as_ref()?;
|
|
let commit = branch.most_recent_commit.as_ref()?.clone();
|
|
let workspace = self.workspace.clone();
|
|
let this = cx.entity();
|
|
|
|
Some(
|
|
h_flex()
|
|
.py_1p5()
|
|
.px_2()
|
|
.gap_1p5()
|
|
.justify_between()
|
|
.border_t_1()
|
|
.border_color(cx.theme().colors().border.opacity(0.8))
|
|
.child(
|
|
div()
|
|
.flex_grow()
|
|
.overflow_hidden()
|
|
.max_w(relative(0.85))
|
|
.child(
|
|
Label::new(commit.subject.clone())
|
|
.size(LabelSize::Small)
|
|
.truncate(),
|
|
)
|
|
.id("commit-msg-hover")
|
|
.on_click({
|
|
let commit = commit.clone();
|
|
let repo = active_repository.downgrade();
|
|
move |_, window, cx| {
|
|
CommitView::open(
|
|
commit.clone(),
|
|
repo.clone(),
|
|
workspace.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
})
|
|
.hoverable_tooltip({
|
|
let repo = active_repository.clone();
|
|
move |window, cx| {
|
|
GitPanelMessageTooltip::new(
|
|
this.clone(),
|
|
commit.sha.clone(),
|
|
repo.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
.into()
|
|
}
|
|
}),
|
|
)
|
|
.when(commit.has_parent, |this| {
|
|
let has_unstaged = self.has_unstaged_changes();
|
|
this.child(
|
|
panel_icon_button("undo", IconName::Undo)
|
|
.icon_size(IconSize::XSmall)
|
|
.icon_color(Color::Muted)
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::with_meta(
|
|
"Uncommit",
|
|
Some(&git::Uncommit),
|
|
if has_unstaged {
|
|
"git reset HEAD^ --soft"
|
|
} else {
|
|
"git reset HEAD^"
|
|
},
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
h_flex().h_full().flex_grow().justify_center().child(
|
|
v_flex()
|
|
.gap_2()
|
|
.child(h_flex().w_full().justify_around().child(
|
|
if self.active_repository.is_some() {
|
|
"No changes to commit"
|
|
} else {
|
|
"No Git repositories"
|
|
},
|
|
))
|
|
.children({
|
|
let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
|
|
(worktree_count > 0 && self.active_repository.is_none()).then(|| {
|
|
h_flex().w_full().justify_around().child(
|
|
panel_filled_button("Initialize Repository")
|
|
.tooltip(Tooltip::for_action_title_in(
|
|
"git init",
|
|
&git::Init,
|
|
&self.focus_handle,
|
|
))
|
|
.on_click(move |_, _, cx| {
|
|
cx.defer(move |cx| {
|
|
cx.dispatch_action(&git::Init);
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
.text_ui_sm(cx)
|
|
.mx_auto()
|
|
.text_color(Color::Placeholder.color(cx)),
|
|
)
|
|
}
|
|
|
|
fn render_vertical_scrollbar(
|
|
&self,
|
|
show_horizontal_scrollbar_container: bool,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
div()
|
|
.id("git-panel-vertical-scroll")
|
|
.occlude()
|
|
.flex_none()
|
|
.h_full()
|
|
.cursor_default()
|
|
.absolute()
|
|
.right_0()
|
|
.top_0()
|
|
.bottom_0()
|
|
.w(px(12.))
|
|
.when(show_horizontal_scrollbar_container, |this| {
|
|
this.pb_neg_3p5()
|
|
})
|
|
.on_mouse_move(cx.listener(|_, _, _, cx| {
|
|
cx.notify();
|
|
cx.stop_propagation()
|
|
}))
|
|
.on_hover(|_, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.on_any_mouse_down(|_, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.on_mouse_up(
|
|
MouseButton::Left,
|
|
cx.listener(|this, _, window, cx| {
|
|
if !this.vertical_scrollbar.state.is_dragging()
|
|
&& !this.focus_handle.contains_focused(window, cx)
|
|
{
|
|
this.vertical_scrollbar.hide(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
cx.stop_propagation();
|
|
}),
|
|
)
|
|
.on_scroll_wheel(cx.listener(|_, _, _, cx| {
|
|
cx.notify();
|
|
}))
|
|
.children(Scrollbar::vertical(
|
|
// percentage as f32..end_offset as f32,
|
|
self.vertical_scrollbar.state.clone(),
|
|
))
|
|
}
|
|
|
|
/// Renders the horizontal scrollbar.
|
|
///
|
|
/// The right offset is used to determine how far to the right the
|
|
/// scrollbar should extend to, useful for ensuring it doesn't collide
|
|
/// with the vertical scrollbar when visible.
|
|
fn render_horizontal_scrollbar(
|
|
&self,
|
|
right_offset: Pixels,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
div()
|
|
.id("git-panel-horizontal-scroll")
|
|
.occlude()
|
|
.flex_none()
|
|
.w_full()
|
|
.cursor_default()
|
|
.absolute()
|
|
.bottom_neg_px()
|
|
.left_0()
|
|
.right_0()
|
|
.pr(right_offset)
|
|
.on_mouse_move(cx.listener(|_, _, _, cx| {
|
|
cx.notify();
|
|
cx.stop_propagation()
|
|
}))
|
|
.on_hover(|_, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.on_any_mouse_down(|_, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.on_mouse_up(
|
|
MouseButton::Left,
|
|
cx.listener(|this, _, window, cx| {
|
|
if !this.horizontal_scrollbar.state.is_dragging()
|
|
&& !this.focus_handle.contains_focused(window, cx)
|
|
{
|
|
this.horizontal_scrollbar.hide(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
cx.stop_propagation();
|
|
}),
|
|
)
|
|
.on_scroll_wheel(cx.listener(|_, _, _, cx| {
|
|
cx.notify();
|
|
}))
|
|
.children(Scrollbar::horizontal(
|
|
// percentage as f32..end_offset as f32,
|
|
self.horizontal_scrollbar.state.clone(),
|
|
))
|
|
}
|
|
|
|
fn render_buffer_header_controls(
|
|
&self,
|
|
entity: &Entity<Self>,
|
|
file: &Arc<dyn File>,
|
|
_: &Window,
|
|
cx: &App,
|
|
) -> Option<AnyElement> {
|
|
let repo = self.active_repository.as_ref()?.read(cx);
|
|
let project_path = (file.worktree_id(cx), file.path()).into();
|
|
let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
|
|
let ix = self.entry_by_path(&repo_path, cx)?;
|
|
let entry = self.entries.get(ix)?;
|
|
|
|
let entry_staging = self.entry_staging(entry.status_entry()?);
|
|
|
|
let checkbox = Checkbox::new("stage-file", entry_staging.as_bool().into())
|
|
.disabled(!self.has_write_access(cx))
|
|
.fill()
|
|
.elevation(ElevationIndex::Surface)
|
|
.on_click({
|
|
let entry = entry.clone();
|
|
let git_panel = entity.downgrade();
|
|
move |_, window, cx| {
|
|
git_panel
|
|
.update(cx, |this, cx| {
|
|
this.toggle_staged_for_entry(&entry, window, cx);
|
|
cx.stop_propagation();
|
|
})
|
|
.ok();
|
|
}
|
|
});
|
|
Some(
|
|
h_flex()
|
|
.id("start-slot")
|
|
.text_lg()
|
|
.child(checkbox)
|
|
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
|
// prevent the list item active state triggering when toggling checkbox
|
|
cx.stop_propagation();
|
|
})
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
|
|
fn render_entries(
|
|
&self,
|
|
has_write_access: bool,
|
|
_: &Window,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
let entry_count = self.entries.len();
|
|
|
|
let scroll_track_size = px(16.);
|
|
|
|
let h_scroll_offset = if self.vertical_scrollbar.show_scrollbar {
|
|
// magic number
|
|
px(3.)
|
|
} else {
|
|
px(0.)
|
|
};
|
|
|
|
v_flex()
|
|
.flex_1()
|
|
.size_full()
|
|
.overflow_hidden()
|
|
.relative()
|
|
// Show a border on the top and bottom of the container when
|
|
// the vertical scrollbar container is visible so we don't have a
|
|
// floating left border in the panel.
|
|
.when(self.vertical_scrollbar.show_track, |this| {
|
|
this.border_t_1()
|
|
.border_b_1()
|
|
.border_color(cx.theme().colors().border)
|
|
})
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.size_full()
|
|
.relative()
|
|
.overflow_hidden()
|
|
.child(
|
|
uniform_list(
|
|
"entries",
|
|
entry_count,
|
|
cx.processor(move |this, range: Range<usize>, window, cx| {
|
|
let mut items = Vec::with_capacity(range.end - range.start);
|
|
|
|
for ix in range {
|
|
match &this.entries.get(ix) {
|
|
Some(GitListEntry::Status(entry)) => {
|
|
items.push(this.render_entry(
|
|
ix,
|
|
entry,
|
|
has_write_access,
|
|
window,
|
|
cx,
|
|
));
|
|
}
|
|
Some(GitListEntry::Header(header)) => {
|
|
items.push(this.render_list_header(
|
|
ix,
|
|
header,
|
|
has_write_access,
|
|
window,
|
|
cx,
|
|
));
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
items
|
|
}),
|
|
)
|
|
.when(
|
|
!self.horizontal_scrollbar.show_track
|
|
&& self.horizontal_scrollbar.show_scrollbar,
|
|
|this| {
|
|
// when not showing the horizontal scrollbar track, make sure we don't
|
|
// obscure the last entry
|
|
this.pb(scroll_track_size)
|
|
},
|
|
)
|
|
.size_full()
|
|
.flex_grow()
|
|
.with_sizing_behavior(ListSizingBehavior::Auto)
|
|
.with_horizontal_sizing_behavior(
|
|
ListHorizontalSizingBehavior::Unconstrained,
|
|
)
|
|
.with_width_from_item(self.max_width_item_index)
|
|
.track_scroll(self.scroll_handle.clone()),
|
|
)
|
|
.on_mouse_down(
|
|
MouseButton::Right,
|
|
cx.listener(move |this, event: &MouseDownEvent, window, cx| {
|
|
this.deploy_panel_context_menu(event.position, window, cx)
|
|
}),
|
|
)
|
|
.when(self.vertical_scrollbar.show_track, |this| {
|
|
this.child(
|
|
v_flex()
|
|
.h_full()
|
|
.flex_none()
|
|
.w(scroll_track_size)
|
|
.bg(cx.theme().colors().panel_background)
|
|
.child(
|
|
div()
|
|
.size_full()
|
|
.flex_1()
|
|
.border_l_1()
|
|
.border_color(cx.theme().colors().border),
|
|
),
|
|
)
|
|
})
|
|
.when(self.vertical_scrollbar.show_scrollbar, |this| {
|
|
this.child(
|
|
self.render_vertical_scrollbar(
|
|
self.horizontal_scrollbar.show_track,
|
|
cx,
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
.when(self.horizontal_scrollbar.show_track, |this| {
|
|
this.child(
|
|
h_flex()
|
|
.w_full()
|
|
.h(scroll_track_size)
|
|
.flex_none()
|
|
.relative()
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.flex_1()
|
|
// for some reason the horizontal scrollbar is 1px
|
|
// taller than the vertical scrollbar??
|
|
.h(scroll_track_size - px(1.))
|
|
.bg(cx.theme().colors().panel_background)
|
|
.border_t_1()
|
|
.border_color(cx.theme().colors().border),
|
|
)
|
|
.when(self.vertical_scrollbar.show_track, |this| {
|
|
this.child(
|
|
div()
|
|
.flex_none()
|
|
// -1px prevents a missing pixel between the two container borders
|
|
.w(scroll_track_size - px(1.))
|
|
.h_full(),
|
|
)
|
|
.child(
|
|
// HACK: Fill the missing 1px 🥲
|
|
div()
|
|
.absolute()
|
|
.right(scroll_track_size - px(1.))
|
|
.bottom(scroll_track_size - px(1.))
|
|
.size_px()
|
|
.bg(cx.theme().colors().border),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
.when(self.horizontal_scrollbar.show_scrollbar, |this| {
|
|
this.child(self.render_horizontal_scrollbar(h_scroll_offset, cx))
|
|
})
|
|
}
|
|
|
|
fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
|
|
Label::new(label.into()).color(color).single_line()
|
|
}
|
|
|
|
fn list_item_height(&self) -> Rems {
|
|
rems(1.75)
|
|
}
|
|
|
|
fn render_list_header(
|
|
&self,
|
|
ix: usize,
|
|
header: &GitHeaderEntry,
|
|
_: bool,
|
|
_: &Window,
|
|
_: &Context<Self>,
|
|
) -> AnyElement {
|
|
let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
|
|
|
|
h_flex()
|
|
.id(id)
|
|
.h(self.list_item_height())
|
|
.w_full()
|
|
.items_end()
|
|
.px(rems(0.75)) // ~12px
|
|
.pb(rems(0.3125)) // ~ 5px
|
|
.child(
|
|
Label::new(header.title())
|
|
.color(Color::Muted)
|
|
.size(LabelSize::Small)
|
|
.line_height_style(LineHeightStyle::UiLabel)
|
|
.single_line(),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
pub fn load_commit_details(
|
|
&self,
|
|
sha: String,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<anyhow::Result<CommitDetails>> {
|
|
let Some(repo) = self.active_repository.clone() else {
|
|
return Task::ready(Err(anyhow::anyhow!("no active repo")));
|
|
};
|
|
repo.update(cx, |repo, cx| {
|
|
let show = repo.show(sha);
|
|
cx.spawn(async move |_, _| show.await?)
|
|
})
|
|
}
|
|
|
|
fn deploy_entry_context_menu(
|
|
&mut self,
|
|
position: Point<Pixels>,
|
|
ix: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
|
|
return;
|
|
};
|
|
let stage_title = if entry.status.staging().is_fully_staged() {
|
|
"Unstage File"
|
|
} else {
|
|
"Stage File"
|
|
};
|
|
let restore_title = if entry.status.is_created() {
|
|
"Trash File"
|
|
} else {
|
|
"Restore File"
|
|
};
|
|
let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
|
|
context_menu
|
|
.context(self.focus_handle.clone())
|
|
.action(stage_title, ToggleStaged.boxed_clone())
|
|
.action(restore_title, git::RestoreFile::default().boxed_clone())
|
|
.separator()
|
|
.action("Open Diff", Confirm.boxed_clone())
|
|
.action("Open File", SecondaryConfirm.boxed_clone())
|
|
});
|
|
self.selected_entry = Some(ix);
|
|
self.set_context_menu(context_menu, position, window, cx);
|
|
}
|
|
|
|
fn deploy_panel_context_menu(
|
|
&mut self,
|
|
position: Point<Pixels>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let context_menu = git_panel_context_menu(
|
|
self.focus_handle.clone(),
|
|
GitMenuState {
|
|
has_tracked_changes: self.has_tracked_changes(),
|
|
has_staged_changes: self.has_staged_changes(),
|
|
has_unstaged_changes: self.has_unstaged_changes(),
|
|
has_new_changes: self.new_count > 0,
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
self.set_context_menu(context_menu, position, window, cx);
|
|
}
|
|
|
|
fn set_context_menu(
|
|
&mut self,
|
|
context_menu: Entity<ContextMenu>,
|
|
position: Point<Pixels>,
|
|
window: &Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let subscription = cx.subscribe_in(
|
|
&context_menu,
|
|
window,
|
|
|this, _, _: &DismissEvent, window, cx| {
|
|
if this.context_menu.as_ref().is_some_and(|context_menu| {
|
|
context_menu.0.focus_handle(cx).contains_focused(window, cx)
|
|
}) {
|
|
cx.focus_self(window);
|
|
}
|
|
this.context_menu.take();
|
|
cx.notify();
|
|
},
|
|
);
|
|
self.context_menu = Some((context_menu, position, subscription));
|
|
cx.notify();
|
|
}
|
|
|
|
fn render_entry(
|
|
&self,
|
|
ix: usize,
|
|
entry: &GitStatusEntry,
|
|
has_write_access: bool,
|
|
window: &Window,
|
|
cx: &Context<Self>,
|
|
) -> AnyElement {
|
|
let display_name = entry.display_name();
|
|
|
|
let selected = self.selected_entry == Some(ix);
|
|
let marked = self.marked_entries.contains(&ix);
|
|
let status_style = GitPanelSettings::get_global(cx).status_style;
|
|
let status = entry.status;
|
|
|
|
let has_conflict = status.is_conflicted();
|
|
let is_modified = status.is_modified();
|
|
let is_deleted = status.is_deleted();
|
|
|
|
let label_color = if status_style == StatusStyle::LabelColor {
|
|
if has_conflict {
|
|
Color::VersionControlConflict
|
|
} else if is_modified {
|
|
Color::VersionControlModified
|
|
} else if is_deleted {
|
|
// We don't want a bunch of red labels in the list
|
|
Color::Disabled
|
|
} else {
|
|
Color::VersionControlAdded
|
|
}
|
|
} else {
|
|
Color::Default
|
|
};
|
|
|
|
let path_color = if status.is_deleted() {
|
|
Color::Disabled
|
|
} else {
|
|
Color::Muted
|
|
};
|
|
|
|
let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
|
|
let checkbox_wrapper_id: ElementId =
|
|
ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
|
|
let checkbox_id: ElementId =
|
|
ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
|
|
|
|
let entry_staging = self.entry_staging(entry);
|
|
let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into();
|
|
if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
|
|
is_staged = ToggleState::Selected;
|
|
}
|
|
|
|
let handle = cx.weak_entity();
|
|
|
|
let selected_bg_alpha = 0.08;
|
|
let marked_bg_alpha = 0.12;
|
|
let state_opacity_step = 0.04;
|
|
|
|
let base_bg = match (selected, marked) {
|
|
(true, true) => cx
|
|
.theme()
|
|
.status()
|
|
.info
|
|
.alpha(selected_bg_alpha + marked_bg_alpha),
|
|
(true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
|
|
(false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
|
|
_ => cx.theme().colors().ghost_element_background,
|
|
};
|
|
|
|
let hover_bg = if selected {
|
|
cx.theme()
|
|
.status()
|
|
.info
|
|
.alpha(selected_bg_alpha + state_opacity_step)
|
|
} else {
|
|
cx.theme().colors().ghost_element_hover
|
|
};
|
|
|
|
let active_bg = if selected {
|
|
cx.theme()
|
|
.status()
|
|
.info
|
|
.alpha(selected_bg_alpha + state_opacity_step * 2.0)
|
|
} else {
|
|
cx.theme().colors().ghost_element_active
|
|
};
|
|
|
|
h_flex()
|
|
.id(id)
|
|
.h(self.list_item_height())
|
|
.w_full()
|
|
.items_center()
|
|
.border_1()
|
|
.when(selected && self.focus_handle.is_focused(window), |el| {
|
|
el.border_color(cx.theme().colors().border_focused)
|
|
})
|
|
.px(rems(0.75)) // ~12px
|
|
.overflow_hidden()
|
|
.flex_none()
|
|
.gap_1p5()
|
|
.bg(base_bg)
|
|
.hover(|this| this.bg(hover_bg))
|
|
.active(|this| this.bg(active_bg))
|
|
.on_click({
|
|
cx.listener(move |this, event: &ClickEvent, window, cx| {
|
|
this.selected_entry = Some(ix);
|
|
cx.notify();
|
|
if event.modifiers().secondary() {
|
|
this.open_file(&Default::default(), window, cx)
|
|
} else {
|
|
this.open_diff(&Default::default(), window, cx);
|
|
this.focus_handle.focus(window);
|
|
}
|
|
})
|
|
})
|
|
.on_mouse_down(
|
|
MouseButton::Right,
|
|
move |event: &MouseDownEvent, window, cx| {
|
|
// why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
|
|
if event.button != MouseButton::Right {
|
|
return;
|
|
}
|
|
|
|
let Some(this) = handle.upgrade() else {
|
|
return;
|
|
};
|
|
this.update(cx, |this, cx| {
|
|
this.deploy_entry_context_menu(event.position, ix, window, cx);
|
|
});
|
|
cx.stop_propagation();
|
|
},
|
|
)
|
|
.child(
|
|
div()
|
|
.id(checkbox_wrapper_id)
|
|
.flex_none()
|
|
.occlude()
|
|
.cursor_pointer()
|
|
.child(
|
|
Checkbox::new(checkbox_id, is_staged)
|
|
.disabled(!has_write_access)
|
|
.fill()
|
|
.elevation(ElevationIndex::Surface)
|
|
.on_click_ext({
|
|
let entry = entry.clone();
|
|
let this = cx.weak_entity();
|
|
move |_, click, window, cx| {
|
|
this.update(cx, |this, cx| {
|
|
if !has_write_access {
|
|
return;
|
|
}
|
|
if click.modifiers().shift {
|
|
this.stage_bulk(ix, cx);
|
|
} else {
|
|
this.toggle_staged_for_entry(
|
|
&GitListEntry::Status(entry.clone()),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
cx.stop_propagation();
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.tooltip(move |window, cx| {
|
|
let is_staged = entry_staging.is_fully_staged();
|
|
|
|
let action = if is_staged { "Unstage" } else { "Stage" };
|
|
let tooltip_name = action.to_string();
|
|
|
|
Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
|
|
}),
|
|
),
|
|
)
|
|
.child(git_status_icon(status))
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.flex_1()
|
|
// .overflow_hidden()
|
|
.when_some(entry.parent_dir(), |this, parent| {
|
|
if !parent.is_empty() {
|
|
this.child(
|
|
self.entry_label(format!("{}/", parent), path_color)
|
|
.when(status.is_deleted(), |this| this.strikethrough()),
|
|
)
|
|
} else {
|
|
this
|
|
}
|
|
})
|
|
.child(
|
|
self.entry_label(display_name, label_color)
|
|
.when(status.is_deleted(), |this| this.strikethrough()),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn has_write_access(&self, cx: &App) -> bool {
|
|
!self.project.read(cx).is_read_only(cx)
|
|
}
|
|
|
|
pub fn amend_pending(&self) -> bool {
|
|
self.amend_pending
|
|
}
|
|
|
|
pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
|
|
self.amend_pending = value;
|
|
self.serialize(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn signoff_enabled(&self) -> bool {
|
|
self.signoff_enabled
|
|
}
|
|
|
|
pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
|
|
self.signoff_enabled = value;
|
|
self.serialize(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn toggle_signoff_enabled(
|
|
&mut self,
|
|
_: &Signoff,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.set_signoff_enabled(!self.signoff_enabled, cx);
|
|
}
|
|
|
|
pub async fn load(
|
|
workspace: WeakEntity<Workspace>,
|
|
mut cx: AsyncWindowContext,
|
|
) -> anyhow::Result<Entity<Self>> {
|
|
let serialized_panel = match workspace
|
|
.read_with(&cx, |workspace, _| Self::serialization_key(workspace))
|
|
.ok()
|
|
.flatten()
|
|
{
|
|
Some(serialization_key) => cx
|
|
.background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
|
|
.await
|
|
.context("loading git panel")
|
|
.log_err()
|
|
.flatten()
|
|
.map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
|
|
.transpose()
|
|
.log_err()
|
|
.flatten(),
|
|
None => None,
|
|
};
|
|
|
|
workspace.update_in(&mut cx, |workspace, window, cx| {
|
|
let panel = GitPanel::new(workspace, window, cx);
|
|
|
|
if let Some(serialized_panel) = serialized_panel {
|
|
panel.update(cx, |panel, cx| {
|
|
panel.width = serialized_panel.width;
|
|
panel.amend_pending = serialized_panel.amend_pending;
|
|
panel.signoff_enabled = serialized_panel.signoff_enabled;
|
|
cx.notify();
|
|
})
|
|
}
|
|
|
|
panel
|
|
})
|
|
}
|
|
|
|
fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
|
|
let Some(op) = self.bulk_staging.as_ref() else {
|
|
return;
|
|
};
|
|
let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
|
|
return;
|
|
};
|
|
if let Some(entry) = self.entries.get(index)
|
|
&& let Some(entry) = entry.status_entry()
|
|
{
|
|
self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
|
|
}
|
|
if index < anchor_index {
|
|
std::mem::swap(&mut index, &mut anchor_index);
|
|
}
|
|
let entries = self
|
|
.entries
|
|
.get(anchor_index..=index)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|entry| entry.status_entry().cloned())
|
|
.collect::<Vec<_>>();
|
|
self.change_file_stage(true, entries, cx);
|
|
}
|
|
|
|
fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
|
|
let Some(repo) = self.active_repository.as_ref() else {
|
|
return;
|
|
};
|
|
self.bulk_staging = Some(BulkStaging {
|
|
repo_id: repo.read(cx).id,
|
|
anchor: path,
|
|
});
|
|
}
|
|
|
|
pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
|
|
self.set_amend_pending(!self.amend_pending, cx);
|
|
if self.amend_pending {
|
|
self.load_last_commit_message_if_empty(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
|
|
let is_enabled = agent_settings::AgentSettings::get_global(cx).enabled
|
|
&& !DisableAiSettings::get_global(cx).disable_ai;
|
|
|
|
is_enabled
|
|
.then(|| {
|
|
let ConfiguredModel { provider, model } =
|
|
LanguageModelRegistry::read_global(cx).commit_message_model(cx)?;
|
|
|
|
provider.is_authenticated(cx).then(|| model)
|
|
})
|
|
.flatten()
|
|
}
|
|
|
|
impl Render for GitPanel {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let project = self.project.read(cx);
|
|
let has_entries = !self.entries.is_empty();
|
|
let room = self
|
|
.workspace
|
|
.upgrade()
|
|
.and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
|
|
|
|
let has_write_access = self.has_write_access(cx);
|
|
|
|
let has_co_authors = room.is_some_and(|room| {
|
|
self.load_local_committer(cx);
|
|
let room = room.read(cx);
|
|
room.remote_participants()
|
|
.values()
|
|
.any(|remote_participant| remote_participant.can_write())
|
|
});
|
|
|
|
v_flex()
|
|
.id("git_panel")
|
|
.key_context(self.dispatch_context(window, cx))
|
|
.track_focus(&self.focus_handle)
|
|
.when(has_write_access && !project.is_read_only(cx), |this| {
|
|
this.on_action(cx.listener(Self::toggle_staged_for_selected))
|
|
.on_action(cx.listener(Self::stage_range))
|
|
.on_action(cx.listener(GitPanel::commit))
|
|
.on_action(cx.listener(GitPanel::amend))
|
|
.on_action(cx.listener(GitPanel::toggle_signoff_enabled))
|
|
.on_action(cx.listener(Self::stage_all))
|
|
.on_action(cx.listener(Self::unstage_all))
|
|
.on_action(cx.listener(Self::stage_selected))
|
|
.on_action(cx.listener(Self::unstage_selected))
|
|
.on_action(cx.listener(Self::restore_tracked_files))
|
|
.on_action(cx.listener(Self::revert_selected))
|
|
.on_action(cx.listener(Self::clean_all))
|
|
.on_action(cx.listener(Self::generate_commit_message_action))
|
|
.on_action(cx.listener(Self::stash_all))
|
|
.on_action(cx.listener(Self::stash_pop))
|
|
})
|
|
.on_action(cx.listener(Self::select_first))
|
|
.on_action(cx.listener(Self::select_next))
|
|
.on_action(cx.listener(Self::select_previous))
|
|
.on_action(cx.listener(Self::select_last))
|
|
.on_action(cx.listener(Self::close_panel))
|
|
.on_action(cx.listener(Self::open_diff))
|
|
.on_action(cx.listener(Self::open_file))
|
|
.on_action(cx.listener(Self::focus_changes_list))
|
|
.on_action(cx.listener(Self::focus_editor))
|
|
.on_action(cx.listener(Self::expand_commit_editor))
|
|
.when(has_write_access && has_co_authors, |git_panel| {
|
|
git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
|
|
})
|
|
.on_hover(cx.listener(move |this, hovered, window, cx| {
|
|
if *hovered {
|
|
this.horizontal_scrollbar.show(cx);
|
|
this.vertical_scrollbar.show(cx);
|
|
cx.notify();
|
|
} else if !this.focus_handle.contains_focused(window, cx) {
|
|
this.hide_scrollbars(window, cx);
|
|
}
|
|
}))
|
|
.size_full()
|
|
.overflow_hidden()
|
|
.bg(cx.theme().colors().panel_background)
|
|
.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))
|
|
} else {
|
|
this.child(self.render_empty_state(cx).into_any_element())
|
|
}
|
|
})
|
|
.children(self.render_footer(window, cx))
|
|
.when(self.amend_pending, |this| {
|
|
this.child(self.render_pending_amend(cx))
|
|
})
|
|
.when(!self.amend_pending, |this| {
|
|
this.children(self.render_previous_commit(cx))
|
|
})
|
|
.into_any_element(),
|
|
)
|
|
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
|
|
deferred(
|
|
anchored()
|
|
.position(*position)
|
|
.anchor(Corner::TopLeft)
|
|
.child(menu.clone()),
|
|
)
|
|
.with_priority(1)
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl Focusable for GitPanel {
|
|
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
|
if self.entries.is_empty() {
|
|
self.commit_editor.focus_handle(cx)
|
|
} else {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<Event> for GitPanel {}
|
|
|
|
impl EventEmitter<PanelEvent> for GitPanel {}
|
|
|
|
pub(crate) struct GitPanelAddon {
|
|
pub(crate) workspace: WeakEntity<Workspace>,
|
|
}
|
|
|
|
impl editor::Addon for GitPanelAddon {
|
|
fn to_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
|
|
fn render_buffer_header_controls(
|
|
&self,
|
|
excerpt_info: &ExcerptInfo,
|
|
window: &Window,
|
|
cx: &App,
|
|
) -> Option<AnyElement> {
|
|
let file = excerpt_info.buffer.file()?;
|
|
let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
|
|
|
|
git_panel
|
|
.read(cx)
|
|
.render_buffer_header_controls(&git_panel, file, window, cx)
|
|
}
|
|
}
|
|
|
|
impl Panel for GitPanel {
|
|
fn persistent_name() -> &'static str {
|
|
"GitPanel"
|
|
}
|
|
|
|
fn position(&self, _: &Window, cx: &App) -> DockPosition {
|
|
GitPanelSettings::get_global(cx).dock
|
|
}
|
|
|
|
fn position_is_valid(&self, position: DockPosition) -> bool {
|
|
matches!(position, DockPosition::Left | DockPosition::Right)
|
|
}
|
|
|
|
fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
|
|
settings::update_settings_file::<GitPanelSettings>(
|
|
self.fs.clone(),
|
|
cx,
|
|
move |settings, _| settings.dock = Some(position),
|
|
);
|
|
}
|
|
|
|
fn size(&self, _: &Window, cx: &App) -> Pixels {
|
|
self.width
|
|
.unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
|
|
}
|
|
|
|
fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.width = size;
|
|
self.serialize(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
|
|
Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
|
|
}
|
|
|
|
fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
|
|
Some("Git Panel")
|
|
}
|
|
|
|
fn toggle_action(&self) -> Box<dyn Action> {
|
|
Box::new(ToggleFocus)
|
|
}
|
|
|
|
fn activation_priority(&self) -> u32 {
|
|
2
|
|
}
|
|
}
|
|
|
|
impl PanelHeader for GitPanel {}
|
|
|
|
struct GitPanelMessageTooltip {
|
|
commit_tooltip: Option<Entity<CommitTooltip>>,
|
|
}
|
|
|
|
impl GitPanelMessageTooltip {
|
|
fn new(
|
|
git_panel: Entity<GitPanel>,
|
|
sha: SharedString,
|
|
repository: Entity<Repository>,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Entity<Self> {
|
|
cx.new(|cx| {
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
|
|
(
|
|
git_panel.load_commit_details(sha.to_string(), cx),
|
|
git_panel.workspace.clone(),
|
|
)
|
|
})?;
|
|
let details = details.await?;
|
|
|
|
let commit_details = crate::commit_tooltip::CommitDetails {
|
|
sha: details.sha.clone(),
|
|
author_name: details.author_name.clone(),
|
|
author_email: details.author_email.clone(),
|
|
commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
|
|
message: Some(ParsedCommitMessage {
|
|
message: details.message,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
|
|
this.commit_tooltip = Some(cx.new(move |cx| {
|
|
CommitTooltip::new(commit_details, repository, workspace, cx)
|
|
}));
|
|
cx.notify();
|
|
})
|
|
})
|
|
.detach();
|
|
|
|
Self {
|
|
commit_tooltip: None,
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Render for GitPanelMessageTooltip {
|
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
if let Some(commit_tooltip) = &self.commit_tooltip {
|
|
commit_tooltip.clone().into_any_element()
|
|
} else {
|
|
gpui::Empty.into_any_element()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(IntoElement, RegisterComponent)]
|
|
pub struct PanelRepoFooter {
|
|
active_repository: SharedString,
|
|
branch: Option<Branch>,
|
|
head_commit: Option<CommitDetails>,
|
|
|
|
// 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 PanelRepoFooter {
|
|
pub fn new(
|
|
active_repository: SharedString,
|
|
branch: Option<Branch>,
|
|
head_commit: Option<CommitDetails>,
|
|
git_panel: Option<Entity<GitPanel>>,
|
|
) -> Self {
|
|
Self {
|
|
active_repository,
|
|
branch,
|
|
head_commit,
|
|
git_panel,
|
|
}
|
|
}
|
|
|
|
pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
|
|
Self {
|
|
active_repository,
|
|
branch,
|
|
head_commit: None,
|
|
git_panel: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for PanelRepoFooter {
|
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let project = self
|
|
.git_panel
|
|
.as_ref()
|
|
.map(|panel| panel.read(cx).project.clone());
|
|
|
|
let repo = self
|
|
.git_panel
|
|
.as_ref()
|
|
.and_then(|panel| panel.read(cx).active_repository.clone());
|
|
|
|
let single_repo = project
|
|
.as_ref()
|
|
.map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
|
|
.unwrap_or(true);
|
|
|
|
const MAX_BRANCH_LEN: usize = 16;
|
|
const MAX_REPO_LEN: usize = 16;
|
|
const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
|
|
const MAX_SHORT_SHA_LEN: usize = 8;
|
|
|
|
let branch_name = self
|
|
.branch
|
|
.as_ref()
|
|
.map(|branch| branch.name().to_owned())
|
|
.or_else(|| {
|
|
self.head_commit.as_ref().map(|commit| {
|
|
commit
|
|
.sha
|
|
.chars()
|
|
.take(MAX_SHORT_SHA_LEN)
|
|
.collect::<String>()
|
|
})
|
|
})
|
|
.unwrap_or_else(|| " (no branch)".to_owned());
|
|
let show_separator = self.branch.is_some() || self.head_commit.is_some();
|
|
|
|
let active_repo_name = self.active_repository.clone();
|
|
|
|
let branch_actual_len = branch_name.len();
|
|
let repo_actual_len = active_repo_name.len();
|
|
|
|
// ideally, show the whole branch and repo names but
|
|
// when we can't, use a budget to allocate space between the two
|
|
let (repo_display_len, branch_display_len) =
|
|
if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
|
|
(repo_actual_len, branch_actual_len)
|
|
} else if branch_actual_len <= MAX_BRANCH_LEN {
|
|
let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
|
|
(repo_space, branch_actual_len)
|
|
} else if repo_actual_len <= MAX_REPO_LEN {
|
|
let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
|
|
(repo_actual_len, branch_space)
|
|
} else {
|
|
(MAX_REPO_LEN, MAX_BRANCH_LEN)
|
|
};
|
|
|
|
let truncated_repo_name = if repo_actual_len <= repo_display_len {
|
|
active_repo_name.to_string()
|
|
} else {
|
|
util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
|
|
};
|
|
|
|
let truncated_branch_name = if branch_actual_len <= branch_display_len {
|
|
branch_name
|
|
} else {
|
|
util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
|
|
};
|
|
|
|
let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
|
|
.style(ButtonStyle::Transparent)
|
|
.size(ButtonSize::None)
|
|
.label_size(LabelSize::Small)
|
|
.color(Color::Muted);
|
|
|
|
let repo_selector = PopoverMenu::new("repository-switcher")
|
|
.menu({
|
|
let project = project;
|
|
move |window, cx| {
|
|
let project = project.clone()?;
|
|
Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx)))
|
|
}
|
|
})
|
|
.trigger_with_tooltip(
|
|
repo_selector_trigger.disabled(single_repo).truncate(true),
|
|
Tooltip::text("Switch Active Repository"),
|
|
)
|
|
.anchor(Corner::BottomLeft)
|
|
.into_any_element();
|
|
|
|
let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
|
|
.style(ButtonStyle::Transparent)
|
|
.size(ButtonSize::None)
|
|
.label_size(LabelSize::Small)
|
|
.truncate(true)
|
|
.tooltip(Tooltip::for_action_title(
|
|
"Switch Branch",
|
|
&zed_actions::git::Switch,
|
|
))
|
|
.on_click(|_, window, cx| {
|
|
window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
|
|
});
|
|
|
|
let branch_selector = PopoverMenu::new("popover-button")
|
|
.menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
|
|
.trigger_with_tooltip(
|
|
branch_selector_button,
|
|
Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
|
|
)
|
|
.anchor(Corner::BottomLeft)
|
|
.offset(gpui::Point {
|
|
x: px(0.0),
|
|
y: px(-2.0),
|
|
});
|
|
|
|
h_flex()
|
|
.w_full()
|
|
.px_2()
|
|
.h(px(36.))
|
|
.items_center()
|
|
.justify_between()
|
|
.gap_1()
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.overflow_hidden()
|
|
.items_center()
|
|
.child(
|
|
div().child(
|
|
Icon::new(IconName::GitBranchAlt)
|
|
.size(IconSize::Small)
|
|
.color(if single_repo {
|
|
Color::Disabled
|
|
} else {
|
|
Color::Muted
|
|
}),
|
|
),
|
|
)
|
|
.child(repo_selector)
|
|
.when(show_separator, |this| {
|
|
this.child(
|
|
div()
|
|
.text_color(cx.theme().colors().text_muted)
|
|
.text_sm()
|
|
.child("/"),
|
|
)
|
|
})
|
|
.child(branch_selector),
|
|
)
|
|
.children(if let Some(git_panel) = self.git_panel {
|
|
git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
|
|
} else {
|
|
None
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Component for PanelRepoFooter {
|
|
fn scope() -> ComponentScope {
|
|
ComponentScope::VersionControl
|
|
}
|
|
|
|
fn preview(_window: &mut Window, _cx: &mut App) -> Option<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,
|
|
ref_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,
|
|
has_parent: true,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
|
|
Branch {
|
|
is_head: true,
|
|
ref_name: branch_name.to_string().into(),
|
|
upstream: upstream.map(|tracking| Upstream {
|
|
ref_name: format!("zed/{}", branch_name).into(),
|
|
tracking,
|
|
}),
|
|
most_recent_commit: Some(CommitSummary {
|
|
sha: "abc123".into(),
|
|
subject: "Modify stuff".into(),
|
|
commit_timestamp: 1710932954,
|
|
has_parent: true,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn active_repository(id: usize) -> SharedString {
|
|
format!("repo-{}", id).into()
|
|
}
|
|
|
|
let example_width = px(340.);
|
|
Some(
|
|
v_flex()
|
|
.gap_6()
|
|
.w_full()
|
|
.flex_none()
|
|
.children(vec![
|
|
example_group_with_title(
|
|
"Action Button States",
|
|
vec![
|
|
single_example(
|
|
"No Branch",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(active_repository(1), None))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Remote status unknown",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
active_repository(2),
|
|
Some(branch(unknown_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"No Remote Upstream",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
active_repository(3),
|
|
Some(branch(no_remote_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Not Ahead or Behind",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
active_repository(4),
|
|
Some(branch(not_ahead_or_behind_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Behind remote",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
active_repository(5),
|
|
Some(branch(behind_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Ahead of remote",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
active_repository(6),
|
|
Some(branch(ahead_of_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Ahead and behind remote",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
active_repository(7),
|
|
Some(branch(ahead_and_behind_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
],
|
|
)
|
|
.grow()
|
|
.vertical(),
|
|
])
|
|
.children(vec![
|
|
example_group_with_title(
|
|
"Labels",
|
|
vec![
|
|
single_example(
|
|
"Short Branch & Repo",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
SharedString::from("zed"),
|
|
Some(custom("main", behind_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Long Branch",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
SharedString::from("zed"),
|
|
Some(custom(
|
|
"redesign-and-update-git-ui-list-entry-style",
|
|
behind_upstream,
|
|
)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Long Repo",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
SharedString::from("zed-industries-community-examples"),
|
|
Some(custom("gpui", ahead_of_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Long Repo & Branch",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
SharedString::from("zed-industries-community-examples"),
|
|
Some(custom(
|
|
"redesign-and-update-git-ui-list-entry-style",
|
|
behind_upstream,
|
|
)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Uppercase Repo",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
SharedString::from("LICENSES"),
|
|
Some(custom("main", ahead_of_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Uppercase Branch",
|
|
div()
|
|
.w(example_width)
|
|
.overflow_hidden()
|
|
.child(PanelRepoFooter::new_preview(
|
|
SharedString::from("zed"),
|
|
Some(custom("update-README", behind_upstream)),
|
|
))
|
|
.into_any_element(),
|
|
),
|
|
],
|
|
)
|
|
.grow()
|
|
.vertical(),
|
|
])
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use git::status::{StatusCode, UnmergedStatus, UnmergedStatusCode};
|
|
use gpui::{TestAppContext, VisualTestContext};
|
|
use project::{FakeFs, WorktreeSettings};
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use theme::LoadThemes;
|
|
use util::path;
|
|
|
|
use super::*;
|
|
|
|
fn init_test(cx: &mut gpui::TestAppContext) {
|
|
zlog::init_test();
|
|
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
AgentSettings::register(cx);
|
|
WorktreeSettings::register(cx);
|
|
workspace::init_settings(cx);
|
|
theme::init(LoadThemes::JustBase, cx);
|
|
language::init(cx);
|
|
editor::init(cx);
|
|
Project::init_settings(cx);
|
|
crate::init(cx);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"zed": {
|
|
".git": {},
|
|
"crates": {
|
|
"gpui": {
|
|
"gpui.rs": "fn main() {}"
|
|
},
|
|
"util": {
|
|
"util.rs": "fn do_it() {}"
|
|
}
|
|
}
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/zed/.git")),
|
|
&[
|
|
(
|
|
Path::new("crates/gpui/gpui.rs"),
|
|
StatusCode::Modified.worktree(),
|
|
),
|
|
(
|
|
Path::new("crates/util/util.rs"),
|
|
StatusCode::Modified.worktree(),
|
|
),
|
|
],
|
|
);
|
|
|
|
let project =
|
|
Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
|
|
let workspace =
|
|
cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
|
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
|
|
|
cx.read(|cx| {
|
|
project
|
|
.read(cx)
|
|
.worktrees(cx)
|
|
.next()
|
|
.unwrap()
|
|
.read(cx)
|
|
.as_local()
|
|
.unwrap()
|
|
.scan_complete()
|
|
})
|
|
.await;
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let panel = workspace.update(cx, GitPanel::new).unwrap();
|
|
|
|
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
|
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
|
});
|
|
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
|
handle.await;
|
|
|
|
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
|
pretty_assertions::assert_eq!(
|
|
entries,
|
|
[
|
|
GitListEntry::Header(GitHeaderEntry {
|
|
header: Section::Tracked
|
|
}),
|
|
GitListEntry::Status(GitStatusEntry {
|
|
abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
|
|
repo_path: "crates/gpui/gpui.rs".into(),
|
|
status: StatusCode::Modified.worktree(),
|
|
staging: StageStatus::Unstaged,
|
|
}),
|
|
GitListEntry::Status(GitStatusEntry {
|
|
abs_path: path!("/root/zed/crates/util/util.rs").into(),
|
|
repo_path: "crates/util/util.rs".into(),
|
|
status: StatusCode::Modified.worktree(),
|
|
staging: StageStatus::Unstaged,
|
|
},),
|
|
],
|
|
);
|
|
|
|
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
|
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
|
});
|
|
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
|
handle.await;
|
|
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
|
pretty_assertions::assert_eq!(
|
|
entries,
|
|
[
|
|
GitListEntry::Header(GitHeaderEntry {
|
|
header: Section::Tracked
|
|
}),
|
|
GitListEntry::Status(GitStatusEntry {
|
|
abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
|
|
repo_path: "crates/gpui/gpui.rs".into(),
|
|
status: StatusCode::Modified.worktree(),
|
|
staging: StageStatus::Unstaged,
|
|
}),
|
|
GitListEntry::Status(GitStatusEntry {
|
|
abs_path: path!("/root/zed/crates/util/util.rs").into(),
|
|
repo_path: "crates/util/util.rs".into(),
|
|
status: StatusCode::Modified.worktree(),
|
|
staging: StageStatus::Unstaged,
|
|
},),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_bulk_staging(cx: &mut TestAppContext) {
|
|
use GitListEntry::*;
|
|
|
|
init_test(cx);
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"project": {
|
|
".git": {},
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
"lib.rs": "pub fn hello() {}",
|
|
"utils.rs": "pub fn util() {}"
|
|
},
|
|
"tests": {
|
|
"test.rs": "fn test() {}"
|
|
},
|
|
"new_file.txt": "new content",
|
|
"another_new.rs": "// new file",
|
|
"conflict.txt": "conflicted content"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.set_status_for_repo(
|
|
Path::new(path!("/root/project/.git")),
|
|
&[
|
|
(Path::new("src/main.rs"), StatusCode::Modified.worktree()),
|
|
(Path::new("src/lib.rs"), StatusCode::Modified.worktree()),
|
|
(Path::new("tests/test.rs"), StatusCode::Modified.worktree()),
|
|
(Path::new("new_file.txt"), FileStatus::Untracked),
|
|
(Path::new("another_new.rs"), FileStatus::Untracked),
|
|
(Path::new("src/utils.rs"), FileStatus::Untracked),
|
|
(
|
|
Path::new("conflict.txt"),
|
|
UnmergedStatus {
|
|
first_head: UnmergedStatusCode::Updated,
|
|
second_head: UnmergedStatusCode::Updated,
|
|
}
|
|
.into(),
|
|
),
|
|
],
|
|
);
|
|
|
|
let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
|
|
let workspace =
|
|
cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
|
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
|
|
|
cx.read(|cx| {
|
|
project
|
|
.read(cx)
|
|
.worktrees(cx)
|
|
.next()
|
|
.unwrap()
|
|
.read(cx)
|
|
.as_local()
|
|
.unwrap()
|
|
.scan_complete()
|
|
})
|
|
.await;
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let panel = workspace.update(cx, GitPanel::new).unwrap();
|
|
|
|
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
|
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
|
});
|
|
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
|
handle.await;
|
|
|
|
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
|
#[rustfmt::skip]
|
|
pretty_assertions::assert_matches!(
|
|
entries.as_slice(),
|
|
&[
|
|
Header(GitHeaderEntry { header: Section::Conflict }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Header(GitHeaderEntry { header: Section::Tracked }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Header(GitHeaderEntry { header: Section::New }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
],
|
|
);
|
|
|
|
let second_status_entry = entries[3].clone();
|
|
panel.update_in(cx, |panel, window, cx| {
|
|
panel.toggle_staged_for_entry(&second_status_entry, window, cx);
|
|
});
|
|
|
|
panel.update_in(cx, |panel, window, cx| {
|
|
panel.selected_entry = Some(7);
|
|
panel.stage_range(&git::StageRange, window, cx);
|
|
});
|
|
|
|
cx.read(|cx| {
|
|
project
|
|
.read(cx)
|
|
.worktrees(cx)
|
|
.next()
|
|
.unwrap()
|
|
.read(cx)
|
|
.as_local()
|
|
.unwrap()
|
|
.scan_complete()
|
|
})
|
|
.await;
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
|
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
|
});
|
|
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
|
handle.await;
|
|
|
|
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
|
#[rustfmt::skip]
|
|
pretty_assertions::assert_matches!(
|
|
entries.as_slice(),
|
|
&[
|
|
Header(GitHeaderEntry { header: Section::Conflict }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Header(GitHeaderEntry { header: Section::Tracked }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Header(GitHeaderEntry { header: Section::New }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
],
|
|
);
|
|
|
|
let third_status_entry = entries[4].clone();
|
|
panel.update_in(cx, |panel, window, cx| {
|
|
panel.toggle_staged_for_entry(&third_status_entry, window, cx);
|
|
});
|
|
|
|
panel.update_in(cx, |panel, window, cx| {
|
|
panel.selected_entry = Some(9);
|
|
panel.stage_range(&git::StageRange, window, cx);
|
|
});
|
|
|
|
cx.read(|cx| {
|
|
project
|
|
.read(cx)
|
|
.worktrees(cx)
|
|
.next()
|
|
.unwrap()
|
|
.read(cx)
|
|
.as_local()
|
|
.unwrap()
|
|
.scan_complete()
|
|
})
|
|
.await;
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
let handle = cx.update_window_entity(&panel, |panel, _, _| {
|
|
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
|
|
});
|
|
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
|
|
handle.await;
|
|
|
|
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
|
|
#[rustfmt::skip]
|
|
pretty_assertions::assert_matches!(
|
|
entries.as_slice(),
|
|
&[
|
|
Header(GitHeaderEntry { header: Section::Conflict }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Header(GitHeaderEntry { header: Section::Tracked }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Header(GitHeaderEntry { header: Section::New }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
|
|
],
|
|
);
|
|
}
|
|
}
|