From 91c70068729d030a00fad53b87aecb540c910bc3 Mon Sep 17 00:00:00 2001 From: Sathwik Date: Thu, 25 Jun 2026 14:12:06 +0530 Subject: [PATCH 01/14] git_panel: Add group by staging view option --- crates/git_ui/src/git_panel.rs | 345 ++++++++++++++++-- .../settings_content/src/settings_content.rs | 1 + 2 files changed, 316 insertions(+), 30 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 90463f1b9e1d29..9d28f62a671798 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -134,6 +134,8 @@ actions!( SetGroupByNone, /// Groups entries by status. SetGroupByStatus, + /// Groups entries by staging state. + SetGroupByStaging, /// Toggles showing entries in tree vs flat view. ToggleTreeView, /// Expands the selected entry to show its children. @@ -330,6 +332,23 @@ fn git_panel_view_options_menu( } }) }) + .item({ + let view_options_menu_state = view_options_menu_state.clone(); + ContextMenuEntry::new("Staging") + .toggle( + IconPosition::End, + state.group_by == GitPanelGroupBy::Staging, + ) + .handler(move |window, cx| { + if state.group_by != GitPanelGroupBy::Staging { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + group_by: GitPanelGroupBy::Staging, + ..state + }); + window.dispatch_action(Box::new(SetGroupByStaging), cx); + } + }) + }) }) } @@ -409,6 +428,8 @@ enum Section { Conflict, Tracked, New, + Staged, + Unstaged, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -426,6 +447,10 @@ impl GitHeaderEntry { } Section::Tracked => !status.is_created(), Section::New => status.is_created(), + Section::Staged => GitPanel::stage_status_for_entry(status_entry, repo).has_staged(), + Section::Unstaged => { + GitPanel::stage_status_for_entry(status_entry, repo).has_unstaged() + } } } pub fn title(&self) -> &'static str { @@ -433,6 +458,8 @@ impl GitHeaderEntry { Section::Conflict => "Conflicts", Section::Tracked => "Tracked", Section::New => "Untracked", + Section::Staged => "Staged", + Section::Unstaged => "Unstaged", } } } @@ -1128,7 +1155,13 @@ impl GitPanel { .status_for_path(&repo_path) .map(|status| status.status) .map(|status| { - if repo.had_conflict_on_last_merge_head_change(&repo_path) { + if GitPanelSettings::get_global(cx).group_by == GitPanelGroupBy::Staging { + if status.staging().has_staged() { + Section::Staged + } else { + Section::Unstaged + } + } else if repo.had_conflict_on_last_merge_head_change(&repo_path) { Section::Conflict } else if status.is_created() { Section::New @@ -2256,13 +2289,19 @@ impl GitPanel { (stage, repo_paths) } GitListEntry::Header(section) => { - let goal_staged_state = !self.header_state(section.header).selected(); + let goal_staged_state = match section.header { + Section::Staged => false, + Section::Unstaged => true, + _ => !self.header_state(section.header).selected(), + }; + let mut seen_paths = HashSet::default(); let entries = self .entries .iter() .filter_map(|entry| entry.status_entry()) .filter(|status_entry| { section.contains(status_entry, &repo) + && seen_paths.insert(status_entry.repo_path.clone()) && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool() != Some(goal_staged_state) }) @@ -2272,9 +2311,15 @@ impl GitPanel { (goal_staged_state, entries) } GitListEntry::Directory(entry) => { - let goal_staged_state = match self.stage_status_for_directory(entry, repo) { - StageStatus::Staged => StageStatus::Unstaged, - StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged, + let goal_staged_state = match entry.key.section { + Section::Staged => StageStatus::Unstaged, + Section::Unstaged => StageStatus::Staged, + _ => match self.stage_status_for_directory(entry, repo) { + StageStatus::Staged => StageStatus::Unstaged, + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + StageStatus::Staged + } + }, }; let goal_stage = goal_staged_state == StageStatus::Staged; @@ -3924,6 +3969,24 @@ impl GitPanel { } } + fn set_group_by_staging( + &mut self, + _: &SetGroupByStaging, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }); + }); + } + } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { let current_setting = GitPanelSettings::get_global(cx).tree_view; if let Some(workspace) = self.workspace.upgrade() { @@ -4199,7 +4262,9 @@ impl GitPanel { let settings = GitPanelSettings::get_global(cx); let sort_by = settings.sort_by; - let group_by_status = settings.group_by == GitPanelGroupBy::Status; + let group_by = settings.group_by; + let group_by_status = group_by == GitPanelGroupBy::Status; + let group_by_staging = group_by == GitPanelGroupBy::Staging; let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); if let Some(active_repo) = self.active_repository.as_ref() { @@ -4232,6 +4297,8 @@ impl GitPanel { let mut changed_entries = Vec::new(); let mut new_entries = Vec::new(); let mut conflict_entries = Vec::new(); + let mut staged_entries = Vec::new(); + let mut unstaged_entries = Vec::new(); let mut single_staged_entry = None; let mut staged_count = 0; let mut seen_directories = HashSet::default(); @@ -4275,7 +4342,14 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - if group_by_status && is_conflict { + if group_by_staging { + if staging.has_staged() { + staged_entries.push(entry.clone()); + } + if staging.has_unstaged() { + unstaged_entries.push(entry); + } + } else if group_by_status && is_conflict { conflict_entries.push(entry); } else if group_by_status && is_new { new_entries.push(entry); @@ -4327,6 +4401,8 @@ impl GitPanel { sort_entries(&mut conflict_entries); sort_entries(&mut changed_entries); sort_entries(&mut new_entries); + sort_entries(&mut staged_entries); + sort_entries(&mut unstaged_entries); } let mut push_entry = @@ -4355,15 +4431,18 @@ impl GitPanel { this.entries.push(entry); }; - macro_rules! take_section_entries { - () => { - [ - (Section::Conflict, std::mem::take(&mut conflict_entries)), - (Section::Tracked, std::mem::take(&mut changed_entries)), - (Section::New, std::mem::take(&mut new_entries)), - ] - }; - } + let section_entries = if group_by_staging { + vec![ + (Section::Staged, std::mem::take(&mut staged_entries)), + (Section::Unstaged, std::mem::take(&mut unstaged_entries)), + ] + } else { + vec![ + (Section::Conflict, std::mem::take(&mut conflict_entries)), + (Section::Tracked, std::mem::take(&mut changed_entries)), + (Section::New, std::mem::take(&mut new_entries)), + ] + }; match &mut self.view_mode { GitPanelViewMode::Tree(tree_state) => { @@ -4374,12 +4453,12 @@ impl GitPanel { // because push_entry mutably borrows self let mut tree_state = std::mem::take(tree_state); - for (section, entries) in take_section_entries!() { + for (section, entries) in section_entries { if entries.is_empty() { continue; } - if section != Section::Tracked || group_by_status { + if section != Section::Tracked || group_by != GitPanelGroupBy::None { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), @@ -4407,12 +4486,12 @@ impl GitPanel { self.view_mode = GitPanelViewMode::Tree(tree_state); } GitPanelViewMode::Flat => { - for (section, entries) in take_section_entries!() { + for (section, entries) in section_entries { if entries.is_empty() { continue; } - if section != Section::Tracked || group_by_status { + if section != Section::Tracked || group_by != GitPanelGroupBy::None { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), @@ -4465,6 +4544,8 @@ impl GitPanel { 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), + Section::Staged => (self.entry_count, self.entry_count), + Section::Unstaged => (0, self.entry_count), }; if staged_count == 0 { ToggleState::Unselected @@ -4475,6 +4556,24 @@ impl GitPanel { } } + fn section_for_entry_index(&self, ix: usize) -> Option
{ + self.entries.get(..=ix)?.iter().rev().find_map(|entry| { + if let GitListEntry::Header(header) = entry { + Some(header.header) + } else { + None + } + }) + } + + fn staging_action_for_section(section: Section) -> Option<(bool, &'static str, &'static str)> { + match section { + Section::Staged => Some((false, "-", "Unstage")), + Section::Unstaged => Some((true, "+", "Stage")), + _ => None, + } + } + fn update_counts(&mut self, repo: &Repository) { self.show_placeholders = false; self.conflicted_count = 0; @@ -4486,7 +4585,12 @@ impl GitPanel { self.entry_count = 0; self.diff_stat_total = DiffStat::default(); + let mut counted_paths = HashSet::default(); for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { + if !counted_paths.insert(status_entry.repo_path.clone()) { + continue; + } + self.entry_count += 1; if let Some(diff_stat) = status_entry.diff_stat { self.diff_stat_total.added = @@ -6467,6 +6571,7 @@ impl GitPanel { let toggle_state = self.header_state(header.header); let section = header.header; let weak = cx.weak_entity(); + let staging_action = Self::staging_action_for_section(section); h_flex() .id(id) @@ -6486,12 +6591,20 @@ impl GitPanel { .color(Color::Muted) .size(LabelSize::Small), ) - .child( + .child(if let Some((_stage, label, action)) = staging_action { + Button::new(checkbox_id, label) + .disabled(!has_write_access) + .label_size(LabelSize::Small) + .style(ButtonStyle::Subtle) + .tooltip(move |_window, cx| Tooltip::simple(format!("{action} all"), cx)) + .into_any_element() + } else { Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) .fill() - .elevation(ElevationIndex::Surface), - ) + .elevation(ElevationIndex::Surface) + .into_any_element() + }) .on_click(move |_, window, cx| { if !has_write_access { return; @@ -6688,6 +6801,12 @@ impl GitPanel { ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into()); let stage_status = GitPanel::stage_status_for_entry(entry, &repo); + let staging_action = if settings.group_by == GitPanelGroupBy::Staging { + self.section_for_entry_index(ix) + .and_then(Self::staging_action_for_section) + } else { + None + }; let mut is_staged: ToggleState = match stage_status { StageStatus::Staged => ToggleState::Selected, StageStatus::Unstaged => ToggleState::Unselected, @@ -6800,7 +6919,28 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child( + .child(if let Some((stage, label, action)) = staging_action { + Button::new(checkbox_id, label) + .disabled(!has_write_access) + .label_size(LabelSize::Small) + .style(ButtonStyle::Subtle) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, _window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + this.change_file_stage(stage, vec![entry.clone()], cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| Tooltip::simple(action, cx)) + .into_any_element() + } else { Checkbox::new(checkbox_id, is_staged) .disabled(!has_write_access) .fill() @@ -6840,8 +6980,9 @@ impl GitPanel { let tooltip_name = action.to_string(); Tooltip::for_action(tooltip_name, &ToggleStaged, cx) - }), - ), + }) + .into_any_element() + }), ) .on_click({ cx.listener(move |this, event: &ClickEvent, window, cx| { @@ -6942,6 +7083,11 @@ impl GitPanel { StageStatus::Unstaged => ToggleState::Unselected, StageStatus::PartiallyStaged => ToggleState::Indeterminate, }; + let staging_action = if settings.group_by == GitPanelGroupBy::Staging { + Self::staging_action_for_section(entry.key.section) + } else { + None + }; let name_row = h_flex() .min_w_0() @@ -6986,7 +7132,31 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child( + .child(if let Some((_stage, label, action)) = staging_action { + Button::new(checkbox_id, label) + .disabled(!has_write_access) + .label_size(LabelSize::Small) + .style(ButtonStyle::Subtle) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + let list_entry = GitListEntry::Directory(entry.clone()); + this.toggle_staged_for_entry(&list_entry, window, cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| { + Tooltip::simple(format!("{action} folder"), cx) + }) + .into_any_element() + } else { Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) .fill() @@ -7015,8 +7185,9 @@ impl GitPanel { StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", }; Tooltip::simple(format!("{action} folder"), cx) - }), - ), + }) + .into_any_element() + }), ) .on_click({ let key = entry.key.clone(); @@ -7350,6 +7521,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::set_sort_by_name)) .on_action(cx.listener(Self::set_group_by_none)) .on_action(cx.listener(Self::set_group_by_status)) + .on_action(cx.listener(Self::set_group_by_staging)) .on_action(cx.listener(Self::toggle_tree_view)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) @@ -8113,7 +8285,7 @@ pub(crate) fn commit_title_exceeds_limit(title: &str, max_length: usize) -> bool mod tests { use git::{ repository::repo_path, - status::{StatusCode, UnmergedStatus, UnmergedStatusCode}, + status::{StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}, }; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, px}; use indoc::indoc; @@ -8695,6 +8867,119 @@ mod tests { ); } + #[gpui::test] + async fn test_group_by_staging(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "conflicted content", + "new.rs": "new content", + "partial.rs": "partial content", + "staged.rs": "staged content", + "unstaged.rs": "unstaged content", + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.rs", + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + ), + ("new.rs", FileStatus::Untracked), + ( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + 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_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let entries = panel.read_with(&mut cx, |panel, _| { + assert_eq!(panel.entry_count, 5); + panel.entries.clone() + }); + + #[rustfmt::skip] + pretty_assertions::assert_matches!( + entries.as_slice(), + &[ + Header(GitHeaderEntry { header: Section::Staged }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Staged, .. }), + Header(GitHeaderEntry { header: Section::Unstaged }), + Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + ], + ); + assert_entry_paths( + &entries, + &[ + None, + Some("partial.rs"), + Some("staged.rs"), + None, + Some("conflict.rs"), + Some("new.rs"), + Some("partial.rs"), + Some("unstaged.rs"), + ], + ); + } + #[gpui::test] async fn test_bulk_staging(cx: &mut TestAppContext) { use GitListEntry::*; diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index f0f652d2baa4ad..4628bcf34e5713 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -787,6 +787,7 @@ pub enum GitPanelGroupBy { None, #[default] Status, + Staging, } #[derive( From 1717fdb66966d402a0c9868bdc7aecfe0000d694 Mon Sep 17 00:00:00 2001 From: Sathwik Date: Thu, 2 Jul 2026 14:37:25 +0530 Subject: [PATCH 02/14] Group git conflicts separately in staging view --- crates/git_ui/src/diff_multibuffer.rs | 19 ++- crates/git_ui/src/git_panel.rs | 225 ++++++++++++++++++++++---- crates/git_ui/src/project_diff.rs | 71 ++++++++ 3 files changed, 280 insertions(+), 35 deletions(-) diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs index 96c05303719e2d..33e4f4faf2485e 100644 --- a/crates/git_ui/src/diff_multibuffer.rs +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -929,6 +929,8 @@ impl Render for DiffMultibuffer { const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; +const STAGED_SORT_PREFIX: u64 = 2; +const UNSTAGED_SORT_PREFIX: u64 = 3; /// Computes a stable [`PathKey`] for a buffer in the project diff. /// @@ -950,14 +952,15 @@ pub(crate) fn project_diff_path_key( cx: &App, ) -> PathKey { let settings = GitPanelSettings::get_global(cx); - let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { - TRACKED_SORT_PREFIX - } else if repo.had_conflict_on_last_merge_head_change(repo_path) { - CONFLICT_SORT_PREFIX - } else if status.is_created() { - NEW_SORT_PREFIX - } else { - TRACKED_SORT_PREFIX + let sort_prefix = match settings.group_by { + GitPanelGroupBy::Staging if status.is_conflicted() => CONFLICT_SORT_PREFIX, + GitPanelGroupBy::Staging if status.staging().has_staged() => STAGED_SORT_PREFIX, + GitPanelGroupBy::Staging => UNSTAGED_SORT_PREFIX, + GitPanelGroupBy::Status if repo.had_conflict_on_last_merge_head_change(repo_path) => { + CONFLICT_SORT_PREFIX + } + GitPanelGroupBy::Status if status.is_created() => NEW_SORT_PREFIX, + _ => TRACKED_SORT_PREFIX, }; let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); PathKey::with_sort_prefix(sort_prefix, path) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 9d28f62a671798..5cfd9ca0caa604 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -85,6 +85,7 @@ use ui::{ IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, + IconButtonShape, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; @@ -1156,7 +1157,9 @@ impl GitPanel { .map(|status| status.status) .map(|status| { if GitPanelSettings::get_global(cx).group_by == GitPanelGroupBy::Staging { - if status.staging().has_staged() { + if status.is_conflicted() { + Section::Conflict + } else if status.staging().has_staged() { Section::Staged } else { Section::Unstaged @@ -4342,7 +4345,9 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - if group_by_staging { + if group_by_staging && entry.status.is_conflicted() { + conflict_entries.push(entry); + } else if group_by_staging { if staging.has_staged() { staged_entries.push(entry.clone()); } @@ -4433,6 +4438,7 @@ impl GitPanel { let section_entries = if group_by_staging { vec![ + (Section::Conflict, std::mem::take(&mut conflict_entries)), (Section::Staged, std::mem::take(&mut staged_entries)), (Section::Unstaged, std::mem::take(&mut unstaged_entries)), ] @@ -4566,14 +4572,28 @@ impl GitPanel { }) } - fn staging_action_for_section(section: Section) -> Option<(bool, &'static str, &'static str)> { + fn staging_action_for_section(section: Section) -> Option<(bool, IconName, &'static str)> { match section { - Section::Staged => Some((false, "-", "Unstage")), - Section::Unstaged => Some((true, "+", "Stage")), + Section::Staged => Some((false, IconName::Dash, "Unstage")), + Section::Unstaged => Some((true, IconName::Plus, "Stage")), _ => None, } } + fn staging_action_button( + id: ElementId, + icon: IconName, + action: &'static str, + disabled: bool, + ) -> IconButton { + IconButton::new(id, icon) + .disabled(disabled) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .style(ButtonStyle::Subtle) + .aria_label(action) + } + fn update_counts(&mut self, repo: &Repository) { self.show_placeholders = false; self.conflicted_count = 0; @@ -6572,10 +6592,13 @@ impl GitPanel { let section = header.header; let weak = cx.weak_entity(); let staging_action = Self::staging_action_for_section(section); + let staging_conflict = GitPanelSettings::get_global(cx).group_by + == GitPanelGroupBy::Staging + && section == Section::Conflict; h_flex() .id(id) - .cursor_pointer() + .when(!staging_conflict, |this| this.cursor_pointer()) .group(group_name) .h(self.list_item_height()) .w_full() @@ -6591,11 +6614,10 @@ impl GitPanel { .color(Color::Muted) .size(LabelSize::Small), ) - .child(if let Some((_stage, label, action)) = staging_action { - Button::new(checkbox_id, label) - .disabled(!has_write_access) - .label_size(LabelSize::Small) - .style(ButtonStyle::Subtle) + .child(if staging_conflict { + div().into_any_element() + } else if let Some((_stage, icon, action)) = staging_action { + Self::staging_action_button(checkbox_id, icon, action, !has_write_access) .tooltip(move |_window, cx| Tooltip::simple(format!("{action} all"), cx)) .into_any_element() } else { @@ -6606,7 +6628,7 @@ impl GitPanel { .into_any_element() }) .on_click(move |_, window, cx| { - if !has_write_access { + if !has_write_access || staging_conflict { return; } @@ -6801,9 +6823,12 @@ impl GitPanel { ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into()); let stage_status = GitPanel::stage_status_for_entry(entry, &repo); + let section = self.section_for_entry_index(ix); + let staging_conflict = settings.group_by == GitPanelGroupBy::Staging + && section == Some(Section::Conflict) + && status.is_conflicted(); let staging_action = if settings.group_by == GitPanelGroupBy::Staging { - self.section_for_entry_index(ix) - .and_then(Self::staging_action_for_section) + section.and_then(Self::staging_action_for_section) } else { None }; @@ -6919,11 +6944,31 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child(if let Some((stage, label, action)) = staging_action { - Button::new(checkbox_id, label) - .disabled(!has_write_access) - .label_size(LabelSize::Small) - .style(ButtonStyle::Subtle) + .child(if staging_conflict { + Self::staging_action_button( + checkbox_id, + IconName::Check, + "Mark as Resolved", + !has_write_access, + ) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, _window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + this.change_file_stage(true, vec![entry.clone()], cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| Tooltip::simple("Mark as Resolved", cx)) + .into_any_element() + } else if let Some((stage, icon, action)) = staging_action { + Self::staging_action_button(checkbox_id, icon, action, !has_write_access) .on_click({ let entry = entry.clone(); let this = cx.weak_entity(); @@ -7088,6 +7133,8 @@ impl GitPanel { } else { None }; + let staging_conflict = + settings.group_by == GitPanelGroupBy::Staging && entry.key.section == Section::Conflict; let name_row = h_flex() .min_w_0() @@ -7132,11 +7179,10 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child(if let Some((_stage, label, action)) = staging_action { - Button::new(checkbox_id, label) - .disabled(!has_write_access) - .label_size(LabelSize::Small) - .style(ButtonStyle::Subtle) + .child(if staging_conflict { + div().into_any_element() + } else if let Some((_stage, icon, action)) = staging_action { + Self::staging_action_button(checkbox_id, icon, action, !has_write_access) .on_click({ let entry = entry.clone(); let this = cx.weak_entity(); @@ -8868,7 +8914,7 @@ mod tests { } #[gpui::test] - async fn test_group_by_staging(cx: &mut TestAppContext) { + async fn test_group_by_staging_section_membership_and_order(cx: &mut TestAppContext) { use GitListEntry::*; init_test(cx); @@ -8955,11 +9001,12 @@ mod tests { pretty_assertions::assert_matches!( entries.as_slice(), &[ + Header(GitHeaderEntry { header: Section::Conflict }), + Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), Header(GitHeaderEntry { header: Section::Staged }), Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), Status(GitStatusEntry { staging: StageStatus::Staged, .. }), Header(GitHeaderEntry { header: Section::Unstaged }), - Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), @@ -8968,11 +9015,12 @@ mod tests { assert_entry_paths( &entries, &[ + None, + Some("conflict.rs"), None, Some("partial.rs"), Some("staged.rs"), None, - Some("conflict.rs"), Some("new.rs"), Some("partial.rs"), Some("unstaged.rs"), @@ -8980,6 +9028,129 @@ mod tests { ); } + #[gpui::test] + async fn test_staging_conflict_mark_resolved_transition(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n", + }), + ) + .await; + + let unresolved_status = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[("conflict.rs", unresolved_status)], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + 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_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let conflict_entry = panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + ], + ); + panel + .entries + .get(1) + .and_then(GitListEntry::status_entry) + .cloned() + .expect("conflict entry should exist") + }); + + panel.update_in(&mut cx, |panel, _window, cx| { + panel.change_file_stage(true, vec![conflict_entry.clone()], cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _| { + assert!(matches!( + panel.entries.as_slice(), + [ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + ] + )); + }); + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[("conflict.rs", FileStatus::index(StatusCode::Modified))], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Staged + }), + Status(GitStatusEntry { + staging: StageStatus::Staged, + .. + }), + ], + ); + assert_eq!(panel.entry_count, 1); + }); + } + #[gpui::test] async fn test_bulk_staging(cx: &mut TestAppContext) { use GitListEntry::*; diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index d674be55766d94..abb2d3a7ce26e1 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -970,9 +970,14 @@ pub(crate) fn render_send_review_to_agent_button( #[cfg(test)] mod tests { + use crate::diff_multibuffer::project_diff_path_key; use buffer_diff::DiffHunkSecondaryStatus; use db::indoc; use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; + use git::{ + repository::RepoPath, + status::{FileStatus, UnmergedStatus, UnmergedStatusCode}, + }; use gpui::TestAppContext; use multi_buffer::PathKey; use project::FakeFs; @@ -1912,6 +1917,72 @@ mod tests { assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]); } + #[gpui::test] + async fn test_staging_group_orders_conflicts_staged_then_unstaged(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a_unstaged.rs": "unstaged\n", + "m_staged.rs": "staged\n", + "z_conflict.rs": "conflict\n", + }), + ) + .await; + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .repositories(cx) + .values() + .next() + .cloned() + .expect("repository should exist") + }); + + let (conflict_key, staged_key, unstaged_key) = cx.update(|cx| { + let repository = repository.read(cx); + let conflict_key = project_diff_path_key( + repository, + &RepoPath::from_rel_path(rel_path("z_conflict.rs")), + FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }), + cx, + ); + let staged_key = project_diff_path_key( + repository, + &RepoPath::from_rel_path(rel_path("m_staged.rs")), + FileStatus::index(git::status::StatusCode::Modified), + cx, + ); + let unstaged_key = project_diff_path_key( + repository, + &RepoPath::from_rel_path(rel_path("a_unstaged.rs")), + git::status::StatusCode::Modified.worktree(), + cx, + ); + (conflict_key, staged_key, unstaged_key) + }); + + assert!(conflict_key < staged_key); + assert!(staged_key < unstaged_key); + } + #[gpui::test] async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) { init_test(cx); From 2d231382aefc9e7839f2e004e997a822d4b8f1d6 Mon Sep 17 00:00:00 2001 From: Sathwik Date: Sat, 4 Jul 2026 09:19:53 +0530 Subject: [PATCH 03/14] Deduplicate git panel entries by path --- crates/git_ui/src/git_panel.rs | 76 +++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 5cfd9ca0caa604..550fb854ee0c69 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1640,6 +1640,14 @@ impl GitPanel { self.selected_entry.and_then(|i| self.entries.get(i)) } + fn change_entries_by_path(&self) -> impl Iterator { + // A grouping can project one changed file into multiple list rows. + self.entries + .iter() + .filter_map(GitListEntry::status_entry) + .unique_by(|entry| entry.repo_path.clone()) + } + fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { if self.active_tab == GitPanelTab::History { self.open_selected_history_commit(window, cx); @@ -2010,10 +2018,9 @@ impl GitPanel { cx: &mut Context, ) { let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry().cloned()) + .change_entries_by_path() .filter(|status_entry| !status_entry.status.is_created()) + .cloned() .collect::>(); match entries.len() { @@ -2060,9 +2067,7 @@ impl GitPanel { return; }; let to_delete = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) + .change_entries_by_path() .filter(|status_entry| status_entry.status.is_created()) .cloned() .collect::>(); @@ -2297,14 +2302,10 @@ impl GitPanel { Section::Unstaged => true, _ => !self.header_state(section.header).selected(), }; - let mut seen_paths = HashSet::default(); let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) + .change_entries_by_path() .filter(|status_entry| { section.contains(status_entry, &repo) - && seen_paths.insert(status_entry.repo_path.clone()) && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool() != Some(goal_staged_state) }) @@ -2373,6 +2374,7 @@ impl GitPanel { let repo_paths = entries .iter() .map(|entry| entry.repo_path.clone()) + .unique() .collect(); if stage { repo.stage_entries(repo_paths, cx) @@ -2726,9 +2728,7 @@ impl GitPanel { cx.background_spawn(async move { commit_task.await? }) } else { let changed_files = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) + .change_entries_by_path() .filter(|status_entry| !status_entry.status.is_created()) .map(|status_entry| status_entry.repo_path.clone()) .collect::>(); @@ -4266,8 +4266,8 @@ impl GitPanel { let settings = GitPanelSettings::get_global(cx); let sort_by = settings.sort_by; let group_by = settings.group_by; - let group_by_status = group_by == GitPanelGroupBy::Status; - let group_by_staging = group_by == GitPanelGroupBy::Staging; + let group_by_file_status = group_by == GitPanelGroupBy::Status; + let group_by_staging_state = group_by == GitPanelGroupBy::Staging; let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); if let Some(active_repo) = self.active_repository.as_ref() { @@ -4345,18 +4345,18 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - if group_by_staging && entry.status.is_conflicted() { + if group_by_staging_state && entry.status.is_conflicted() { conflict_entries.push(entry); - } else if group_by_staging { + } else if group_by_staging_state { if staging.has_staged() { staged_entries.push(entry.clone()); } if staging.has_unstaged() { unstaged_entries.push(entry); } - } else if group_by_status && is_conflict { + } else if group_by_file_status && is_conflict { conflict_entries.push(entry); - } else if group_by_status && is_new { + } else if group_by_file_status && is_new { new_entries.push(entry); } else { changed_entries.push(entry); @@ -4436,7 +4436,7 @@ impl GitPanel { this.entries.push(entry); }; - let section_entries = if group_by_staging { + let section_entries = if group_by_staging_state { vec![ (Section::Conflict, std::mem::take(&mut conflict_entries)), (Section::Staged, std::mem::take(&mut staged_entries)), @@ -4605,12 +4605,8 @@ impl GitPanel { self.entry_count = 0; self.diff_stat_total = DiffStat::default(); - let mut counted_paths = HashSet::default(); - for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { - if !counted_paths.insert(status_entry.repo_path.clone()) { - continue; - } - + let change_entries = self.change_entries_by_path().cloned().collect::>(); + for status_entry in change_entries { self.entry_count += 1; if let Some(diff_stat) = status_entry.diff_stat { self.diff_stat_total.added = @@ -4621,7 +4617,7 @@ impl GitPanel { .saturating_add(diff_stat.deleted); } - let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo) + let is_staging_or_staged = GitPanel::stage_status_for_entry(&status_entry, repo) .as_bool() .unwrap_or(true); @@ -8926,6 +8922,7 @@ mod tests { "conflict.rs": "conflicted content", "new.rs": "new content", "partial.rs": "partial content", + "partial_new.rs": "partial new content", "staged.rs": "staged content", "unstaged.rs": "unstaged content", }), @@ -8952,6 +8949,14 @@ mod tests { } .into(), ), + ( + "partial_new.rs", + TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Modified, + } + .into(), + ), ("staged.rs", FileStatus::index(StatusCode::Modified)), ("unstaged.rs", StatusCode::Modified.worktree()), ], @@ -8993,7 +8998,16 @@ mod tests { await_git_panel_entries(&panel, &mut cx).await; let entries = panel.read_with(&mut cx, |panel, _| { - assert_eq!(panel.entry_count, 5); + assert_eq!(panel.entry_count, 6); + assert_eq!( + panel + .change_entries_by_path() + .filter(|entry| entry.status.is_created()) + .map(|entry| &*entry.repo_path) + .sorted() + .collect::>(), + [rel_path("new.rs"), rel_path("partial_new.rs")] + ); panel.entries.clone() }); @@ -9005,10 +9019,12 @@ mod tests { Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), Header(GitHeaderEntry { header: Section::Staged }), Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), Status(GitStatusEntry { staging: StageStatus::Staged, .. }), Header(GitHeaderEntry { header: Section::Unstaged }), Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), ], ); @@ -9019,10 +9035,12 @@ mod tests { Some("conflict.rs"), None, Some("partial.rs"), + Some("partial_new.rs"), Some("staged.rs"), None, Some("new.rs"), Some("partial.rs"), + Some("partial_new.rs"), Some("unstaged.rs"), ], ); From bcb6d10eaa22d6a44eb0caa93be554e3b18a126a Mon Sep 17 00:00:00 2001 From: Sathwik Date: Sat, 4 Jul 2026 10:32:30 +0530 Subject: [PATCH 04/14] Handle staged and unstaged projections separately --- crates/git_ui/src/git_panel.rs | 250 ++++++++++++++++++++++++--------- 1 file changed, 187 insertions(+), 63 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 550fb854ee0c69..f42605388d44af 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -438,6 +438,19 @@ struct GitHeaderEntry { header: Section, } +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +struct ProjectedChangeEntry { + section: Section, + index: usize, +} + +#[derive(Clone, Copy)] +struct StagingAction { + stage: bool, + icon: IconName, + label: &'static str, +} + impl GitHeaderEntry { pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool { let this = &self.header; @@ -793,7 +806,7 @@ pub struct GitPanel { entries: Vec, view_mode: GitPanelViewMode, tree_expanded_dirs: HashMap, - entries_indices: HashMap, + projected_entries_by_path: HashMap>, single_staged_entry: Option, single_tracked_entry: Option, focus_handle: FocusHandle, @@ -1078,7 +1091,7 @@ impl GitPanel { entries: Vec::new(), view_mode: GitPanelViewMode::from_settings(cx), tree_expanded_dirs: HashMap::default(), - entries_indices: HashMap::default(), + projected_entries_by_path: HashMap::default(), focus_handle: cx.focus_handle(), fs, new_count: 0, @@ -1133,7 +1146,18 @@ impl GitPanel { } pub fn entry_by_path(&self, path: &RepoPath) -> Option { - self.entries_indices.get(path).copied() + self.projected_entries_by_path + .get(path)? + .first() + .map(|entry| entry.index) + } + + fn entry_by_path_in_section(&self, path: &RepoPath, section: Section) -> Option { + self.projected_entries_by_path + .get(path)? + .iter() + .find(|entry| entry.section == section) + .map(|entry| entry.index) } pub fn select_entry_by_path( @@ -1198,7 +1222,10 @@ impl GitPanel { self.update_visible_entries(window, cx); } - let Some(ix) = self.entry_by_path(&repo_path) else { + let Some(ix) = section + .and_then(|section| self.entry_by_path_in_section(&repo_path, section)) + .or_else(|| self.entry_by_path(&repo_path)) + else { return; }; @@ -2488,7 +2515,18 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { - if let Some(selected_entry) = self.get_selected_entry().cloned() { + let Some(selected_index) = self.selected_entry else { + return; + }; + let Some(selected_entry) = self.entries.get(selected_index).cloned() else { + return; + }; + + if let Some(action) = self.staging_action_for_entry_index(selected_index) + && let Some(entry) = selected_entry.status_entry() + { + self.change_file_stage(action.stage, vec![entry.clone()], cx); + } else { self.toggle_staged_for_entry(&selected_entry, window, cx); } } @@ -4249,7 +4287,7 @@ impl GitPanel { self.git_access = None; } self.entries.clear(); - self.entries_indices.clear(); + self.projected_entries_by_path.clear(); self.single_staged_entry.take(); self.single_tracked_entry.take(); self.conflicted_count = 0; @@ -4413,6 +4451,7 @@ impl GitPanel { let mut push_entry = |this: &mut Self, entry: GitListEntry, + section: Section, is_visible: bool, logical_indices: Option<&mut Vec>| { if let Some(estimate) = @@ -4426,7 +4465,13 @@ impl GitPanel { if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone()) { - this.entries_indices.insert(repo_path, this.entries.len()); + this.projected_entries_by_path + .entry(repo_path) + .or_default() + .push(ProjectedChangeEntry { + section, + index: this.entries.len(), + }); } if let (Some(indices), true) = (logical_indices, is_visible) { @@ -4468,6 +4513,7 @@ impl GitPanel { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), + section, true, Some(&mut tree_state.logical_indices), ); @@ -4479,6 +4525,7 @@ impl GitPanel { push_entry( self, entry, + section, is_visible, Some(&mut tree_state.logical_indices), ); @@ -4501,13 +4548,14 @@ impl GitPanel { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), + section, true, None, ); } for entry in entries { - push_entry(self, GitListEntry::Status(entry), true, None); + push_entry(self, GitListEntry::Status(entry), section, true, None); } } } @@ -4572,14 +4620,27 @@ impl GitPanel { }) } - fn staging_action_for_section(section: Section) -> Option<(bool, IconName, &'static str)> { + fn staging_action_for_section(section: Section) -> Option { match section { - Section::Staged => Some((false, IconName::Dash, "Unstage")), - Section::Unstaged => Some((true, IconName::Plus, "Stage")), + Section::Staged => Some(StagingAction { + stage: false, + icon: IconName::Dash, + label: "Unstage", + }), + Section::Unstaged => Some(StagingAction { + stage: true, + icon: IconName::Plus, + label: "Stage", + }), _ => None, } } + fn staging_action_for_entry_index(&self, ix: usize) -> Option { + self.section_for_entry_index(ix) + .and_then(Self::staging_action_for_section) + } + fn staging_action_button( id: ElementId, icon: IconName, @@ -6612,10 +6673,15 @@ impl GitPanel { ) .child(if staging_conflict { div().into_any_element() - } else if let Some((_stage, icon, action)) = staging_action { - Self::staging_action_button(checkbox_id, icon, action, !has_write_access) - .tooltip(move |_window, cx| Tooltip::simple(format!("{action} all"), cx)) - .into_any_element() + } else if let Some(action) = staging_action { + Self::staging_action_button( + checkbox_id, + action.icon, + action.label, + !has_write_access, + ) + .tooltip(move |_window, cx| Tooltip::simple(format!("{} all", action.label), cx)) + .into_any_element() } else { Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) @@ -6662,13 +6728,15 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { + let staging_action = self.staging_action_for_entry_index(ix); 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 stage_title = match staging_action { + Some(StagingAction { stage: true, .. }) => "Stage File", + Some(StagingAction { stage: false, .. }) => "Unstage File", + None if entry.status.staging().is_fully_staged() => "Unstage File", + None => "Stage File", }; let restore_title = if entry.status.is_created() { "Trash File" @@ -6823,11 +6891,7 @@ impl GitPanel { let staging_conflict = settings.group_by == GitPanelGroupBy::Staging && section == Some(Section::Conflict) && status.is_conflicted(); - let staging_action = if settings.group_by == GitPanelGroupBy::Staging { - section.and_then(Self::staging_action_for_section) - } else { - None - }; + let staging_action = self.staging_action_for_entry_index(ix); let mut is_staged: ToggleState = match stage_status { StageStatus::Staged => ToggleState::Selected, StageStatus::Unstaged => ToggleState::Unselected, @@ -6963,24 +7027,29 @@ impl GitPanel { }) .tooltip(move |_window, cx| Tooltip::simple("Mark as Resolved", cx)) .into_any_element() - } else if let Some((stage, icon, action)) = staging_action { - Self::staging_action_button(checkbox_id, icon, action, !has_write_access) - .on_click({ - let entry = entry.clone(); - let this = cx.weak_entity(); - move |_, _window, cx| { - this.update(cx, |this, cx| { - if !has_write_access { - return; - } - this.change_file_stage(stage, vec![entry.clone()], cx); - cx.stop_propagation(); - }) - .ok(); - } - }) - .tooltip(move |_window, cx| Tooltip::simple(action, cx)) - .into_any_element() + } else if let Some(action) = staging_action { + Self::staging_action_button( + checkbox_id, + action.icon, + action.label, + !has_write_access, + ) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, _window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + this.change_file_stage(action.stage, vec![entry.clone()], cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| Tooltip::simple(action.label, cx)) + .into_any_element() } else { Checkbox::new(checkbox_id, is_staged) .disabled(!has_write_access) @@ -7177,27 +7246,32 @@ impl GitPanel { .cursor_pointer() .child(if staging_conflict { div().into_any_element() - } else if let Some((_stage, icon, action)) = staging_action { - Self::staging_action_button(checkbox_id, icon, action, !has_write_access) - .on_click({ - let entry = entry.clone(); - let this = cx.weak_entity(); - move |_, window, cx| { - this.update(cx, |this, cx| { - if !has_write_access { - return; - } - let list_entry = GitListEntry::Directory(entry.clone()); - this.toggle_staged_for_entry(&list_entry, window, cx); - cx.stop_propagation(); - }) - .ok(); - } - }) - .tooltip(move |_window, cx| { - Tooltip::simple(format!("{action} folder"), cx) - }) - .into_any_element() + } else if let Some(action) = staging_action { + Self::staging_action_button( + checkbox_id, + action.icon, + action.label, + !has_write_access, + ) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + let list_entry = GitListEntry::Directory(entry.clone()); + this.toggle_staged_for_entry(&list_entry, window, cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| { + Tooltip::simple(format!("{} folder", action.label), cx) + }) + .into_any_element() } else { Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) @@ -9008,6 +9082,37 @@ mod tests { .collect::>(), [rel_path("new.rs"), rel_path("partial_new.rs")] ); + + let partial_path = repo_path("partial.rs"); + let projections = panel + .projected_entries_by_path + .get(&partial_path) + .expect("partially staged entry should have projections"); + assert_eq!( + projections.as_slice(), + &[ + ProjectedChangeEntry { + section: Section::Staged, + index: 3, + }, + ProjectedChangeEntry { + section: Section::Unstaged, + index: 8, + }, + ] + ); + assert_eq!( + panel + .staging_action_for_entry_index(projections[0].index) + .map(|action| action.stage), + Some(false) + ); + assert_eq!( + panel + .staging_action_for_entry_index(projections[1].index) + .map(|action| action.stage), + Some(true) + ); panel.entries.clone() }); @@ -9044,6 +9149,25 @@ mod tests { Some("unstaged.rs"), ], ); + + let worktree_id = + cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); + panel.update_in(&mut cx, |panel, window, cx| { + panel.select_entry_by_path( + ProjectPath { + worktree_id, + path: rel_path("partial.rs").into_arc(), + }, + window, + cx, + ); + }); + panel.read_with(&cx, |panel, _| { + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged) + ); + }); } #[gpui::test] From e3c5e6094a4e82bbcb6e0186a55089a74db84e1b Mon Sep 17 00:00:00 2001 From: Sathwik Date: Sat, 4 Jul 2026 11:24:58 +0530 Subject: [PATCH 05/14] Preserve git panel selection across status refresh --- crates/git_ui/src/git_panel.rs | 108 +++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index f42605388d44af..fcb4e755e0fb70 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1170,7 +1170,7 @@ impl GitPanel { return; }; - let (repo_path, section) = { + let (repo_path, default_section) = { let repo = git_repo.read(cx); let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else { return; @@ -1199,6 +1199,15 @@ impl GitPanel { (repo_path, section) }; + let selected_section = self.selected_entry.and_then(|index| { + let selected_entry = self.entries.get(index)?.status_entry()?; + if selected_entry.repo_path == repo_path { + self.section_for_entry_index(index) + } else { + None + } + }); + let section = selected_section.or(default_section); let mut needs_rebuild = false; if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) { @@ -4276,6 +4285,10 @@ impl GitPanel { fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context) { let path_style = self.project.read(cx).path_style(cx); + let selected_change = self.selected_entry.and_then(|index| { + let entry = self.entries.get(index)?.status_entry()?; + Some((entry.repo_path.clone(), self.section_for_entry_index(index))) + }); let bulk_staging = self.bulk_staging.take(); let last_staged_path_prev_index = bulk_staging .as_ref() @@ -4340,6 +4353,7 @@ impl GitPanel { let mut conflict_entries = Vec::new(); let mut staged_entries = Vec::new(); let mut unstaged_entries = Vec::new(); + let mut tracked_entries = Vec::new(); let mut single_staged_entry = None; let mut staged_count = 0; let mut seen_directories = HashSet::default(); @@ -4378,6 +4392,10 @@ impl GitPanel { diff_stat: entry.diff_stat, }; + if !is_conflict && !is_new { + tracked_entries.push(entry.clone()); + } + if staging.has_staged() { staged_count += 1; single_staged_entry = Some(entry.clone()); @@ -4426,8 +4444,8 @@ impl GitPanel { } } - if conflict_entries.is_empty() && changed_entries.len() == 1 { - self.single_tracked_entry = changed_entries.first().cloned(); + if tracked_entries.len() == 1 { + self.single_tracked_entry = tracked_entries.pop(); } if !is_tree_view { @@ -4580,6 +4598,11 @@ impl GitPanel { self.bulk_staging = bulk_staging; } + if let Some((path, section)) = selected_change { + self.selected_entry = section + .and_then(|section| self.entry_by_path_in_section(&path, section)) + .or_else(|| self.entry_by_path(&path)); + } self.select_first_entry_if_none(window, cx); self.select_last_entry_if_out_of_bounds(window, cx); @@ -9036,7 +9059,7 @@ mod tests { ], ); - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; let window_handle = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window_handle @@ -9168,6 +9191,70 @@ mod tests { panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged) ); }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged); + panel.select_entry_by_path( + ProjectPath { + worktree_id, + path: rel_path("partial.rs").into_arc(), + }, + window, + cx, + ); + }); + panel.read_with(&cx, |panel, _| { + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged) + ); + }); + + panel.update_in(&mut cx, |panel, _window, _cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged); + }); + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.rs", + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + ), + ("new.rs", FileStatus::Untracked), + ("partial.rs", StatusCode::Modified.worktree()), + ( + "partial_new.rs", + TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&cx, |panel, _| { + let selected_entry = panel + .get_selected_entry() + .and_then(GitListEntry::status_entry) + .expect("selected change should remain selected"); + assert_eq!(selected_entry.repo_path, repo_path("partial.rs")); + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged) + ); + }); } #[gpui::test] @@ -10957,6 +11044,19 @@ mod tests { // "Update tracked" let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); assert_eq!(message, Some("Update tracked".to_string())); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }); + }); + }); + await_git_panel_entries(&panel, cx).await; + + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert_eq!(message, Some("Update tracked".to_string())); } #[test] From 2b47525bb7f88ef4707a0a065eb409bf31de4b0c Mon Sep 17 00:00:00 2001 From: Sathwik Date: Sat, 4 Jul 2026 17:11:19 +0530 Subject: [PATCH 06/14] Document staging group_by option --- crates/git_ui/src/git_panel.rs | 3 +-- docs/src/visual-customization.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index fcb4e755e0fb70..dabf571a76baf4 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -81,11 +81,10 @@ use strum::{IntoEnumIterator, VariantNames}; use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, + ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, IconButtonShape, IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, - IconButtonShape, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index b58469d12e43d3..b3f9131ca9af54 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -571,7 +571,7 @@ See [Terminal settings](./reference/all-settings.md#terminal) for additional non "default_width": 360, // Default width of the git panel. "status_style": "icon", // label_color, icon "sort_by": "path", // path, name - "group_by": "status", // none, status + "group_by": "status", // none, status, staging "scrollbar": { "show": null // Show/hide: (auto, system, always, never) } From abc4f38ad02c28ab020ed7abd66bafd2b108dc2f Mon Sep 17 00:00:00 2001 From: Sathwik Date: Tue, 7 Jul 2026 16:39:09 +0530 Subject: [PATCH 07/14] Route git diff actions to the matching diff view --- crates/git_ui/src/diff_multibuffer.rs | 5 - crates/git_ui/src/git_panel.rs | 169 ++++++++++++++++++++++++-- crates/git_ui/src/project_diff.rs | 71 ----------- crates/git_ui/src/staged_diff.rs | 14 ++- crates/git_ui/src/unstaged_diff.rs | 14 ++- 5 files changed, 180 insertions(+), 93 deletions(-) diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs index 33e4f4faf2485e..84a6573ea7002b 100644 --- a/crates/git_ui/src/diff_multibuffer.rs +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -929,8 +929,6 @@ impl Render for DiffMultibuffer { const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; -const STAGED_SORT_PREFIX: u64 = 2; -const UNSTAGED_SORT_PREFIX: u64 = 3; /// Computes a stable [`PathKey`] for a buffer in the project diff. /// @@ -953,9 +951,6 @@ pub(crate) fn project_diff_path_key( ) -> PathKey { let settings = GitPanelSettings::get_global(cx); let sort_prefix = match settings.group_by { - GitPanelGroupBy::Staging if status.is_conflicted() => CONFLICT_SORT_PREFIX, - GitPanelGroupBy::Staging if status.staging().has_staged() => STAGED_SORT_PREFIX, - GitPanelGroupBy::Staging => UNSTAGED_SORT_PREFIX, GitPanelGroupBy::Status if repo.had_conflict_on_last_merge_head_change(repo_path) => { CONFLICT_SORT_PREFIX } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index dabf571a76baf4..9baafe170dd107 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -450,6 +450,13 @@ struct StagingAction { label: &'static str, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DiffTarget { + Uncommitted, + Staged, + Unstaged, +} + impl GitHeaderEntry { pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool { let this = &self.header; @@ -1603,13 +1610,34 @@ impl GitPanel { fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context) { maybe!({ let workspace = self.workspace.upgrade()?; - - if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) { - let entry = self.entries.get(self.selected_entry?)?.status_entry()?; - - project_diff.update(cx, |project_diff, cx| { - project_diff.move_to_entry(entry.clone(), window, cx); - }); + let selected_index = self.selected_entry?; + let entry = self.entries.get(selected_index)?.status_entry()?.clone(); + let target = + Self::diff_target_for_section(self.section_for_entry_index(selected_index)); + + match target { + DiffTarget::Staged => { + if let Some(staged_diff) = workspace.read(cx).item_of_type::(cx) { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.move_to_entry(entry, window, cx); + }); + } + } + DiffTarget::Unstaged => { + if let Some(unstaged_diff) = workspace.read(cx).item_of_type::(cx) + { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.move_to_entry(entry, window, cx); + }); + } + } + DiffTarget::Uncommitted => { + if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) { + project_diff.update(cx, |project_diff, cx| { + project_diff.move_to_entry(entry, window, cx); + }); + } + } } Some(()) @@ -1697,11 +1725,15 @@ impl GitPanel { return; } maybe!({ - let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + let selected_index = self.selected_entry?; + let entry = self.entries.get(selected_index)?.status_entry()?; let workspace = self.workspace.upgrade()?; let git_repo = self.active_repository.as_ref()?; + let target = + Self::diff_target_for_section(self.section_for_entry_index(selected_index)); - if let Some(project_diff) = workspace.read(cx).active_item_as::(cx) + if target == DiffTarget::Uncommitted + && let Some(project_diff) = workspace.read(cx).active_item_as::(cx) && let Some(project_path) = project_diff.read(cx).active_project_path(cx) && Some(&entry.repo_path) == git_repo @@ -1715,8 +1747,16 @@ impl GitPanel { }; self.workspace - .update(cx, |workspace, cx| { - ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + .update(cx, |workspace, cx| match target { + DiffTarget::Uncommitted => { + ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + } + DiffTarget::Staged => { + StagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + } + DiffTarget::Unstaged => { + UnstagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx); + } }) .ok(); self.focus_handle.focus(window, cx); @@ -4663,6 +4703,14 @@ impl GitPanel { .and_then(Self::staging_action_for_section) } + fn diff_target_for_section(section: Option
) -> DiffTarget { + match section { + Some(Section::Staged) => DiffTarget::Staged, + Some(Section::Unstaged) => DiffTarget::Unstaged, + _ => DiffTarget::Uncommitted, + } + } + fn staging_action_button( id: ElementId, icon: IconName, @@ -9379,6 +9427,105 @@ mod tests { }); } + #[gpui::test] + async fn test_group_by_staging_open_diff_uses_section_diff(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "partial.rs": "partial content", + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + )], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + 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_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged); + panel.open_diff(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.open_solo_diff(&menu::SecondaryConfirm, window, cx); + }); + cx.run_until_parked(); + + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged); + panel.open_diff(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + }); + } + #[gpui::test] async fn test_bulk_staging(cx: &mut TestAppContext) { use GitListEntry::*; diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index abb2d3a7ce26e1..d674be55766d94 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -970,14 +970,9 @@ pub(crate) fn render_send_review_to_agent_button( #[cfg(test)] mod tests { - use crate::diff_multibuffer::project_diff_path_key; use buffer_diff::DiffHunkSecondaryStatus; use db::indoc; use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; - use git::{ - repository::RepoPath, - status::{FileStatus, UnmergedStatus, UnmergedStatusCode}, - }; use gpui::TestAppContext; use multi_buffer::PathKey; use project::FakeFs; @@ -1917,72 +1912,6 @@ mod tests { assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]); } - #[gpui::test] - async fn test_staging_group_orders_conflicts_staged_then_unstaged(cx: &mut TestAppContext) { - init_test(cx); - - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().group_by = - Some(GitPanelGroupBy::Staging); - }); - }); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "a_unstaged.rs": "unstaged\n", - "m_staged.rs": "staged\n", - "z_conflict.rs": "conflict\n", - }), - ) - .await; - let project = Project::test(fs, [path!("/project").as_ref()], cx).await; - cx.run_until_parked(); - - let repository = project.read_with(cx, |project, cx| { - project - .repositories(cx) - .values() - .next() - .cloned() - .expect("repository should exist") - }); - - let (conflict_key, staged_key, unstaged_key) = cx.update(|cx| { - let repository = repository.read(cx); - let conflict_key = project_diff_path_key( - repository, - &RepoPath::from_rel_path(rel_path("z_conflict.rs")), - FileStatus::Unmerged(UnmergedStatus { - first_head: UnmergedStatusCode::Updated, - second_head: UnmergedStatusCode::Updated, - }), - cx, - ); - let staged_key = project_diff_path_key( - repository, - &RepoPath::from_rel_path(rel_path("m_staged.rs")), - FileStatus::index(git::status::StatusCode::Modified), - cx, - ); - let unstaged_key = project_diff_path_key( - repository, - &RepoPath::from_rel_path(rel_path("a_unstaged.rs")), - git::status::StatusCode::Modified.worktree(), - cx, - ); - (conflict_key, staged_key, unstaged_key) - }); - - assert!(conflict_key < staged_key); - assert!(staged_key < unstaged_key); - } - #[gpui::test] async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs index 9b607c72cbc1b5..30ab5bd0590c97 100644 --- a/crates/git_ui/src/staged_diff.rs +++ b/crates/git_ui/src/staged_diff.rs @@ -198,13 +198,21 @@ impl StagedDiff { if let Some(entry) = entry { staged_diff.update(cx, |staged_diff, cx| { - staged_diff - .diff - .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + staged_diff.move_to_entry(entry, window, cx); }); } } + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + } + pub(crate) fn new( project: Entity, workspace: Entity, diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs index 44f700c722a7ff..a31ea12b3fe5b3 100644 --- a/crates/git_ui/src/unstaged_diff.rs +++ b/crates/git_ui/src/unstaged_diff.rs @@ -202,13 +202,21 @@ impl UnstagedDiff { if let Some(entry) = entry { unstaged_diff.update(cx, |unstaged_diff, cx| { - unstaged_diff - .diff - .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + unstaged_diff.move_to_entry(entry, window, cx); }); } } + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + } + pub(crate) fn new( project: Entity, workspace: Entity, From 3ebb18dd82f2f1b71b4ccd2f594a951299d13f7e Mon Sep 17 00:00:00 2001 From: Sathwik Date: Wed, 8 Jul 2026 13:32:25 +0530 Subject: [PATCH 08/14] Gate conflict and new sort prefixes to status grouping --- crates/git_ui/src/diff_multibuffer.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs index 84a6573ea7002b..96c05303719e2d 100644 --- a/crates/git_ui/src/diff_multibuffer.rs +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -950,12 +950,14 @@ pub(crate) fn project_diff_path_key( cx: &App, ) -> PathKey { let settings = GitPanelSettings::get_global(cx); - let sort_prefix = match settings.group_by { - GitPanelGroupBy::Status if repo.had_conflict_on_last_merge_head_change(repo_path) => { - CONFLICT_SORT_PREFIX - } - GitPanelGroupBy::Status if status.is_created() => NEW_SORT_PREFIX, - _ => TRACKED_SORT_PREFIX, + let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { + TRACKED_SORT_PREFIX + } else if repo.had_conflict_on_last_merge_head_change(repo_path) { + CONFLICT_SORT_PREFIX + } else if status.is_created() { + NEW_SORT_PREFIX + } else { + TRACKED_SORT_PREFIX }; let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); PathKey::with_sort_prefix(sort_prefix, path) From 7051177f37199eb4db36b3d3efe43590613f8505 Mon Sep 17 00:00:00 2001 From: Sathwik Date: Wed, 8 Jul 2026 13:51:24 +0530 Subject: [PATCH 09/14] Remove unused git panel import --- crates/git_ui/src/git_panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8e79e6ae71a50c..dc20ddb32050a3 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -83,8 +83,8 @@ use time::OffsetDateTime; use ui::{ ButtonLike, Checkbox, Chip, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, IconButtonShape, IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProjectEmptyState, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, - Tooltip, WithScrollbar, prelude::*, + ProjectEmptyState, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, + prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; From 67c4987758dbf137bc20a2c4b98e5df2cc1d3e74 Mon Sep 17 00:00:00 2001 From: Sathwik Date: Wed, 8 Jul 2026 14:47:48 +0530 Subject: [PATCH 10/14] Fix staging action for partially staged files --- crates/git_ui/src/git_panel.rs | 107 ++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 16 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index dc20ddb32050a3..8b4201f19957d4 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4770,23 +4770,21 @@ impl GitPanel { .saturating_add(diff_stat.deleted); } - let is_staging_or_staged = GitPanel::stage_status_for_entry(&status_entry, repo) - .as_bool() - .unwrap_or(true); + let stage_status = GitPanel::stage_status_for_entry(&status_entry, repo); if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.conflicted_staged_count += 1; } } else if status_entry.status.is_created() { self.new_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.new_staged_count += 1; } } else { self.tracked_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.tracked_staged_count += 1; } } @@ -4800,9 +4798,12 @@ impl GitPanel { } 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 + self.change_entries_by_path() + .any(|entry| entry.staging.has_unstaged()) + } + + fn primary_changes_action_stages(&self) -> bool { + self.entry_count == 0 || self.has_unstaged_changes() } fn has_tracked_changes(&self) -> bool { @@ -4810,7 +4811,8 @@ impl GitPanel { } pub fn has_unstaged_conflicts(&self) -> bool { - self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count + self.change_entries_by_path() + .any(|entry| entry.status.is_conflicted() && entry.staging.has_unstaged()) } fn show_error_toast(&self, action: impl Into, e: anyhow::Error, cx: &mut App) { @@ -5362,12 +5364,11 @@ impl GitPanel { } fn render_git_changes_actions_button(&self, cx: &mut Context) -> impl IntoElement { - let (text, action, stage, tooltip) = - if self.total_staged_count() == self.entry_count && self.entry_count > 0 { - ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") - } else { - ("Stage All", StageAll.boxed_clone(), true, "git add --all") - }; + let (text, action, stage, tooltip) = if self.primary_changes_action_stages() { + ("Stage All", StageAll.boxed_clone(), true, "git add --all") + } else { + ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") + }; SplitButton::new( ButtonLike::new_rounded_left("git-changes-actions-split-button-left") @@ -9479,6 +9480,80 @@ mod tests { }); } + #[gpui::test] + async fn test_group_by_staging_primary_action_stages_partially_staged_files( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "partial.rs": "partial content", + "staged.rs": "staged content", + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + 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_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&mut cx, |panel, _| { + assert_eq!(panel.entry_count, 2); + assert_eq!(panel.total_staged_count(), panel.entry_count); + assert!(panel.has_unstaged_changes()); + assert!(panel.primary_changes_action_stages()); + }); + } + #[gpui::test] async fn test_group_by_staging_open_diff_uses_section_diff(cx: &mut TestAppContext) { init_test(cx); From 64624e1b5c053975e09e37d25d011cd80c283821 Mon Sep 17 00:00:00 2001 From: Sathwik Date: Thu, 9 Jul 2026 14:29:58 +0530 Subject: [PATCH 11/14] Add section specific diff stats for grouped by staging --- crates/fs/src/fake_git_repo.rs | 91 +++++++---- crates/git/src/repository.rs | 22 ++- crates/git_ui/src/git_panel.rs | 143 ++++++++++++++++- crates/project/src/git_store.rs | 275 +++++++++++++++++++++++++++----- crates/proto/proto/git.proto | 4 + 5 files changed, 454 insertions(+), 81 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index ba23efb166b56a..e9b15045f1d65d 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -1145,6 +1145,7 @@ impl GitRepository for FakeGitRepository { fn diff_stat( &self, + diff_stat_type: git::repository::DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result> { fn count_lines(s: &str) -> u32 { @@ -1168,46 +1169,24 @@ impl GitRepository for FakeGitRepository { }) } - let path_prefixes = path_prefixes.to_vec(); - - let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); - let worktree_files: HashMap = self - .fs - .files() - .iter() - .filter_map(|path| { - let repo_path = path.strip_prefix(&workdir_path).ok()?; - if repo_path.starts_with(".git") { - return None; - } - let content = self - .fs - .read_file_sync(path) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok())?; - let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; - Some((RepoPath::from_rel_path(&repo_path), content)) - }) - .collect(); - - self.with_state_async(false, move |state| { - let mut entries = Vec::new(); - let all_paths: HashSet<&RepoPath> = state - .head_contents + fn diff_entries( + old_contents_by_path: &HashMap, + new_contents_by_path: &HashMap, + path_prefixes: &[RepoPath], + ) -> Vec<(RepoPath, git::status::DiffStat)> { + let all_paths: HashSet<&RepoPath> = old_contents_by_path .keys() - .chain( - worktree_files - .keys() - .filter(|p| state.index_contents.contains_key(*p)), - ) + .chain(new_contents_by_path.keys()) .collect(); + let mut entries = Vec::new(); for path in all_paths { - if !matches_prefixes(path, &path_prefixes) { + if !matches_prefixes(path, path_prefixes) { continue; } - let head = state.head_contents.get(path); - let worktree = worktree_files.get(path); - match (head, worktree) { + + let old = old_contents_by_path.get(path); + let new = new_contents_by_path.get(path); + match (old, new) { (Some(old), Some(new)) if old != new => { entries.push(( path.clone(), @@ -1239,6 +1218,48 @@ impl GitRepository for FakeGitRepository { } } entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + entries + } + + let path_prefixes = path_prefixes.to_vec(); + + let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); + let worktree_files: HashMap = self + .fs + .files() + .iter() + .filter_map(|path| { + let repo_path = path.strip_prefix(&workdir_path).ok()?; + if repo_path.starts_with(".git") { + return None; + } + let content = self + .fs + .read_file_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok())?; + let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; + Some((RepoPath::from_rel_path(&repo_path), content)) + }) + .collect(); + + self.with_state_async(false, move |state| { + let worktree_files = worktree_files + .iter() + .filter(|(path, _)| state.index_contents.contains_key(*path)) + .map(|(path, contents)| (path.clone(), contents.clone())) + .collect::>(); + let entries = match diff_stat_type { + git::repository::DiffStatType::HeadToWorktree => { + diff_entries(&state.head_contents, &worktree_files, &path_prefixes) + } + git::repository::DiffStatType::HeadToIndex => { + diff_entries(&state.head_contents, &state.index_contents, &path_prefixes) + } + git::repository::DiffStatType::IndexToWorktree => { + diff_entries(&state.index_contents, &worktree_files, &path_prefixes) + } + }; Ok(git::status::GitDiffStat { entries: entries.into(), }) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 6c88ee4e688336..50e91032a85dd4 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1051,6 +1051,7 @@ pub trait GitRepository: Send + Sync { fn diff_stat( &self, + diff_stat_type: DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result>; @@ -1135,6 +1136,13 @@ pub enum DiffType { MergeBase { base_ref: SharedString }, } +#[derive(Clone, Copy)] +pub enum DiffStatType { + HeadToWorktree, + HeadToIndex, + IndexToWorktree, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] pub enum PushOptions { SetUpstream, @@ -2293,6 +2301,7 @@ impl GitRepository for RealGitRepository { fn diff_stat( &self, + diff_stat_type: DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result> { let path_prefixes = path_prefixes.to_vec(); @@ -2301,12 +2310,13 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let git_binary = git_binary?; - let mut args: Vec = vec![ - "diff".into(), - "--numstat".into(), - "--no-renames".into(), - "HEAD".into(), - ]; + let mut args: Vec = + vec!["diff".into(), "--numstat".into(), "--no-renames".into()]; + match diff_stat_type { + DiffStatType::HeadToWorktree => args.push("HEAD".into()), + DiffStatType::HeadToIndex => args.push("--cached".into()), + DiffStatType::IndexToWorktree => {} + } if !path_prefixes.is_empty() { args.push("--".into()); args.extend( diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8b4201f19957d4..5b16cdb35d8fac 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4733,6 +4733,22 @@ impl GitPanel { } } + fn diff_stat_for_entry_in_section( + entry: &GitStatusEntry, + section: Option
, + repo: &Repository, + ) -> Option { + match section { + Some(Section::Staged) => repo + .staged_diff_stat_for_path(&entry.repo_path) + .or(entry.diff_stat), + Some(Section::Unstaged) => repo + .unstaged_diff_stat_for_path(&entry.repo_path) + .or(entry.diff_stat), + _ => entry.diff_stat, + } + } + fn staging_action_button( id: ElementId, icon: IconName, @@ -7015,6 +7031,7 @@ impl GitPanel { && section == Some(Section::Conflict) && status.is_conflicted(); let staging_action = self.staging_action_for_entry_index(ix); + let diff_stat = Self::diff_stat_for_entry_in_section(entry, section, repo); let mut is_staged: ToggleState = match stage_status { StageStatus::Staged => ToggleState::Selected, StageStatus::Unstaged => ToggleState::Unselected, @@ -7112,7 +7129,7 @@ impl GitPanel { .active(|s| s.bg(active_bg)) .child(name_row) .when(GitPanelSettings::get_global(cx).diff_stats, |el| { - el.when_some(entry.diff_stat, move |this, stat| { + el.when_some(diff_stat, move |this, stat| { let id = format!("diff-stat-{}", id_for_diff_stat); this.child(ui::DiffStat::new( id, @@ -9554,6 +9571,130 @@ mod tests { }); } + #[gpui::test] + async fn test_group_by_staging_uses_section_diff_stats_for_partial_rows( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "partial.rs": "worktree\nline 2\nline 3\n", + }), + ) + .await; + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("partial.rs", "head\n".to_string())], + ); + fs.set_index_for_repo( + path!("/project/.git").as_ref(), + &[("partial.rs", "index\nline 2\n".to_string())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + 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_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&mut cx, |panel, cx| { + let repo = panel.active_repository.as_ref().unwrap().read(cx); + let partial_path = repo_path("partial.rs"); + let projections = panel + .projected_entries_by_path + .get(&partial_path) + .expect("partially staged entry should have projections"); + let staged_projection = projections + .iter() + .find(|projection| projection.section == Section::Staged) + .expect("partial file should have a staged projection"); + let unstaged_projection = projections + .iter() + .find(|projection| projection.section == Section::Unstaged) + .expect("partial file should have an unstaged projection"); + + let staged_entry = panel + .entries + .get(staged_projection.index) + .and_then(GitListEntry::status_entry) + .expect("staged projection should be a status entry"); + let unstaged_entry = panel + .entries + .get(unstaged_projection.index) + .and_then(GitListEntry::status_entry) + .expect("unstaged projection should be a status entry"); + + assert_eq!( + staged_entry.diff_stat, + Some(DiffStat { + added: 3, + deleted: 1, + }) + ); + assert_eq!( + panel.diff_stat_total, + DiffStat { + added: 3, + deleted: 1, + } + ); + assert_eq!( + GitPanel::diff_stat_for_entry_in_section( + staged_entry, + Some(Section::Staged), + repo, + ), + Some(DiffStat { + added: 2, + deleted: 1, + }) + ); + assert_eq!( + GitPanel::diff_stat_for_entry_in_section( + unstaged_entry, + Some(Section::Unstaged), + repo, + ), + Some(DiffStat { + added: 3, + deleted: 2, + }) + ); + }); + } + #[gpui::test] async fn test_group_by_staging_open_diff_uses_section_diff(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 6bf3fb85005905..99eadf284d35d0 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -35,10 +35,11 @@ use git::{ parse_git_remote_url, repository::{ Branch, BranchesScanResult, CommitData, CommitDetails, CommitDiff, CommitFile, - CommitOptions, CreateWorktreeTarget, DiffType, FetchOptions, FileHistoryChangedFileSets, - GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, - LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, - SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, delete_branch_flag, + CommitOptions, CreateWorktreeTarget, DiffStatType, DiffType, FetchOptions, + FileHistoryChangedFileSets, GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, + InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, + RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, + delete_branch_flag, }, stash::{GitStash, StashEntry}, status::{ @@ -321,7 +322,11 @@ pub struct StatusEntry { } impl StatusEntry { - fn to_proto(&self) -> proto::StatusEntry { + fn to_proto( + &self, + staged_diff_stat: Option, + unstaged_diff_stat: Option, + ) -> proto::StatusEntry { let simple_status = match self.status { FileStatus::Ignored | FileStatus::Untracked => proto::GitStatus::Added as i32, FileStatus::Unmerged { .. } => proto::GitStatus::Conflict as i32, @@ -341,8 +346,34 @@ impl StatusEntry { status: Some(status_to_proto(self.status)), diff_stat_added: self.diff_stat.map(|ds| ds.added), diff_stat_deleted: self.diff_stat.map(|ds| ds.deleted), + staged_diff_stat_added: staged_diff_stat.map(|ds| ds.added), + staged_diff_stat_deleted: staged_diff_stat.map(|ds| ds.deleted), + unstaged_diff_stat_added: unstaged_diff_stat.map(|ds| ds.added), + unstaged_diff_stat_deleted: unstaged_diff_stat.map(|ds| ds.deleted), + } + } + + fn diff_stat_from_proto(added: Option, deleted: Option) -> Option { + match (added, deleted) { + (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), + _ => None, } } + + fn section_diff_stats_from_proto( + value: &proto::StatusEntry, + ) -> (Option, Option) { + ( + Self::diff_stat_from_proto( + value.staged_diff_stat_added, + value.staged_diff_stat_deleted, + ), + Self::diff_stat_from_proto( + value.unstaged_diff_stat_added, + value.unstaged_diff_stat_deleted, + ), + ) + } } impl TryFrom for StatusEntry { @@ -351,10 +382,8 @@ impl TryFrom for StatusEntry { fn try_from(value: proto::StatusEntry) -> Result { let repo_path = RepoPath::from_proto(&value.repo_path).context("invalid repo path")?; let status = status_from_proto(value.simple_status, value.status)?; - let diff_stat = match (value.diff_stat_added, value.diff_stat_deleted) { - (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), - _ => None, - }; + let diff_stat = + StatusEntry::diff_stat_from_proto(value.diff_stat_added, value.diff_stat_deleted); Ok(Self { repo_path, status, @@ -401,6 +430,8 @@ pub enum CommitDataState { pub struct RepositorySnapshot { pub id: RepositoryId, pub statuses_by_path: SumTree, + staged_diff_stats_by_path: HashMap, + unstaged_diff_stats_by_path: HashMap, pub work_directory_abs_path: Arc, pub dot_git_abs_path: Arc, /// Absolute path to the directory holding this worktree's Git state. @@ -5072,6 +5103,8 @@ impl RepositorySnapshot { Self { id, statuses_by_path: Default::default(), + staged_diff_stats_by_path: Default::default(), + unstaged_diff_stats_by_path: Default::default(), repository_dir_abs_path, dot_git_abs_path, common_dir_abs_path, @@ -5102,7 +5135,16 @@ impl RepositorySnapshot { updated_statuses: self .statuses_by_path .iter() - .map(|entry| entry.to_proto()) + .map(|entry| { + entry.to_proto( + self.staged_diff_stats_by_path + .get(&entry.repo_path) + .copied(), + self.unstaged_diff_stats_by_path + .get(&entry.repo_path) + .copied(), + ) + }) .collect(), removed_statuses: Default::default(), current_merge_conflicts: self @@ -5152,14 +5194,44 @@ impl RepositorySnapshot { (Some(new_entry), Some(old_entry)) => { match new_entry.repo_path.cmp(&old_entry.repo_path) { Ordering::Less => { - updated_statuses.push(new_entry.to_proto()); + updated_statuses.push( + new_entry.to_proto( + self.staged_diff_stats_by_path + .get(&new_entry.repo_path) + .copied(), + self.unstaged_diff_stats_by_path + .get(&new_entry.repo_path) + .copied(), + ), + ); current_new_entry = new_statuses.next(); } Ordering::Equal => { + let new_staged_diff_stat = self + .staged_diff_stats_by_path + .get(&new_entry.repo_path) + .copied(); + let new_unstaged_diff_stat = self + .unstaged_diff_stats_by_path + .get(&new_entry.repo_path) + .copied(); if new_entry.status != old_entry.status || new_entry.diff_stat != old_entry.diff_stat + || new_staged_diff_stat + != old + .staged_diff_stats_by_path + .get(&old_entry.repo_path) + .copied() + || new_unstaged_diff_stat + != old + .unstaged_diff_stats_by_path + .get(&old_entry.repo_path) + .copied() { - updated_statuses.push(new_entry.to_proto()); + updated_statuses.push( + new_entry + .to_proto(new_staged_diff_stat, new_unstaged_diff_stat), + ); } current_old_entry = old_statuses.next(); current_new_entry = new_statuses.next(); @@ -5175,7 +5247,16 @@ impl RepositorySnapshot { current_old_entry = old_statuses.next(); } (Some(new_entry), None) => { - updated_statuses.push(new_entry.to_proto()); + updated_statuses.push( + new_entry.to_proto( + self.staged_diff_stats_by_path + .get(&new_entry.repo_path) + .copied(), + self.unstaged_diff_stats_by_path + .get(&new_entry.repo_path) + .copied(), + ), + ); current_new_entry = new_statuses.next(); } (None, None) => break, @@ -5285,6 +5366,14 @@ impl RepositorySnapshot { .and_then(|entry| entry.diff_stat) } + pub fn staged_diff_stat_for_path(&self, path: &RepoPath) -> Option { + self.staged_diff_stats_by_path.get(path).copied() + } + + pub fn unstaged_diff_stat_for_path(&self, path: &RepoPath) -> Option { + self.unstaged_diff_stats_by_path.get(path).copied() + } + pub fn abs_path_to_repo_path(&self, abs_path: &Path) -> Option { Self::abs_path_to_repo_path_inner(&self.work_directory_abs_path, abs_path, self.path_style) } @@ -5894,6 +5983,14 @@ impl Repository { self.snapshot.diff_stat_for_path(path) } + pub fn staged_diff_stat_for_path(&self, path: &RepoPath) -> Option { + self.snapshot.staged_diff_stat_for_path(path) + } + + pub fn unstaged_diff_stat_for_path(&self, path: &RepoPath) -> Option { + self.snapshot.unstaged_diff_stat_for_path(path) + } + pub fn cached_stash(&self) -> GitStash { self.snapshot.stash_entries.clone() } @@ -8729,23 +8826,41 @@ impl Repository { self.snapshot.remote_upstream_url = update.remote_upstream_url; self.snapshot.remote_origin_url = update.remote_origin_url; - let edits = update - .removed_statuses - .into_iter() - .filter_map(|path| { - Some(sum_tree::Edit::Remove(PathKey( - RelPath::from_proto(&path).log_err()?, - ))) - }) - .chain( - update - .updated_statuses - .into_iter() - .filter_map(|updated_status| { - Some(sum_tree::Edit::Insert(updated_status.try_into().log_err()?)) - }), - ) - .collect::>(); + let mut edits = Vec::new(); + for path in update.removed_statuses { + let Some(repo_path) = RepoPath::from_proto(&path).log_err() else { + continue; + }; + self.snapshot.staged_diff_stats_by_path.remove(&repo_path); + self.snapshot.unstaged_diff_stats_by_path.remove(&repo_path); + edits.push(sum_tree::Edit::Remove(PathKey(repo_path.as_ref().clone()))); + } + for updated_status in update.updated_statuses { + let (staged_diff_stat, unstaged_diff_stat) = + StatusEntry::section_diff_stats_from_proto(&updated_status); + let Some(updated_status) = StatusEntry::try_from(updated_status).log_err() else { + continue; + }; + if let Some(staged_diff_stat) = staged_diff_stat { + self.snapshot + .staged_diff_stats_by_path + .insert(updated_status.repo_path.clone(), staged_diff_stat); + } else { + self.snapshot + .staged_diff_stats_by_path + .remove(&updated_status.repo_path); + } + if let Some(unstaged_diff_stat) = unstaged_diff_stat { + self.snapshot + .unstaged_diff_stats_by_path + .insert(updated_status.repo_path.clone(), unstaged_diff_stat); + } else { + self.snapshot + .unstaged_diff_stats_by_path + .remove(&updated_status.repo_path); + } + edits.push(sum_tree::Edit::Insert(updated_status)); + } if conflicts_changed || !edits.is_empty() { cx.emit(RepositoryEvent::StatusesChanged); } @@ -9119,7 +9234,7 @@ impl Repository { let has_head = prev_snapshot.head_commit.is_some(); - let changed_path_statuses = cx + let (changed_path_statuses, changed_paths, staged_diff_stats, unstaged_diff_stats) = cx .background_spawn(async move { let changed_paths = GitStore::coalesce_repo_paths( changed_paths @@ -9133,7 +9248,23 @@ impl Repository { let status_task = backend.status(&changed_paths_vec); let diff_stat_future = if has_head { - backend.diff_stat(&changed_paths_vec) + backend.diff_stat(DiffStatType::HeadToWorktree, &changed_paths_vec) + } else { + future::ready(Ok(status::GitDiffStat { + entries: Arc::default(), + })) + .boxed() + }; + let staged_diff_stat_future = if has_head { + backend.diff_stat(DiffStatType::HeadToIndex, &changed_paths_vec) + } else { + future::ready(Ok(status::GitDiffStat { + entries: Arc::default(), + })) + .boxed() + }; + let unstaged_diff_stat_future = if has_head { + backend.diff_stat(DiffStatType::IndexToWorktree, &changed_paths_vec) } else { future::ready(Ok(status::GitDiffStat { entries: Arc::default(), @@ -9141,14 +9272,26 @@ impl Repository { .boxed() }; - let (statuses, diff_stats) = - futures::future::try_join(status_task, diff_stat_future).await?; + let (statuses, diff_stats, staged_diff_stats, unstaged_diff_stats) = + futures::try_join!( + status_task, + diff_stat_future, + staged_diff_stat_future, + unstaged_diff_stat_future + )?; let diff_stats: HashMap = HashMap::from_iter(diff_stats.entries.into_iter().cloned()); + let staged_diff_stats: HashMap = + HashMap::from_iter(staged_diff_stats.entries.into_iter().cloned()); + let unstaged_diff_stats: HashMap = + HashMap::from_iter(unstaged_diff_stats.entries.into_iter().cloned()); let mut changed_path_statuses = Vec::new(); let prev_statuses = prev_snapshot.statuses_by_path.clone(); + let prev_staged_diff_stats = prev_snapshot.staged_diff_stats_by_path.clone(); + let prev_unstaged_diff_stats = + prev_snapshot.unstaged_diff_stats_by_path.clone(); let current_status_paths = statuses .entries .iter() @@ -9176,10 +9319,19 @@ impl Repository { for (repo_path, status) in &*statuses.entries { let current_diff_stat = diff_stats.get(repo_path).copied(); + let current_staged_diff_stat = + staged_diff_stats.get(repo_path).copied(); + let current_unstaged_diff_stat = + unstaged_diff_stats.get(repo_path).copied(); if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left) && cursor.item().is_some_and(|entry| { - entry.status == *status && entry.diff_stat == current_diff_stat + entry.status == *status + && entry.diff_stat == current_diff_stat + && prev_staged_diff_stats.get(repo_path).copied() + == current_staged_diff_stat + && prev_unstaged_diff_stats.get(repo_path).copied() + == current_unstaged_diff_stat }) { continue; @@ -9191,13 +9343,32 @@ impl Repository { diff_stat: current_diff_stat, })); } - anyhow::Ok(changed_path_statuses) + anyhow::Ok(( + changed_path_statuses, + changed_paths, + staged_diff_stats, + unstaged_diff_stats, + )) }) .await?; this.update(&mut cx, |this, cx| { if !changed_path_statuses.is_empty() { cx.emit(RepositoryEvent::StatusesChanged); + for path in &changed_paths { + this.snapshot + .staged_diff_stats_by_path + .retain(|repo_path, _| !repo_path.starts_with(path)); + this.snapshot + .unstaged_diff_stats_by_path + .retain(|repo_path, _| !repo_path.starts_with(path)); + } + this.snapshot + .staged_diff_stats_by_path + .extend(staged_diff_stats); + this.snapshot + .unstaged_diff_stats_by_path + .extend(unstaged_diff_stats); this.snapshot .statuses_by_path .edit(changed_path_statuses, ()); @@ -10528,7 +10699,17 @@ async fn compute_snapshot( let backend = backend.clone(); async move { if snapshot.head_commit.is_some() { - backend.diff_stat(&[]).await.log_err().unwrap_or_default() + let diff_stats = backend.diff_stat(DiffStatType::HeadToWorktree, &[]); + let staged_diff_stats = backend.diff_stat(DiffStatType::HeadToIndex, &[]); + let unstaged_diff_stats = backend.diff_stat(DiffStatType::IndexToWorktree, &[]); + let (diff_stats, staged_diff_stats, unstaged_diff_stats) = + futures::future::join3(diff_stats, staged_diff_stats, unstaged_diff_stats) + .await; + ( + diff_stats.log_err().unwrap_or_default(), + staged_diff_stats.log_err().unwrap_or_default(), + unstaged_diff_stats.log_err().unwrap_or_default(), + ) } else { Default::default() } @@ -10539,12 +10720,22 @@ async fn compute_snapshot( async move { backend.stash_entries().await.log_err().unwrap_or_default() } }; - let (statuses, diff_stats, stash_entries) = + let (statuses, (diff_stats, staged_diff_stats, unstaged_diff_stats), stash_entries) = futures::future::join3(statuses_future, diff_stat_future, stash_entries_future).await; log::debug!("fetched statuses, diff stats, stash entries"); let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); + let staged_diff_stats_by_path: HashMap = staged_diff_stats + .entries + .iter() + .map(|(path, stat)| (path.clone(), *stat)) + .collect(); + let unstaged_diff_stats_by_path: HashMap = unstaged_diff_stats + .entries + .iter() + .map(|(path, stat)| (path.clone(), *stat)) + .collect(); let mut conflicted_paths = Vec::new(); let statuses_by_path = SumTree::from_iter( statuses.entries.iter().map(|(repo_path, status)| { @@ -10573,7 +10764,11 @@ async fn compute_snapshot( log::debug!("new merge details: {merge_details:?}"); this.update(cx, |this, cx| { - if conflicts_changed || statuses_by_path != this.snapshot.statuses_by_path { + if conflicts_changed + || statuses_by_path != this.snapshot.statuses_by_path + || staged_diff_stats_by_path != this.snapshot.staged_diff_stats_by_path + || unstaged_diff_stats_by_path != this.snapshot.unstaged_diff_stats_by_path + { cx.emit(RepositoryEvent::StatusesChanged); } if stash_entries != this.snapshot.stash_entries { @@ -10583,6 +10778,8 @@ async fn compute_snapshot( this.snapshot.scan_id += 1; this.snapshot.merge = merge_details; this.snapshot.statuses_by_path = statuses_by_path; + this.snapshot.staged_diff_stats_by_path = staged_diff_stats_by_path; + this.snapshot.unstaged_diff_stats_by_path = unstaged_diff_stats_by_path; this.snapshot.stash_entries = stash_entries; this.snapshot.clone() diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 8d589f947cd90e..6824890a8670c6 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -330,6 +330,10 @@ message StatusEntry { GitFileStatus status = 3; optional uint32 diff_stat_added = 4; optional uint32 diff_stat_deleted = 5; + optional uint32 staged_diff_stat_added = 6; + optional uint32 staged_diff_stat_deleted = 7; + optional uint32 unstaged_diff_stat_added = 8; + optional uint32 unstaged_diff_stat_deleted = 9; } message StashEntry { From a6391745a257212f23f85cf666db02fe4de24ebf Mon Sep 17 00:00:00 2001 From: Sathwik Date: Thu, 9 Jul 2026 15:29:55 +0530 Subject: [PATCH 12/14] Initialize collab diff stat fields --- crates/collab/src/db.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index d8dad9fec13fa0..786f750cd0c700 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -732,6 +732,10 @@ fn db_status_to_proto( }), diff_stat_added: entry.lines_added.map(|v| v as u32), diff_stat_deleted: entry.lines_deleted.map(|v| v as u32), + staged_diff_stat_added: None, + staged_diff_stat_deleted: None, + unstaged_diff_stat_added: None, + unstaged_diff_stat_deleted: None, }) } From f6790134206dc9be7ae540d0e7598a5b61d93508 Mon Sep 17 00:00:00 2001 From: Christopher Biscardi Date: Thu, 9 Jul 2026 07:46:14 -0700 Subject: [PATCH 13/14] Revert "Initialize collab diff stat fields" This reverts commit a6391745a257212f23f85cf666db02fe4de24ebf. --- crates/collab/src/db.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 786f750cd0c700..d8dad9fec13fa0 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -732,10 +732,6 @@ fn db_status_to_proto( }), diff_stat_added: entry.lines_added.map(|v| v as u32), diff_stat_deleted: entry.lines_deleted.map(|v| v as u32), - staged_diff_stat_added: None, - staged_diff_stat_deleted: None, - unstaged_diff_stat_added: None, - unstaged_diff_stat_deleted: None, }) } From 511e18275a70f2fc4f44cdf180358118217e178b Mon Sep 17 00:00:00 2001 From: Christopher Biscardi Date: Thu, 9 Jul 2026 07:46:27 -0700 Subject: [PATCH 14/14] Revert "Add section specific diff stats for grouped by staging" This reverts commit 64624e1b5c053975e09e37d25d011cd80c283821. --- crates/fs/src/fake_git_repo.rs | 91 ++++------- crates/git/src/repository.rs | 22 +-- crates/git_ui/src/git_panel.rs | 143 +---------------- crates/project/src/git_store.rs | 275 +++++--------------------------- crates/proto/proto/git.proto | 4 - 5 files changed, 81 insertions(+), 454 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index e9b15045f1d65d..ba23efb166b56a 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -1145,7 +1145,6 @@ impl GitRepository for FakeGitRepository { fn diff_stat( &self, - diff_stat_type: git::repository::DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result> { fn count_lines(s: &str) -> u32 { @@ -1169,24 +1168,46 @@ impl GitRepository for FakeGitRepository { }) } - fn diff_entries( - old_contents_by_path: &HashMap, - new_contents_by_path: &HashMap, - path_prefixes: &[RepoPath], - ) -> Vec<(RepoPath, git::status::DiffStat)> { - let all_paths: HashSet<&RepoPath> = old_contents_by_path + let path_prefixes = path_prefixes.to_vec(); + + let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); + let worktree_files: HashMap = self + .fs + .files() + .iter() + .filter_map(|path| { + let repo_path = path.strip_prefix(&workdir_path).ok()?; + if repo_path.starts_with(".git") { + return None; + } + let content = self + .fs + .read_file_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok())?; + let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; + Some((RepoPath::from_rel_path(&repo_path), content)) + }) + .collect(); + + self.with_state_async(false, move |state| { + let mut entries = Vec::new(); + let all_paths: HashSet<&RepoPath> = state + .head_contents .keys() - .chain(new_contents_by_path.keys()) + .chain( + worktree_files + .keys() + .filter(|p| state.index_contents.contains_key(*p)), + ) .collect(); - let mut entries = Vec::new(); for path in all_paths { - if !matches_prefixes(path, path_prefixes) { + if !matches_prefixes(path, &path_prefixes) { continue; } - - let old = old_contents_by_path.get(path); - let new = new_contents_by_path.get(path); - match (old, new) { + let head = state.head_contents.get(path); + let worktree = worktree_files.get(path); + match (head, worktree) { (Some(old), Some(new)) if old != new => { entries.push(( path.clone(), @@ -1218,48 +1239,6 @@ impl GitRepository for FakeGitRepository { } } entries.sort_by(|(a, _), (b, _)| a.cmp(b)); - entries - } - - let path_prefixes = path_prefixes.to_vec(); - - let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); - let worktree_files: HashMap = self - .fs - .files() - .iter() - .filter_map(|path| { - let repo_path = path.strip_prefix(&workdir_path).ok()?; - if repo_path.starts_with(".git") { - return None; - } - let content = self - .fs - .read_file_sync(path) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok())?; - let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; - Some((RepoPath::from_rel_path(&repo_path), content)) - }) - .collect(); - - self.with_state_async(false, move |state| { - let worktree_files = worktree_files - .iter() - .filter(|(path, _)| state.index_contents.contains_key(*path)) - .map(|(path, contents)| (path.clone(), contents.clone())) - .collect::>(); - let entries = match diff_stat_type { - git::repository::DiffStatType::HeadToWorktree => { - diff_entries(&state.head_contents, &worktree_files, &path_prefixes) - } - git::repository::DiffStatType::HeadToIndex => { - diff_entries(&state.head_contents, &state.index_contents, &path_prefixes) - } - git::repository::DiffStatType::IndexToWorktree => { - diff_entries(&state.index_contents, &worktree_files, &path_prefixes) - } - }; Ok(git::status::GitDiffStat { entries: entries.into(), }) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 50e91032a85dd4..6c88ee4e688336 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1051,7 +1051,6 @@ pub trait GitRepository: Send + Sync { fn diff_stat( &self, - diff_stat_type: DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result>; @@ -1136,13 +1135,6 @@ pub enum DiffType { MergeBase { base_ref: SharedString }, } -#[derive(Clone, Copy)] -pub enum DiffStatType { - HeadToWorktree, - HeadToIndex, - IndexToWorktree, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] pub enum PushOptions { SetUpstream, @@ -2301,7 +2293,6 @@ impl GitRepository for RealGitRepository { fn diff_stat( &self, - diff_stat_type: DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result> { let path_prefixes = path_prefixes.to_vec(); @@ -2310,13 +2301,12 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let git_binary = git_binary?; - let mut args: Vec = - vec!["diff".into(), "--numstat".into(), "--no-renames".into()]; - match diff_stat_type { - DiffStatType::HeadToWorktree => args.push("HEAD".into()), - DiffStatType::HeadToIndex => args.push("--cached".into()), - DiffStatType::IndexToWorktree => {} - } + let mut args: Vec = vec![ + "diff".into(), + "--numstat".into(), + "--no-renames".into(), + "HEAD".into(), + ]; if !path_prefixes.is_empty() { args.push("--".into()); args.extend( diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 5b16cdb35d8fac..8b4201f19957d4 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4733,22 +4733,6 @@ impl GitPanel { } } - fn diff_stat_for_entry_in_section( - entry: &GitStatusEntry, - section: Option
, - repo: &Repository, - ) -> Option { - match section { - Some(Section::Staged) => repo - .staged_diff_stat_for_path(&entry.repo_path) - .or(entry.diff_stat), - Some(Section::Unstaged) => repo - .unstaged_diff_stat_for_path(&entry.repo_path) - .or(entry.diff_stat), - _ => entry.diff_stat, - } - } - fn staging_action_button( id: ElementId, icon: IconName, @@ -7031,7 +7015,6 @@ impl GitPanel { && section == Some(Section::Conflict) && status.is_conflicted(); let staging_action = self.staging_action_for_entry_index(ix); - let diff_stat = Self::diff_stat_for_entry_in_section(entry, section, repo); let mut is_staged: ToggleState = match stage_status { StageStatus::Staged => ToggleState::Selected, StageStatus::Unstaged => ToggleState::Unselected, @@ -7129,7 +7112,7 @@ impl GitPanel { .active(|s| s.bg(active_bg)) .child(name_row) .when(GitPanelSettings::get_global(cx).diff_stats, |el| { - el.when_some(diff_stat, move |this, stat| { + el.when_some(entry.diff_stat, move |this, stat| { let id = format!("diff-stat-{}", id_for_diff_stat); this.child(ui::DiffStat::new( id, @@ -9571,130 +9554,6 @@ mod tests { }); } - #[gpui::test] - async fn test_group_by_staging_uses_section_diff_stats_for_partial_rows( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "partial.rs": "worktree\nline 2\nline 3\n", - }), - ) - .await; - fs.set_head_and_index_for_repo( - path!("/project/.git").as_ref(), - &[("partial.rs", "head\n".to_string())], - ); - fs.set_index_for_repo( - path!("/project/.git").as_ref(), - &[("partial.rs", "index\nline 2\n".to_string())], - ); - - let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; - let window_handle = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = window_handle - .read_with(cx, |mw, _| mw.workspace().clone()) - .unwrap(); - let mut cx = VisualTestContext::from_window(window_handle.into(), cx); - - cx.update(|_window, cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().group_by = - Some(GitPanelGroupBy::Staging); - }) - }); - }); - - 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_in(&mut cx, GitPanel::new); - await_git_panel_entries(&panel, &mut cx).await; - - panel.read_with(&mut cx, |panel, cx| { - let repo = panel.active_repository.as_ref().unwrap().read(cx); - let partial_path = repo_path("partial.rs"); - let projections = panel - .projected_entries_by_path - .get(&partial_path) - .expect("partially staged entry should have projections"); - let staged_projection = projections - .iter() - .find(|projection| projection.section == Section::Staged) - .expect("partial file should have a staged projection"); - let unstaged_projection = projections - .iter() - .find(|projection| projection.section == Section::Unstaged) - .expect("partial file should have an unstaged projection"); - - let staged_entry = panel - .entries - .get(staged_projection.index) - .and_then(GitListEntry::status_entry) - .expect("staged projection should be a status entry"); - let unstaged_entry = panel - .entries - .get(unstaged_projection.index) - .and_then(GitListEntry::status_entry) - .expect("unstaged projection should be a status entry"); - - assert_eq!( - staged_entry.diff_stat, - Some(DiffStat { - added: 3, - deleted: 1, - }) - ); - assert_eq!( - panel.diff_stat_total, - DiffStat { - added: 3, - deleted: 1, - } - ); - assert_eq!( - GitPanel::diff_stat_for_entry_in_section( - staged_entry, - Some(Section::Staged), - repo, - ), - Some(DiffStat { - added: 2, - deleted: 1, - }) - ); - assert_eq!( - GitPanel::diff_stat_for_entry_in_section( - unstaged_entry, - Some(Section::Unstaged), - repo, - ), - Some(DiffStat { - added: 3, - deleted: 2, - }) - ); - }); - } - #[gpui::test] async fn test_group_by_staging_open_diff_uses_section_diff(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 99eadf284d35d0..6bf3fb85005905 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -35,11 +35,10 @@ use git::{ parse_git_remote_url, repository::{ Branch, BranchesScanResult, CommitData, CommitDetails, CommitDiff, CommitFile, - CommitOptions, CreateWorktreeTarget, DiffStatType, DiffType, FetchOptions, - FileHistoryChangedFileSets, GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, - InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, - RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, - delete_branch_flag, + CommitOptions, CreateWorktreeTarget, DiffType, FetchOptions, FileHistoryChangedFileSets, + GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, + LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, + SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, delete_branch_flag, }, stash::{GitStash, StashEntry}, status::{ @@ -322,11 +321,7 @@ pub struct StatusEntry { } impl StatusEntry { - fn to_proto( - &self, - staged_diff_stat: Option, - unstaged_diff_stat: Option, - ) -> proto::StatusEntry { + fn to_proto(&self) -> proto::StatusEntry { let simple_status = match self.status { FileStatus::Ignored | FileStatus::Untracked => proto::GitStatus::Added as i32, FileStatus::Unmerged { .. } => proto::GitStatus::Conflict as i32, @@ -346,34 +341,8 @@ impl StatusEntry { status: Some(status_to_proto(self.status)), diff_stat_added: self.diff_stat.map(|ds| ds.added), diff_stat_deleted: self.diff_stat.map(|ds| ds.deleted), - staged_diff_stat_added: staged_diff_stat.map(|ds| ds.added), - staged_diff_stat_deleted: staged_diff_stat.map(|ds| ds.deleted), - unstaged_diff_stat_added: unstaged_diff_stat.map(|ds| ds.added), - unstaged_diff_stat_deleted: unstaged_diff_stat.map(|ds| ds.deleted), - } - } - - fn diff_stat_from_proto(added: Option, deleted: Option) -> Option { - match (added, deleted) { - (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), - _ => None, } } - - fn section_diff_stats_from_proto( - value: &proto::StatusEntry, - ) -> (Option, Option) { - ( - Self::diff_stat_from_proto( - value.staged_diff_stat_added, - value.staged_diff_stat_deleted, - ), - Self::diff_stat_from_proto( - value.unstaged_diff_stat_added, - value.unstaged_diff_stat_deleted, - ), - ) - } } impl TryFrom for StatusEntry { @@ -382,8 +351,10 @@ impl TryFrom for StatusEntry { fn try_from(value: proto::StatusEntry) -> Result { let repo_path = RepoPath::from_proto(&value.repo_path).context("invalid repo path")?; let status = status_from_proto(value.simple_status, value.status)?; - let diff_stat = - StatusEntry::diff_stat_from_proto(value.diff_stat_added, value.diff_stat_deleted); + let diff_stat = match (value.diff_stat_added, value.diff_stat_deleted) { + (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), + _ => None, + }; Ok(Self { repo_path, status, @@ -430,8 +401,6 @@ pub enum CommitDataState { pub struct RepositorySnapshot { pub id: RepositoryId, pub statuses_by_path: SumTree, - staged_diff_stats_by_path: HashMap, - unstaged_diff_stats_by_path: HashMap, pub work_directory_abs_path: Arc, pub dot_git_abs_path: Arc, /// Absolute path to the directory holding this worktree's Git state. @@ -5103,8 +5072,6 @@ impl RepositorySnapshot { Self { id, statuses_by_path: Default::default(), - staged_diff_stats_by_path: Default::default(), - unstaged_diff_stats_by_path: Default::default(), repository_dir_abs_path, dot_git_abs_path, common_dir_abs_path, @@ -5135,16 +5102,7 @@ impl RepositorySnapshot { updated_statuses: self .statuses_by_path .iter() - .map(|entry| { - entry.to_proto( - self.staged_diff_stats_by_path - .get(&entry.repo_path) - .copied(), - self.unstaged_diff_stats_by_path - .get(&entry.repo_path) - .copied(), - ) - }) + .map(|entry| entry.to_proto()) .collect(), removed_statuses: Default::default(), current_merge_conflicts: self @@ -5194,44 +5152,14 @@ impl RepositorySnapshot { (Some(new_entry), Some(old_entry)) => { match new_entry.repo_path.cmp(&old_entry.repo_path) { Ordering::Less => { - updated_statuses.push( - new_entry.to_proto( - self.staged_diff_stats_by_path - .get(&new_entry.repo_path) - .copied(), - self.unstaged_diff_stats_by_path - .get(&new_entry.repo_path) - .copied(), - ), - ); + updated_statuses.push(new_entry.to_proto()); current_new_entry = new_statuses.next(); } Ordering::Equal => { - let new_staged_diff_stat = self - .staged_diff_stats_by_path - .get(&new_entry.repo_path) - .copied(); - let new_unstaged_diff_stat = self - .unstaged_diff_stats_by_path - .get(&new_entry.repo_path) - .copied(); if new_entry.status != old_entry.status || new_entry.diff_stat != old_entry.diff_stat - || new_staged_diff_stat - != old - .staged_diff_stats_by_path - .get(&old_entry.repo_path) - .copied() - || new_unstaged_diff_stat - != old - .unstaged_diff_stats_by_path - .get(&old_entry.repo_path) - .copied() { - updated_statuses.push( - new_entry - .to_proto(new_staged_diff_stat, new_unstaged_diff_stat), - ); + updated_statuses.push(new_entry.to_proto()); } current_old_entry = old_statuses.next(); current_new_entry = new_statuses.next(); @@ -5247,16 +5175,7 @@ impl RepositorySnapshot { current_old_entry = old_statuses.next(); } (Some(new_entry), None) => { - updated_statuses.push( - new_entry.to_proto( - self.staged_diff_stats_by_path - .get(&new_entry.repo_path) - .copied(), - self.unstaged_diff_stats_by_path - .get(&new_entry.repo_path) - .copied(), - ), - ); + updated_statuses.push(new_entry.to_proto()); current_new_entry = new_statuses.next(); } (None, None) => break, @@ -5366,14 +5285,6 @@ impl RepositorySnapshot { .and_then(|entry| entry.diff_stat) } - pub fn staged_diff_stat_for_path(&self, path: &RepoPath) -> Option { - self.staged_diff_stats_by_path.get(path).copied() - } - - pub fn unstaged_diff_stat_for_path(&self, path: &RepoPath) -> Option { - self.unstaged_diff_stats_by_path.get(path).copied() - } - pub fn abs_path_to_repo_path(&self, abs_path: &Path) -> Option { Self::abs_path_to_repo_path_inner(&self.work_directory_abs_path, abs_path, self.path_style) } @@ -5983,14 +5894,6 @@ impl Repository { self.snapshot.diff_stat_for_path(path) } - pub fn staged_diff_stat_for_path(&self, path: &RepoPath) -> Option { - self.snapshot.staged_diff_stat_for_path(path) - } - - pub fn unstaged_diff_stat_for_path(&self, path: &RepoPath) -> Option { - self.snapshot.unstaged_diff_stat_for_path(path) - } - pub fn cached_stash(&self) -> GitStash { self.snapshot.stash_entries.clone() } @@ -8826,41 +8729,23 @@ impl Repository { self.snapshot.remote_upstream_url = update.remote_upstream_url; self.snapshot.remote_origin_url = update.remote_origin_url; - let mut edits = Vec::new(); - for path in update.removed_statuses { - let Some(repo_path) = RepoPath::from_proto(&path).log_err() else { - continue; - }; - self.snapshot.staged_diff_stats_by_path.remove(&repo_path); - self.snapshot.unstaged_diff_stats_by_path.remove(&repo_path); - edits.push(sum_tree::Edit::Remove(PathKey(repo_path.as_ref().clone()))); - } - for updated_status in update.updated_statuses { - let (staged_diff_stat, unstaged_diff_stat) = - StatusEntry::section_diff_stats_from_proto(&updated_status); - let Some(updated_status) = StatusEntry::try_from(updated_status).log_err() else { - continue; - }; - if let Some(staged_diff_stat) = staged_diff_stat { - self.snapshot - .staged_diff_stats_by_path - .insert(updated_status.repo_path.clone(), staged_diff_stat); - } else { - self.snapshot - .staged_diff_stats_by_path - .remove(&updated_status.repo_path); - } - if let Some(unstaged_diff_stat) = unstaged_diff_stat { - self.snapshot - .unstaged_diff_stats_by_path - .insert(updated_status.repo_path.clone(), unstaged_diff_stat); - } else { - self.snapshot - .unstaged_diff_stats_by_path - .remove(&updated_status.repo_path); - } - edits.push(sum_tree::Edit::Insert(updated_status)); - } + let edits = update + .removed_statuses + .into_iter() + .filter_map(|path| { + Some(sum_tree::Edit::Remove(PathKey( + RelPath::from_proto(&path).log_err()?, + ))) + }) + .chain( + update + .updated_statuses + .into_iter() + .filter_map(|updated_status| { + Some(sum_tree::Edit::Insert(updated_status.try_into().log_err()?)) + }), + ) + .collect::>(); if conflicts_changed || !edits.is_empty() { cx.emit(RepositoryEvent::StatusesChanged); } @@ -9234,7 +9119,7 @@ impl Repository { let has_head = prev_snapshot.head_commit.is_some(); - let (changed_path_statuses, changed_paths, staged_diff_stats, unstaged_diff_stats) = cx + let changed_path_statuses = cx .background_spawn(async move { let changed_paths = GitStore::coalesce_repo_paths( changed_paths @@ -9248,23 +9133,7 @@ impl Repository { let status_task = backend.status(&changed_paths_vec); let diff_stat_future = if has_head { - backend.diff_stat(DiffStatType::HeadToWorktree, &changed_paths_vec) - } else { - future::ready(Ok(status::GitDiffStat { - entries: Arc::default(), - })) - .boxed() - }; - let staged_diff_stat_future = if has_head { - backend.diff_stat(DiffStatType::HeadToIndex, &changed_paths_vec) - } else { - future::ready(Ok(status::GitDiffStat { - entries: Arc::default(), - })) - .boxed() - }; - let unstaged_diff_stat_future = if has_head { - backend.diff_stat(DiffStatType::IndexToWorktree, &changed_paths_vec) + backend.diff_stat(&changed_paths_vec) } else { future::ready(Ok(status::GitDiffStat { entries: Arc::default(), @@ -9272,26 +9141,14 @@ impl Repository { .boxed() }; - let (statuses, diff_stats, staged_diff_stats, unstaged_diff_stats) = - futures::try_join!( - status_task, - diff_stat_future, - staged_diff_stat_future, - unstaged_diff_stat_future - )?; + let (statuses, diff_stats) = + futures::future::try_join(status_task, diff_stat_future).await?; let diff_stats: HashMap = HashMap::from_iter(diff_stats.entries.into_iter().cloned()); - let staged_diff_stats: HashMap = - HashMap::from_iter(staged_diff_stats.entries.into_iter().cloned()); - let unstaged_diff_stats: HashMap = - HashMap::from_iter(unstaged_diff_stats.entries.into_iter().cloned()); let mut changed_path_statuses = Vec::new(); let prev_statuses = prev_snapshot.statuses_by_path.clone(); - let prev_staged_diff_stats = prev_snapshot.staged_diff_stats_by_path.clone(); - let prev_unstaged_diff_stats = - prev_snapshot.unstaged_diff_stats_by_path.clone(); let current_status_paths = statuses .entries .iter() @@ -9319,19 +9176,10 @@ impl Repository { for (repo_path, status) in &*statuses.entries { let current_diff_stat = diff_stats.get(repo_path).copied(); - let current_staged_diff_stat = - staged_diff_stats.get(repo_path).copied(); - let current_unstaged_diff_stat = - unstaged_diff_stats.get(repo_path).copied(); if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left) && cursor.item().is_some_and(|entry| { - entry.status == *status - && entry.diff_stat == current_diff_stat - && prev_staged_diff_stats.get(repo_path).copied() - == current_staged_diff_stat - && prev_unstaged_diff_stats.get(repo_path).copied() - == current_unstaged_diff_stat + entry.status == *status && entry.diff_stat == current_diff_stat }) { continue; @@ -9343,32 +9191,13 @@ impl Repository { diff_stat: current_diff_stat, })); } - anyhow::Ok(( - changed_path_statuses, - changed_paths, - staged_diff_stats, - unstaged_diff_stats, - )) + anyhow::Ok(changed_path_statuses) }) .await?; this.update(&mut cx, |this, cx| { if !changed_path_statuses.is_empty() { cx.emit(RepositoryEvent::StatusesChanged); - for path in &changed_paths { - this.snapshot - .staged_diff_stats_by_path - .retain(|repo_path, _| !repo_path.starts_with(path)); - this.snapshot - .unstaged_diff_stats_by_path - .retain(|repo_path, _| !repo_path.starts_with(path)); - } - this.snapshot - .staged_diff_stats_by_path - .extend(staged_diff_stats); - this.snapshot - .unstaged_diff_stats_by_path - .extend(unstaged_diff_stats); this.snapshot .statuses_by_path .edit(changed_path_statuses, ()); @@ -10699,17 +10528,7 @@ async fn compute_snapshot( let backend = backend.clone(); async move { if snapshot.head_commit.is_some() { - let diff_stats = backend.diff_stat(DiffStatType::HeadToWorktree, &[]); - let staged_diff_stats = backend.diff_stat(DiffStatType::HeadToIndex, &[]); - let unstaged_diff_stats = backend.diff_stat(DiffStatType::IndexToWorktree, &[]); - let (diff_stats, staged_diff_stats, unstaged_diff_stats) = - futures::future::join3(diff_stats, staged_diff_stats, unstaged_diff_stats) - .await; - ( - diff_stats.log_err().unwrap_or_default(), - staged_diff_stats.log_err().unwrap_or_default(), - unstaged_diff_stats.log_err().unwrap_or_default(), - ) + backend.diff_stat(&[]).await.log_err().unwrap_or_default() } else { Default::default() } @@ -10720,22 +10539,12 @@ async fn compute_snapshot( async move { backend.stash_entries().await.log_err().unwrap_or_default() } }; - let (statuses, (diff_stats, staged_diff_stats, unstaged_diff_stats), stash_entries) = + let (statuses, diff_stats, stash_entries) = futures::future::join3(statuses_future, diff_stat_future, stash_entries_future).await; log::debug!("fetched statuses, diff stats, stash entries"); let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); - let staged_diff_stats_by_path: HashMap = staged_diff_stats - .entries - .iter() - .map(|(path, stat)| (path.clone(), *stat)) - .collect(); - let unstaged_diff_stats_by_path: HashMap = unstaged_diff_stats - .entries - .iter() - .map(|(path, stat)| (path.clone(), *stat)) - .collect(); let mut conflicted_paths = Vec::new(); let statuses_by_path = SumTree::from_iter( statuses.entries.iter().map(|(repo_path, status)| { @@ -10764,11 +10573,7 @@ async fn compute_snapshot( log::debug!("new merge details: {merge_details:?}"); this.update(cx, |this, cx| { - if conflicts_changed - || statuses_by_path != this.snapshot.statuses_by_path - || staged_diff_stats_by_path != this.snapshot.staged_diff_stats_by_path - || unstaged_diff_stats_by_path != this.snapshot.unstaged_diff_stats_by_path - { + if conflicts_changed || statuses_by_path != this.snapshot.statuses_by_path { cx.emit(RepositoryEvent::StatusesChanged); } if stash_entries != this.snapshot.stash_entries { @@ -10778,8 +10583,6 @@ async fn compute_snapshot( this.snapshot.scan_id += 1; this.snapshot.merge = merge_details; this.snapshot.statuses_by_path = statuses_by_path; - this.snapshot.staged_diff_stats_by_path = staged_diff_stats_by_path; - this.snapshot.unstaged_diff_stats_by_path = unstaged_diff_stats_by_path; this.snapshot.stash_entries = stash_entries; this.snapshot.clone() diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 6824890a8670c6..8d589f947cd90e 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -330,10 +330,6 @@ message StatusEntry { GitFileStatus status = 3; optional uint32 diff_stat_added = 4; optional uint32 diff_stat_deleted = 5; - optional uint32 staged_diff_stat_added = 6; - optional uint32 staged_diff_stat_deleted = 7; - optional uint32 unstaged_diff_stat_added = 8; - optional uint32 unstaged_diff_stat_deleted = 9; } message StashEntry {