Commit All Mode (#24293)

- **Base diffs on uncommitted changes**
- **Show added files in project diff view**
- **Fix git panel optimism**
- **boop**
- **Co-Authored-By: Cole <cole@zed.dev>**
- **Fix commit (all) buttons state**
- **WIP**
- **WIP: commit all mode**

Closes #ISSUE

Release Notes:

- N/A
This commit is contained in:
Conrad Irwin 2025-02-05 12:13:32 -07:00 committed by GitHub
parent 6d81ad1e0b
commit 971a91ced7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 142 additions and 118 deletions

1
assets/icons/circle.svg Normal file
View file

@ -0,0 +1 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="7.25" cy="7.25" r="3" fill="currentColor"></circle></svg>

After

Width:  |  Height:  |  Size: 165 B

View file

@ -75,15 +75,15 @@ struct SerializedGitPanel {
#[derive(Debug, PartialEq, Eq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum Section { enum Section {
Changed, Tracked,
Created, New,
} }
impl Section { impl Section {
pub fn contains(&self, status: FileStatus) -> bool { pub fn contains(&self, status: FileStatus) -> bool {
match self { match self {
Section::Changed => !status.is_created(), Section::Tracked => !status.is_created(),
Section::Created => status.is_created(), Section::New => status.is_created(),
} }
} }
} }
@ -99,8 +99,8 @@ impl GitHeaderEntry {
} }
pub fn title(&self) -> &'static str { pub fn title(&self) -> &'static str {
match self.header { match self.header {
Section::Changed => "Changed", Section::Tracked => "Changed",
Section::Created => "New", Section::New => "New",
} }
} }
} }
@ -112,9 +112,9 @@ enum GitListEntry {
} }
impl GitListEntry { impl GitListEntry {
fn status_entry(&self) -> Option<GitStatusEntry> { fn status_entry(&self) -> Option<&GitStatusEntry> {
match self { match self {
GitListEntry::GitStatusEntry(entry) => Some(entry.clone()), GitListEntry::GitStatusEntry(entry) => Some(entry),
_ => None, _ => None,
} }
} }
@ -129,7 +129,7 @@ pub struct GitStatusEntry {
pub(crate) is_staged: Option<bool>, pub(crate) is_staged: Option<bool>,
} }
pub struct PendingOperation { struct PendingOperation {
finished: bool, finished: bool,
will_become_staged: bool, will_become_staged: bool,
repo_paths: HashSet<RepoPath>, repo_paths: HashSet<RepoPath>,
@ -158,8 +158,11 @@ pub struct GitPanel {
pending: Vec<PendingOperation>, pending: Vec<PendingOperation>,
commit_task: Task<Result<()>>, commit_task: Task<Result<()>>,
commit_pending: bool, commit_pending: bool,
can_commit: bool,
can_commit_all: bool, tracked_staged_count: usize,
tracked_count: usize,
new_staged_count: usize,
new_count: usize,
} }
fn commit_message_editor( fn commit_message_editor(
@ -272,8 +275,10 @@ impl GitPanel {
commit_editor, commit_editor,
project, project,
workspace, workspace,
can_commit: false, tracked_staged_count: 0,
can_commit_all: false, tracked_count: 0,
new_staged_count: 0,
new_count: 0,
}; };
git_panel.schedule_update(false, window, cx); git_panel.schedule_update(false, window, cx);
git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx); git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
@ -579,7 +584,7 @@ impl GitPanel {
section.contains(&status_entry) section.contains(&status_entry)
&& status_entry.is_staged != Some(goal_staged_state) && status_entry.is_staged != Some(goal_staged_state)
}) })
.map(|status_entry| status_entry.repo_path) .map(|status_entry| status_entry.repo_path.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
(goal_staged_state, entries) (goal_staged_state, entries)
@ -593,10 +598,12 @@ impl GitPanel {
repo_paths: repo_paths.iter().cloned().collect(), repo_paths: repo_paths.iter().cloned().collect(),
finished: false, finished: false,
}); });
let repo_paths = repo_paths.clone();
let active_repository = active_repository.clone();
self.update_counts();
cx.notify();
cx.spawn({ cx.spawn({
let repo_paths = repo_paths.clone();
let active_repository = active_repository.clone();
|this, mut cx| async move { |this, mut cx| async move {
let result = cx let result = cx
.update(|cx| { .update(|cx| {
@ -673,7 +680,8 @@ impl GitPanel {
let Some(active_repository) = self.active_repository.clone() else { let Some(active_repository) = self.active_repository.clone() else {
return; return;
}; };
if !self.can_commit { if !self.has_staged_changes() {
self.commit_tracked_changes(&Default::default(), name_and_email, window, cx);
return; return;
} }
let message = self.commit_editor.read(cx).text(cx); let message = self.commit_editor.read(cx).text(cx);
@ -716,7 +724,7 @@ impl GitPanel {
let Some(active_repository) = self.active_repository.clone() else { let Some(active_repository) = self.active_repository.clone() else {
return; return;
}; };
if !self.can_commit_all { if !self.has_staged_changes() || !self.has_tracked_changes() {
return; return;
} }
@ -731,10 +739,10 @@ impl GitPanel {
.iter() .iter()
.filter_map(|entry| entry.status_entry()) .filter_map(|entry| entry.status_entry())
.filter(|status_entry| { .filter(|status_entry| {
Section::Changed.contains(status_entry.status) Section::Tracked.contains(status_entry.status)
&& !status_entry.is_staged.unwrap_or(false) && !status_entry.is_staged.unwrap_or(false)
}) })
.map(|status_entry| status_entry.repo_path) .map(|status_entry| status_entry.repo_path.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move { self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move {
@ -911,10 +919,6 @@ impl GitPanel {
let repo = repo.read(cx); let repo = repo.read(cx);
let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path)); let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
let mut has_changed_checked_boxes = false;
let mut has_changed = false;
let mut has_added_checked_boxes = false;
// Second pass - create entries with proper depth calculation // Second pass - create entries with proper depth calculation
for entry in repo.status() { for entry in repo.status() {
let (depth, difference) = let (depth, difference) =
@ -951,15 +955,8 @@ impl GitPanel {
}; };
if is_new { if is_new {
if entry.is_staged != Some(false) {
has_added_checked_boxes = true
}
new_entries.push(entry); new_entries.push(entry);
} else { } else {
has_changed = true;
if entry.is_staged != Some(false) {
has_changed_checked_boxes = true
}
changed_entries.push(entry); changed_entries.push(entry);
} }
} }
@ -970,7 +967,7 @@ impl GitPanel {
if changed_entries.len() > 0 { if changed_entries.len() > 0 {
self.entries.push(GitListEntry::Header(GitHeaderEntry { self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Changed, header: Section::Tracked,
})); }));
self.entries.extend( self.entries.extend(
changed_entries changed_entries
@ -980,7 +977,7 @@ impl GitPanel {
} }
if new_entries.len() > 0 { if new_entries.len() > 0 {
self.entries.push(GitListEntry::Header(GitHeaderEntry { self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Created, header: Section::New,
})); }));
self.entries self.entries
.extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry)); .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
@ -988,39 +985,62 @@ impl GitPanel {
for (ix, entry) in self.entries.iter().enumerate() { for (ix, entry) in self.entries.iter().enumerate() {
if let Some(status_entry) = entry.status_entry() { if let Some(status_entry) = entry.status_entry() {
self.entries_by_path.insert(status_entry.repo_path, ix); self.entries_by_path
.insert(status_entry.repo_path.clone(), ix);
} }
} }
self.can_commit = has_changed_checked_boxes || has_added_checked_boxes; self.update_counts();
self.can_commit_all = has_changed || has_added_checked_boxes;
self.select_first_entry_if_none(cx); self.select_first_entry_if_none(cx);
cx.notify(); cx.notify();
} }
fn header_state(&self, header_type: Section) -> ToggleState { fn update_counts(&mut self) {
let mut count = 0; self.new_count = 0;
let mut staged_count = 0; self.tracked_count = 0;
'outer: for entry in &self.entries { self.new_staged_count = 0;
let Some(entry) = entry.status_entry() else { self.tracked_staged_count = 0;
for entry in &self.entries {
let Some(status_entry) = entry.status_entry() else {
continue; continue;
}; };
if entry.status.is_created() != (header_type == Section::Created) { if status_entry.status.is_created() {
continue; self.new_count += 1;
} if self.entry_appears_staged(status_entry) != Some(false) {
count += 1; self.new_staged_count += 1;
for pending in self.pending.iter().rev() { }
if pending.repo_paths.contains(&entry.repo_path) { } else {
if pending.will_become_staged { self.tracked_count += 1;
staged_count += 1; if self.entry_appears_staged(status_entry) != Some(false) {
} self.tracked_staged_count += 1;
continue 'outer;
} }
} }
staged_count += entry.status.is_staged().unwrap_or(false) as usize;
} }
}
fn entry_appears_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
for pending in self.pending.iter().rev() {
if pending.repo_paths.contains(&entry.repo_path) {
return Some(pending.will_become_staged);
}
}
entry.is_staged
}
fn has_staged_changes(&self) -> bool {
self.tracked_staged_count > 0 || self.new_staged_count > 0
}
fn has_tracked_changes(&self) -> bool {
self.tracked_count > 0
}
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),
};
if staged_count == 0 { if staged_count == 0 {
ToggleState::Unselected ToggleState::Unselected
} else if count == staged_count { } else if count == staged_count {
@ -1157,25 +1177,27 @@ impl GitPanel {
cx: &Context<Self>, cx: &Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
let editor = self.commit_editor.clone(); let editor = self.commit_editor.clone();
let can_commit = !self.commit_pending && self.can_commit && !editor.read(cx).is_empty(cx); let can_commit =
let can_commit_all = (self.has_staged_changes() || self.has_tracked_changes()) && !self.commit_pending;
!self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
let editor_focus_handle = editor.read(cx).focus_handle(cx).clone(); let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
let focus_handle_1 = self.focus_handle(cx).clone(); let focus_handle_1 = self.focus_handle(cx).clone();
let focus_handle_2 = self.focus_handle(cx).clone(); let tooltip = if self.has_staged_changes() {
"Commit staged changes"
} else {
"Commit changes to tracked files"
};
let title = if self.has_staged_changes() {
"Commit"
} else {
"Commit All"
};
let commit_staged_button = self let commit_button = self
.panel_button("commit-staged-changes", "Commit") .panel_button("commit-changes", title)
.tooltip(move |window, cx| { .tooltip(move |window, cx| {
let focus_handle = focus_handle_1.clone(); let focus_handle = focus_handle_1.clone();
Tooltip::for_action_in( Tooltip::for_action_in(tooltip, &CommitChanges, &focus_handle, window, cx)
"Commit all staged changes",
&CommitChanges,
&focus_handle,
window,
cx,
)
}) })
.disabled(!can_commit) .disabled(!can_commit)
.on_click({ .on_click({
@ -1185,31 +1207,6 @@ impl GitPanel {
}) })
}); });
let commit_all_button = self
.panel_button("commit-all-changes", "Commit All")
.tooltip(move |window, cx| {
let focus_handle = focus_handle_2.clone();
Tooltip::for_action_in(
"Commit all changes, including unstaged changes",
&CommitAllChanges,
&focus_handle,
window,
cx,
)
})
.disabled(!can_commit_all)
.on_click({
let name_and_email = name_and_email.clone();
cx.listener(move |this, _: &ClickEvent, window, cx| {
this.commit_tracked_changes(
&CommitAllChanges,
name_and_email.clone(),
window,
cx,
)
})
});
div().w_full().h(px(140.)).px_2().pt_1().pb_2().child( div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
v_flex() v_flex()
.id("commit-editor-container") .id("commit-editor-container")
@ -1229,8 +1226,7 @@ impl GitPanel {
.right_3() .right_3()
.gap_1p5() .gap_1p5()
.child(div().gap_1().flex_grow()) .child(div().gap_1().flex_grow())
.child(commit_all_button) .child(commit_button),
.child(commit_staged_button),
), ),
) )
} }
@ -1423,8 +1419,17 @@ impl GitPanel {
_window: &Window, _window: &Window,
cx: &Context<Self>, cx: &Context<Self>,
) -> AnyElement { ) -> AnyElement {
let checkbox = Checkbox::new(header.title(), self.header_state(header.header)) let header_state = if self.has_staged_changes() {
self.header_state(header.header)
} else {
match header.header {
Section::Tracked => ToggleState::Selected,
Section::New => ToggleState::Unselected,
}
};
let checkbox = Checkbox::new(header.title(), header_state)
.disabled(!has_write_access) .disabled(!has_write_access)
.placeholder(!self.has_staged_changes())
.fill() .fill()
.elevation(ElevationIndex::Surface); .elevation(ElevationIndex::Surface);
let selected = self.selected_entry == Some(ix); let selected = self.selected_entry == Some(ix);
@ -1507,14 +1512,19 @@ impl GitPanel {
let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into()); let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
let is_staged = pending let mut is_staged = pending
.or_else(|| entry.is_staged) .or_else(|| entry.is_staged)
.map(ToggleState::from) .map(ToggleState::from)
.unwrap_or(ToggleState::Indeterminate); .unwrap_or(ToggleState::Indeterminate);
if !self.has_staged_changes() && !entry.status.is_created() {
is_staged = ToggleState::Selected;
}
let checkbox = Checkbox::new(id, is_staged) let checkbox = Checkbox::new(id, is_staged)
.disabled(!has_write_access) .disabled(!has_write_access)
.fill() .fill()
.placeholder(!self.has_staged_changes())
.elevation(ElevationIndex::Surface) .elevation(ElevationIndex::Surface)
.on_click({ .on_click({
let entry = entry.clone(); let entry = entry.clone();
@ -1532,6 +1542,7 @@ impl GitPanel {
.id(("start-slot", ix)) .id(("start-slot", ix))
.gap(DynamicSpacing::Base04.rems(cx)) .gap(DynamicSpacing::Base04.rems(cx))
.child(checkbox) .child(checkbox)
.tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
.child(git_status_icon(status, cx)) .child(git_status_icon(status, cx))
.on_mouse_down(MouseButton::Left, |_, _, cx| { .on_mouse_down(MouseButton::Left, |_, _, cx| {
// prevent the list item active state triggering when toggling checkbox // prevent the list item active state triggering when toggling checkbox

View file

@ -164,6 +164,7 @@ pub enum IconName {
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
ChevronUpDown, ChevronUpDown,
Circle,
Close, Close,
Code, Code,
Command, Command,

View file

@ -43,6 +43,7 @@ pub struct Checkbox {
id: ElementId, id: ElementId,
toggle_state: ToggleState, toggle_state: ToggleState,
disabled: bool, disabled: bool,
placeholder: bool,
on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>, on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
filled: bool, filled: bool,
style: ToggleStyle, style: ToggleStyle,
@ -62,6 +63,7 @@ impl Checkbox {
style: ToggleStyle::default(), style: ToggleStyle::default(),
tooltip: None, tooltip: None,
label: None, label: None,
placeholder: false,
} }
} }
@ -71,6 +73,12 @@ impl Checkbox {
self self
} }
/// Sets the disabled state of the [`Checkbox`].
pub fn placeholder(mut self, placeholder: bool) -> Self {
self.placeholder = placeholder;
self
}
/// Binds a handler to the [`Checkbox`] that will be called when clicked. /// Binds a handler to the [`Checkbox`] that will be called when clicked.
pub fn on_click( pub fn on_click(
mut self, mut self,
@ -145,23 +153,26 @@ impl Checkbox {
impl RenderOnce for Checkbox { impl RenderOnce for Checkbox {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let group_id = format!("checkbox_group_{:?}", self.id); let group_id = format!("checkbox_group_{:?}", self.id);
let color = if self.disabled {
Color::Disabled
} else if self.placeholder {
Color::Placeholder
} else {
Color::Selected
};
let icon = match self.toggle_state { let icon = match self.toggle_state {
ToggleState::Selected => Some(Icon::new(IconName::Check).size(IconSize::Small).color( ToggleState::Selected => Some(if self.placeholder {
if self.disabled { Icon::new(IconName::Circle)
Color::Disabled .size(IconSize::XSmall)
} else { .color(color)
Color::Selected } else {
}, Icon::new(IconName::Check)
)),
ToggleState::Indeterminate => Some(
Icon::new(IconName::Dash)
.size(IconSize::Small) .size(IconSize::Small)
.color(if self.disabled { .color(color)
Color::Disabled }),
} else { ToggleState::Indeterminate => {
Color::Selected Some(Icon::new(IconName::Dash).size(IconSize::Small).color(color))
}), }
),
ToggleState::Unselected => None, ToggleState::Unselected => None,
}; };

View file

@ -58,12 +58,12 @@ impl From<bool> for ToggleState {
} }
} }
impl From<Option<bool>> for ToggleState { // impl From<Option<bool>> for ToggleState {
fn from(selected: Option<bool>) -> Self { // fn from(selected: Option<bool>) -> Self {
match selected { // match selected {
Some(true) => Self::Selected, // Some(true) => Self::Selected,
Some(false) => Self::Unselected, // Some(false) => Self::Unselected,
None => Self::Unselected, // None => Self::Unselected,
} // }
} // }
} // }