git_panel: Add better view options (#59043)

Self-Review Checklist:

- [x] I have reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Summary

This updates the Git Panel view controls so the panel can independently
model:

- whether entries are shown as a list or tree
- whether flat entries are sorted by path or name
- whether entries are grouped by status or shown as one combined set

It also keeps the Project Diff order consistent with the Git Panel,
including tree ordering, status grouping, and flat name/path sorting.

## Why This Is Useful

The previous sort_by_path boolean overloaded two separate ideas: sorting
and grouping. That made it hard to represent the view users actually
wanted, especially a single tree where tracked and untracked files are
not split into separate sections.

This change replaces that boolean with explicit enum settings:

- git_panel.sort_by: path or name
- git_panel.group_by: none or status

That keeps the settings mutually exclusive, easier to extend, and closer
to the UI model.

## Implementation Notes

- Adds a dedicated Git Panel View Options menu using the sliders icon.
- Moves view, sort, and group controls out of the overflow actions menu.
- Disables sort options in tree view because tree order is folder-first
rather than pure path/name sorting.
- Removes the Tracked heading when grouping is disabled.
- Keeps Git Panel tree expansion state when switching view options.
- Recomputes Project Diff sort prefixes from tree_view, sort_by, and
group_by so diff cards follow the same top-to-bottom order as the Git
Panel.
- Preserves Project Diff open/closed file state across view option
changes by carrying fold state by repo path instead of by synthetic sort
key.
- Updates settings UI renderers and docs for the new enum settings.

## Testing

- [x] cargo fmt --package git_ui --package settings_ui
- [x] cargo check -p git_ui
- [x] Verified settings UI enum dropdown rendering for Git Panel
sort/group settings

Closes https://github.com/zed-industries/zed/issues/53555
Closes https://github.com/zed-industries/zed/issues/56039
Closes https://github.com/zed-industries/zed/issues/45438

Release Notes:

- Improved Git Panel view options and Project Diff ordering.

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
This commit is contained in:
Sathwik ChirivelliandChristopher Biscardi authored and GitHub committed 2026-06-21 22:36:42 +00:00
1 parent b349620d65
commit fdfc80ca83
9 files changed
+652 -127

No files matched your search

+204 -37
View File
@@ -33,7 +33,7 @@ use project::{
branch_diff::{self, BranchDiffEvent, DiffBase},
},
};
use settings::{Settings, SettingsStore};
use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore};
use std::any::{Any, TypeId};
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -92,10 +92,6 @@ pub struct ProjectDiff {
_subscription: Subscription,
}
const CONFLICT_SORT_PREFIX: u64 = 1;
const TRACKED_SORT_PREFIX: u64 = 2;
const NEW_SORT_PREFIX: u64 = 3;
impl ProjectDiff {
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
workspace.register_action(Self::deploy);
@@ -575,14 +571,20 @@ impl ProjectDiff {
},
);
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by;
let mut was_group_by = GitPanelSettings::get_global(cx).group_by;
let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
let mut was_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let is_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
if is_sort_by_path != was_sort_by_path
let settings = GitPanelSettings::get_global(cx);
let sort_by = settings.sort_by;
let group_by = settings.group_by;
let tree_view = settings.tree_view;
let is_collapse_untracked_diff = settings.collapse_untracked_diff;
if sort_by != was_sort_by
|| group_by != was_group_by
|| tree_view != was_tree_view
|| is_collapse_untracked_diff != was_collapse_untracked_diff
{
this._task = {
@@ -592,7 +594,9 @@ impl ProjectDiff {
})
}
}
was_sort_by_path = is_sort_by_path;
was_sort_by = sort_by;
was_group_by = group_by;
was_tree_view = tree_view;
was_collapse_untracked_diff = is_collapse_untracked_diff;
})
.detach();
@@ -634,7 +638,7 @@ impl ProjectDiff {
return;
};
let repo = git_repo.read(cx);
let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
let sort_prefix = project_diff_sort_prefix(repo, &entry.repo_path, entry.status, cx);
let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
self.move_to_path(path_key, window, cx)
@@ -660,7 +664,7 @@ impl ProjectDiff {
.status_for_path(&repo_path)
.map(|entry| entry.status)
.unwrap_or(FileStatus::Untracked);
let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx);
let sort_prefix = project_diff_sort_prefix(&git_repo.read(cx), &repo_path, status, cx);
let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone());
self.move_to_path(path_key, window, cx)
}
@@ -817,7 +821,7 @@ impl ProjectDiff {
conflict_set: Entity<ConflictSet>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<BufferId> {
) -> (BufferId, bool) {
let diff_subscription = cx.subscribe_in(&diff, window, {
let path_key = path_key.clone();
let buffer = buffer.clone();
@@ -890,7 +894,8 @@ impl ProjectDiff {
}
};
let mut needs_fold = None;
let buffer_id = snapshot.text.remote_id();
let mut needs_fold = false;
let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
@@ -927,7 +932,7 @@ impl ProjectDiff {
|| (file_status.is_untracked()
&& GitPanelSettings::get_global(cx).collapse_untracked_diff))
{
needs_fold = Some(snapshot.text.remote_id());
needs_fold = true;
}
})
});
@@ -949,7 +954,7 @@ impl ProjectDiff {
self.move_to_path(path_key, window, cx);
}
needs_fold
(buffer_id, needs_fold)
}
fn buffer_ranges_changed(
@@ -990,14 +995,38 @@ impl ProjectDiff {
.buffers_with_paths()
.map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id()))
.collect::<HashMap<_, _>>();
let previous_folded_paths = {
let snapshot = this.multibuffer.read(cx).snapshot(cx);
let rhs_editor = this.editor.read(cx).rhs_editor().clone();
snapshot
.buffers_with_paths()
.map(|(buffer_snapshot, path_key)| {
(
path_key.path.clone(),
rhs_editor
.read(cx)
.is_buffer_folded(buffer_snapshot.remote_id(), cx),
)
})
.collect::<HashMap<_, _>>()
};
let mut entries = BTreeMap::new();
if let Some(repo) = repo {
let repo = repo.read(cx);
let sort_prefixes = project_diff_sort_prefixes(
&repo,
buffers_to_load
.iter()
.map(|diff_buffer| (&diff_buffer.repo_path, diff_buffer.file_status)),
cx,
);
for diff_buffer in buffers_to_load {
let sort_prefix =
sort_prefix(&repo, &diff_buffer.repo_path, diff_buffer.file_status, cx);
let sort_prefix = sort_prefixes
.get(&diff_buffer.repo_path)
.copied()
.unwrap_or(u64::MAX);
let path_key = PathKey::with_sort_prefix(
sort_prefix,
diff_buffer.repo_path.as_ref().clone(),
@@ -1019,10 +1048,12 @@ impl ProjectDiff {
}
});
entries
(entries, previous_folded_paths)
})?;
let (entries, previous_folded_paths) = entries;
let mut buffers_to_fold = Vec::new();
let mut buffers_to_unfold = Vec::new();
for (path_key, entry) in entries {
if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() {
@@ -1031,7 +1062,8 @@ impl ProjectDiff {
yield_now().await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
if let Some(buffer_id) = this.register_buffer(
let path = path_key.path.clone();
let (buffer_id, needs_fold) = this.register_buffer(
path_key,
entry.file_status,
buffer,
@@ -1039,7 +1071,14 @@ impl ProjectDiff {
conflict_set,
window,
cx,
) {
);
if let Some(was_folded) = previous_folded_paths.get(&path) {
if *was_folded {
buffers_to_fold.push(buffer_id);
} else {
buffers_to_unfold.push(buffer_id);
}
} else if needs_fold {
buffers_to_fold.push(buffer_id);
}
})
@@ -1055,6 +1094,15 @@ impl ProjectDiff {
.update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
});
}
if !buffers_to_unfold.is_empty() {
this.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
for buffer_id in buffers_to_unfold {
editor.unfold_buffer(buffer_id, cx);
}
});
});
}
this.pending_scroll.take();
cx.notify();
})?;
@@ -1085,17 +1133,125 @@ impl ProjectDiff {
}
}
fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
let settings = GitPanelSettings::get_global(cx);
fn project_diff_sort_prefix(
repo: &Repository,
repo_path: &RepoPath,
status: FileStatus,
cx: &App,
) -> u64 {
let mut entries = repo
.cached_status()
.map(|entry| (entry.repo_path.clone(), entry.status))
.collect::<Vec<_>>();
if !entries
.iter()
.any(|(entry_path, _status)| entry_path == repo_path)
{
entries.push((repo_path.clone(), status));
}
if settings.sort_by_path && !settings.tree_view {
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
project_diff_sort_prefixes(
repo,
entries.iter().map(|(path, status)| (path, *status)),
cx,
)
.get(repo_path)
.copied()
.unwrap_or(u64::MAX)
}
fn project_diff_sort_prefixes<'a>(
repo: &Repository,
entries: impl IntoIterator<Item = (&'a RepoPath, FileStatus)>,
cx: &App,
) -> HashMap<RepoPath, u64> {
let settings = GitPanelSettings::get_global(cx);
let group_by_status = settings.group_by == GitPanelGroupBy::Status;
let mut conflict_entries = Vec::new();
let mut tracked_entries = Vec::new();
let mut new_entries = Vec::new();
for (repo_path, status) in entries {
let entry = (repo_path.clone(), status);
if group_by_status && repo.had_conflict_on_last_merge_head_change(repo_path) {
conflict_entries.push(entry);
} else if group_by_status && status.is_created() {
new_entries.push(entry);
} else {
tracked_entries.push(entry);
}
}
let mut ordered_paths = Vec::new();
for mut section_entries in [conflict_entries, tracked_entries, new_entries] {
if settings.tree_view {
append_tree_order(&mut ordered_paths, section_entries);
} else {
sort_flat_entries(&mut section_entries, settings.sort_by);
ordered_paths.extend(
section_entries
.into_iter()
.map(|(repo_path, _status)| repo_path),
);
}
}
ordered_paths
.into_iter()
.enumerate()
.map(|(index, repo_path)| (repo_path, index as u64 + 1))
.collect()
}
fn sort_flat_entries(entries: &mut [(RepoPath, FileStatus)], sort_by: GitPanelSortBy) {
match sort_by {
GitPanelSortBy::Path => entries.sort_by(|(a_path, _), (b_path, _)| a_path.cmp(b_path)),
GitPanelSortBy::Name => entries.sort_by(|(a_path, _), (b_path, _)| {
a_path
.file_name()
.cmp(&b_path.file_name())
.then_with(|| a_path.cmp(b_path))
}),
}
}
fn append_tree_order(ordered_paths: &mut Vec<RepoPath>, mut entries: Vec<(RepoPath, FileStatus)>) {
entries.sort_by(|(a_path, _), (b_path, _)| a_path.cmp(b_path));
let mut root = ProjectDiffTreeNode::default();
for (repo_path, _status) in entries {
let components: Vec<&str> = repo_path.components().collect();
if components.is_empty() {
root.files.push(repo_path);
continue;
}
let mut current = &mut root;
for (index, component) in components.iter().enumerate() {
if index == components.len() - 1 {
current.files.push(repo_path.clone());
} else {
current = current.children.entry((*component).into()).or_default();
}
}
}
root.append_ordered_paths(ordered_paths);
}
#[derive(Default)]
struct ProjectDiffTreeNode {
children: BTreeMap<String, ProjectDiffTreeNode>,
files: Vec<RepoPath>,
}
impl ProjectDiffTreeNode {
fn append_ordered_paths(self, ordered_paths: &mut Vec<RepoPath>) {
for child in self.children.into_values() {
child.append_ordered_paths(ordered_paths);
}
ordered_paths.extend(self.files);
}
}
@@ -2119,7 +2275,7 @@ mod tests {
let editor = cx.update_window_entity(&diff, |diff, window, cx| {
diff.move_to_path(
PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
PathKey::with_sort_prefix(2, rel_path("foo").into_arc()),
window,
cx,
);
@@ -2140,7 +2296,7 @@ mod tests {
let editor = cx.update_window_entity(&diff, |diff, window, cx| {
diff.move_to_path(
PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
PathKey::with_sort_prefix(1, rel_path("bar").into_arc()),
window,
cx,
);
@@ -2667,7 +2823,15 @@ mod tests {
&editor,
cx,
&"
ˇnine
ˇone
two
three
four
five
six
seven
eight
nine
ten
- eleven
+ ELEVEN
@@ -2714,13 +2878,16 @@ mod tests {
&editor,
cx,
&"
one
ˇone
- two
+ TWO
three
four
five
ˇnine
six
seven
eight
nine
ten
- eleven
+ ELEVEN