git: Fix partially-staged paths not being accurately rendered (#44837)

Updates #44089 

- Restores the ability to have a partially staged/`Indeterminate` status
for the git panel checkboxes
- Removes the `optimistic_staging` logic, since its stated purpose is
served by the `PendingOps` system in the `GitStore` (which may have
bugs, but we should fix them in the git store rather than adding another
layer)

Release Notes:

- Fixed partially-staged files not being represented accurately in the
git panel.

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
This commit is contained in:
Cole Miller
2025-12-15 18:40:39 -05:00
committed by GitHub
parent 0ead4668d2
commit 870159e7e8

View File

@@ -319,9 +319,7 @@ impl TreeViewState {
&mut self,
section: Section,
mut entries: Vec<GitStatusEntry>,
repo: &Repository,
seen_directories: &mut HashSet<TreeKey>,
optimistic_staging: &HashMap<RepoPath, bool>,
) -> Vec<(GitListEntry, bool)> {
if entries.is_empty() {
return Vec::new();
@@ -365,14 +363,7 @@ impl TreeViewState {
}
}
let (flattened, _) = self.flatten_tree(
&root,
section,
0,
repo,
seen_directories,
optimistic_staging,
);
let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories);
flattened
}
@@ -381,9 +372,7 @@ impl TreeViewState {
node: &TreeNode,
section: Section,
depth: usize,
repo: &Repository,
seen_directories: &mut HashSet<TreeKey>,
optimistic_staging: &HashMap<RepoPath, bool>,
) -> (Vec<(GitListEntry, bool)>, Vec<GitStatusEntry>) {
let mut all_statuses = Vec::new();
let mut flattened = Vec::new();
@@ -393,26 +382,13 @@ impl TreeViewState {
let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else {
continue;
};
let (child_flattened, mut child_statuses) = self.flatten_tree(
terminal,
section,
depth + 1,
repo,
seen_directories,
optimistic_staging,
);
let (child_flattened, mut child_statuses) =
self.flatten_tree(terminal, section, depth + 1, seen_directories);
let key = TreeKey { section, path };
let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true);
self.expanded_dirs.entry(key.clone()).or_insert(true);
seen_directories.insert(key.clone());
let staged_count = child_statuses
.iter()
.filter(|entry| Self::is_entry_staged(entry, repo, optimistic_staging))
.count();
let staged_state =
GitPanel::toggle_state_for_counts(staged_count, child_statuses.len());
self.directory_descendants
.insert(key.clone(), child_statuses.clone());
@@ -421,7 +397,6 @@ impl TreeViewState {
key,
name,
depth,
staged_state,
expanded,
}),
true,
@@ -465,23 +440,6 @@ impl TreeViewState {
let name = parts.join("/");
(node, SharedString::from(name))
}
fn is_entry_staged(
entry: &GitStatusEntry,
repo: &Repository,
optimistic_staging: &HashMap<RepoPath, bool>,
) -> bool {
if let Some(optimistic) = optimistic_staging.get(&entry.repo_path) {
return *optimistic;
}
repo.pending_ops_for_path(&entry.repo_path)
.map(|ops| ops.staging() || ops.staged())
.or_else(|| {
repo.status_for_path(&entry.repo_path)
.and_then(|status| status.status.staging().as_bool())
})
.unwrap_or(entry.staging.has_staged())
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
@@ -501,7 +459,7 @@ struct GitTreeDirEntry {
key: TreeKey,
name: SharedString,
depth: usize,
staged_state: ToggleState,
// staged_state: ToggleState,
expanded: bool,
}
@@ -638,7 +596,6 @@ pub struct GitPanel {
local_committer_task: Option<Task<()>>,
bulk_staging: Option<BulkStaging>,
stash_entries: GitStash,
optimistic_staging: HashMap<RepoPath, bool>,
_settings_subscription: Subscription,
}
@@ -808,7 +765,6 @@ impl GitPanel {
entry_count: 0,
bulk_staging: None,
stash_entries: Default::default(),
optimistic_staging: HashMap::default(),
_settings_subscription,
};
@@ -1555,7 +1511,7 @@ impl GitPanel {
.detach();
}
fn is_entry_staged(&self, entry: &GitStatusEntry, repo: &Repository) -> bool {
fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus {
// Checking for current staged/unstaged file status is a chained operation:
// 1. first, we check for any pending operation recorded in repository
// 2. if there are no pending ops either running or finished, we then ask the repository
@@ -1564,25 +1520,59 @@ impl GitPanel {
// the checkbox's state (or flickering) which is undesirable.
// 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded
// in `entry` arg.
if let Some(optimistic) = self.optimistic_staging.get(&entry.repo_path) {
return *optimistic;
}
repo.pending_ops_for_path(&entry.repo_path)
.map(|ops| ops.staging() || ops.staged())
.map(|ops| {
if ops.staging() || ops.staged() {
StageStatus::Staged
} else {
StageStatus::Unstaged
}
})
.or_else(|| {
repo.status_for_path(&entry.repo_path)
.and_then(|status| status.status.staging().as_bool())
.map(|status| status.status.staging())
})
.unwrap_or(entry.staging.has_staged())
.unwrap_or(entry.staging)
}
fn toggle_state_for_counts(staged_count: usize, total: usize) -> ToggleState {
if staged_count == 0 || total == 0 {
ToggleState::Unselected
} else if staged_count == total {
ToggleState::Selected
fn stage_status_for_directory(
&self,
entry: &GitTreeDirEntry,
repo: &Repository,
) -> StageStatus {
let GitPanelViewMode::Tree(tree_state) = &self.view_mode else {
util::debug_panic!("We should never render a directory entry while in flat view mode");
return StageStatus::Unstaged;
};
let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else {
return StageStatus::Unstaged;
};
let mut fully_staged_count = 0usize;
let mut any_staged_or_partially_staged = false;
for descendant in descendants {
match GitPanel::stage_status_for_entry(descendant, repo) {
StageStatus::Staged => {
fully_staged_count += 1;
any_staged_or_partially_staged = true;
}
StageStatus::PartiallyStaged => {
any_staged_or_partially_staged = true;
}
StageStatus::Unstaged => {}
}
}
if descendants.is_empty() {
StageStatus::Unstaged
} else if fully_staged_count == descendants.len() {
StageStatus::Staged
} else if any_staged_or_partially_staged {
StageStatus::PartiallyStaged
} else {
ToggleState::Indeterminate
StageStatus::Unstaged
}
}
@@ -1611,31 +1601,37 @@ impl GitPanel {
match entry {
GitListEntry::Status(status_entry) => {
let repo_paths = vec![status_entry.clone()];
let stage = if self.is_entry_staged(status_entry, &repo) {
if let Some(op) = self.bulk_staging.clone()
&& op.anchor == status_entry.repo_path
{
clear_anchor = Some(op.anchor);
let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) {
StageStatus::Staged => {
if let Some(op) = self.bulk_staging.clone()
&& op.anchor == status_entry.repo_path
{
clear_anchor = Some(op.anchor);
}
false
}
StageStatus::Unstaged | StageStatus::PartiallyStaged => {
set_anchor = Some(status_entry.repo_path.clone());
true
}
false
} else {
set_anchor = Some(status_entry.repo_path.clone());
true
};
(stage, repo_paths)
}
GitListEntry::TreeStatus(status_entry) => {
let repo_paths = vec![status_entry.entry.clone()];
let stage = if self.is_entry_staged(&status_entry.entry, &repo) {
if let Some(op) = self.bulk_staging.clone()
&& op.anchor == status_entry.entry.repo_path
{
clear_anchor = Some(op.anchor);
let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) {
StageStatus::Staged => {
if let Some(op) = self.bulk_staging.clone()
&& op.anchor == status_entry.entry.repo_path
{
clear_anchor = Some(op.anchor);
}
false
}
StageStatus::Unstaged | StageStatus::PartiallyStaged => {
set_anchor = Some(status_entry.entry.repo_path.clone());
true
}
false
} else {
set_anchor = Some(status_entry.entry.repo_path.clone());
true
};
(stage, repo_paths)
}
@@ -1647,7 +1643,8 @@ impl GitPanel {
.filter_map(|entry| entry.status_entry())
.filter(|status_entry| {
section.contains(status_entry, &repo)
&& status_entry.staging.as_bool() != Some(goal_staged_state)
&& GitPanel::stage_status_for_entry(status_entry, &repo).as_bool()
!= Some(goal_staged_state)
})
.cloned()
.collect::<Vec<_>>();
@@ -1655,7 +1652,12 @@ impl GitPanel {
(goal_staged_state, entries)
}
GitListEntry::Directory(entry) => {
let goal_staged_state = entry.staged_state != ToggleState::Selected;
let goal_staged_state = 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;
let entries = self
.view_mode
.tree_state()
@@ -1664,10 +1666,11 @@ impl GitPanel {
.unwrap_or_default()
.into_iter()
.filter(|status_entry| {
self.is_entry_staged(status_entry, &repo) != goal_staged_state
GitPanel::stage_status_for_entry(status_entry, &repo)
!= goal_staged_state
})
.collect::<Vec<_>>();
(goal_staged_state, entries)
(goal_stage, entries)
}
}
};
@@ -1682,10 +1685,6 @@ impl GitPanel {
self.set_bulk_staging_anchor(anchor, cx);
}
let repo = active_repository.read(cx);
self.apply_optimistic_stage(&repo_paths, stage, &repo);
cx.notify();
self.change_file_stage(stage, repo_paths, cx);
}
@@ -1730,81 +1729,6 @@ impl GitPanel {
.detach();
}
fn apply_optimistic_stage(
&mut self,
entries: &[GitStatusEntry],
stage: bool,
repo: &Repository,
) {
// This “optimistic” pass keeps all checkboxes—files, folders, and section headers—visually in sync the moment you click,
// even though `change_file_stage` is still talking to the repository in the background.
// Before, the UI would wait for Git, causing checkbox flicker or stale parent states;
// Now, users see instant feedback and accurate parent/child tri-states while the async staging operation completes.
//
// Description:
// It records the desired state in `self.optimistic_staging` (a map from path → bool),
// walks the rendered entries, and swaps their `staging` flags based on that map.
// In tree view it also recomputes every directorys tri-state checkbox using the updated child data,
// so parent folders flip between selected/indeterminate/empty in the same frame.
let new_stage = if stage {
StageStatus::Staged
} else {
StageStatus::Unstaged
};
self.optimistic_staging
.extend(entries.iter().map(|entry| (entry.repo_path.clone(), stage)));
let staged_states: HashMap<TreeKey, ToggleState> = self
.view_mode
.tree_state()
.map(|state| state.directory_descendants.iter())
.into_iter()
.flatten()
.map(|(key, descendants)| {
let staged_count = descendants
.iter()
.filter(|entry| self.is_entry_staged(entry, repo))
.count();
(
key.clone(),
Self::toggle_state_for_counts(staged_count, descendants.len()),
)
})
.collect();
for list_entry in &mut self.entries {
match list_entry {
GitListEntry::Status(status) => {
if self
.optimistic_staging
.get(&status.repo_path)
.is_some_and(|s| *s == stage)
{
status.staging = new_stage;
}
}
GitListEntry::TreeStatus(status) => {
if self
.optimistic_staging
.get(&status.entry.repo_path)
.is_some_and(|s| *s == stage)
{
status.entry.staging = new_stage;
}
}
GitListEntry::Directory(dir) => {
if let Some(state) = staged_states.get(&dir.key) {
dir.staged_state = *state;
}
}
_ => {}
}
}
self.update_counts(repo);
}
pub fn total_staged_count(&self) -> usize {
self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
}
@@ -3394,13 +3318,9 @@ impl GitPanel {
Some(&mut tree_state.logical_indices),
);
for (entry, is_visible) in tree_state.build_tree_entries(
section,
entries,
&repo,
&mut seen_directories,
&self.optimistic_staging,
) {
for (entry, is_visible) in
tree_state.build_tree_entries(section, entries, &mut seen_directories)
{
push_entry(
self,
entry,
@@ -3440,13 +3360,6 @@ impl GitPanel {
self.max_width_item_index = max_width_item_index;
self.update_counts(repo);
let visible_paths: HashSet<RepoPath> = self
.entries
.iter()
.filter_map(|entry| entry.status_entry().map(|e| e.repo_path.clone()))
.collect();
self.optimistic_staging
.retain(|path, _| visible_paths.contains(path));
let bulk_staging_anchor_new_index = bulk_staging
.as_ref()
@@ -3456,7 +3369,9 @@ impl GitPanel {
&& let Some(index) = bulk_staging_anchor_new_index
&& let Some(entry) = self.entries.get(index)
&& let Some(entry) = entry.status_entry()
&& self.is_entry_staged(entry, &repo)
&& GitPanel::stage_status_for_entry(entry, &repo)
.as_bool()
.unwrap_or(false)
{
self.bulk_staging = bulk_staging;
}
@@ -3500,7 +3415,9 @@ impl GitPanel {
for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) {
self.entry_count += 1;
let is_staging_or_staged = self.is_entry_staged(status_entry, repo);
let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo)
.as_bool()
.unwrap_or(false);
if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
self.conflicted_count += 1;
@@ -4737,8 +4654,12 @@ impl GitPanel {
.active_repository(cx)
.expect("active repository must be set");
let repo = active_repo.read(cx);
let is_staging_or_staged = self.is_entry_staged(entry, &repo);
let mut is_staged: ToggleState = is_staging_or_staged.into();
let stage_status = GitPanel::stage_status_for_entry(entry, &repo);
let mut is_staged: ToggleState = match stage_status {
StageStatus::Staged => ToggleState::Selected,
StageStatus::Unstaged => ToggleState::Unselected,
StageStatus::PartiallyStaged => ToggleState::Indeterminate,
};
if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
is_staged = ToggleState::Selected;
}
@@ -4895,12 +4816,9 @@ impl GitPanel {
}
})
.tooltip(move |_window, cx| {
// If is_staging_or_staged is None, this implies the file was partially staged, and so
// we allow the user to stage it in full by displaying `Stage` in the tooltip.
let action = if is_staging_or_staged {
"Unstage"
} else {
"Stage"
let action = match stage_status {
StageStatus::Staged => "Unstage",
StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
};
let tooltip_name = action.to_string();
@@ -4960,7 +4878,21 @@ impl GitPanel {
} else {
IconName::Folder
};
let staged_state = entry.staged_state;
let stage_status = if let Some(repo) = &self.active_repository {
self.stage_status_for_directory(entry, repo.read(cx))
} else {
util::debug_panic!(
"Won't have entries to render without an active repository in Git Panel"
);
StageStatus::PartiallyStaged
};
let toggle_state: ToggleState = match stage_status {
StageStatus::Staged => ToggleState::Selected,
StageStatus::Unstaged => ToggleState::Unselected,
StageStatus::PartiallyStaged => ToggleState::Indeterminate,
};
let name_row = h_flex()
.items_center()
@@ -5006,7 +4938,7 @@ impl GitPanel {
.occlude()
.cursor_pointer()
.child(
Checkbox::new(checkbox_id, staged_state)
Checkbox::new(checkbox_id, toggle_state)
.disabled(!has_write_access)
.fill()
.elevation(ElevationIndex::Surface)
@@ -5029,10 +4961,9 @@ impl GitPanel {
}
})
.tooltip(move |_window, cx| {
let action = if staged_state.selected() {
"Unstage"
} else {
"Stage"
let action = match stage_status {
StageStatus::Staged => "Unstage",
StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
};
Tooltip::simple(format!("{action} folder"), cx)
}),