feat: git panel's staged/unstaged view mode like vscode

This commit is contained in:
Mohamad Mohebifar 2025-08-20 17:10:21 -07:00
parent 02dabbb9fa
commit 1f006d2c88
3 changed files with 260 additions and 58 deletions

View file

@ -746,6 +746,7 @@
//
// Default: false
"collapse_untracked_diff": false,
"display_mode": "tracked_untracked",
"scrollbar": {
// When to show the scrollbar in the git panel.
//

View file

@ -7,7 +7,9 @@ use crate::project_diff::{self, Diff, ProjectDiff};
use crate::remote_output::{self, RemoteAction, SuccessMessage};
use crate::{branch_picker, picker_prompt, render_remote_button};
use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
git_panel_settings::{DisplayMode, GitPanelSettings},
git_status_icon,
repository_selector::RepositorySelector,
};
use agent_settings::AgentSettings;
use anyhow::Context as _;
@ -195,6 +197,8 @@ enum Section {
Conflict,
Tracked,
New,
Staged,
Unstaged,
}
#[derive(Debug, PartialEq, Eq, Clone)]
@ -212,6 +216,8 @@ impl GitHeaderEntry {
}
Section::Tracked => !status.is_created(),
Section::New => status.is_created(),
Section::Staged => status_entry.staging.has_staged(),
Section::Unstaged => status_entry.staging.has_unstaged(),
}
}
pub fn title(&self) -> &'static str {
@ -219,6 +225,8 @@ impl GitHeaderEntry {
Section::Conflict => "Conflicts",
Section::Tracked => "Tracked",
Section::New => "Untracked",
Section::Staged => "Staged",
Section::Unstaged => "Unstaged",
}
}
}
@ -650,44 +658,71 @@ impl GitPanel {
if GitPanelSettings::get_global(cx).sort_by_path {
return self
.entries
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
.binary_search_by(|entry| match entry.status_entry() {
Some(status) => status.repo_path.cmp(path),
None => std::cmp::Ordering::Greater, // Headers sort after any repo path
})
.ok();
}
if self.conflicted_count > 0 {
let conflicted_start = 1;
if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count]
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
{
return Some(conflicted_start + ix);
let settings = GitPanelSettings::get_global(cx);
match settings.display_mode {
DisplayMode::TrackedUntracked => {
if self.conflicted_count > 0 {
let conflicted_start = 1;
if let Ok(ix) = self.entries
[conflicted_start..conflicted_start + self.conflicted_count]
.binary_search_by(|entry| match entry.status_entry() {
Some(status) => status.repo_path.cmp(path),
None => std::cmp::Ordering::Greater,
})
{
return Some(conflicted_start + ix);
}
}
if self.tracked_count > 0 {
let tracked_start = if self.conflicted_count > 0 {
1 + self.conflicted_count
} else {
0
} + 1;
if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count]
.binary_search_by(|entry| match entry.status_entry() {
Some(status) => status.repo_path.cmp(path),
None => std::cmp::Ordering::Greater,
})
{
return Some(tracked_start + ix);
}
}
if self.new_count > 0 {
let untracked_start = if self.conflicted_count > 0 {
1 + self.conflicted_count
} else {
0
} + if self.tracked_count > 0 {
1 + self.tracked_count
} else {
0
} + 1;
if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count]
.binary_search_by(|entry| match entry.status_entry() {
Some(status) => status.repo_path.cmp(path),
None => std::cmp::Ordering::Greater,
})
{
return Some(untracked_start + ix);
}
}
}
}
if self.tracked_count > 0 {
let tracked_start = if self.conflicted_count > 0 {
1 + self.conflicted_count
} else {
0
} + 1;
if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count]
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
{
return Some(tracked_start + ix);
}
}
if self.new_count > 0 {
let untracked_start = if self.conflicted_count > 0 {
1 + self.conflicted_count
} else {
0
} + if self.tracked_count > 0 {
1 + self.tracked_count
} else {
0
} + 1;
if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count]
.binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
{
return Some(untracked_start + ix);
DisplayMode::StagedUnstaged => {
return self
.entries
.binary_search_by(|entry| match entry.status_entry() {
Some(status) => status.repo_path.cmp(path),
None => std::cmp::Ordering::Greater,
})
.ok();
}
}
None
@ -2668,11 +2703,15 @@ impl GitPanel {
self.tracked_staged_count = 0;
self.entry_count = 0;
let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let settings = GitPanelSettings::get_global(cx);
let sort_by_path = settings.sort_by_path;
let display_mode = settings.display_mode;
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 max_width_item: Option<(RepoPath, usize)> = None;
@ -2729,14 +2768,30 @@ impl GitPanel {
None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
}
if sort_by_path {
changed_entries.push(entry);
} else if is_conflict {
conflict_entries.push(entry);
} else if is_new {
new_entries.push(entry);
} else {
changed_entries.push(entry);
match display_mode {
DisplayMode::TrackedUntracked => {
if sort_by_path {
changed_entries.push(entry);
} else if is_conflict {
conflict_entries.push(entry);
} else if is_new {
new_entries.push(entry);
} else {
changed_entries.push(entry);
}
}
DisplayMode::StagedUnstaged => {
if is_conflict {
conflict_entries.push(entry);
} else {
if staging.has_staged() {
staged_entries.push(entry.clone());
}
if staging.has_unstaged() {
unstaged_entries.push(entry);
}
}
}
}
}
@ -2781,21 +2836,41 @@ impl GitPanel {
.extend(conflict_entries.into_iter().map(GitListEntry::Status));
}
if !changed_entries.is_empty() {
if !sort_by_path {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Tracked,
}));
match display_mode {
DisplayMode::TrackedUntracked => {
if !changed_entries.is_empty() {
if !sort_by_path {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Tracked,
}));
}
self.entries
.extend(changed_entries.into_iter().map(GitListEntry::Status));
}
if !new_entries.is_empty() {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::New,
}));
self.entries
.extend(new_entries.into_iter().map(GitListEntry::Status));
}
}
DisplayMode::StagedUnstaged => {
if !staged_entries.is_empty() {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Staged,
}));
self.entries
.extend(staged_entries.into_iter().map(GitListEntry::Status));
}
if !unstaged_entries.is_empty() {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::Unstaged,
}));
self.entries
.extend(unstaged_entries.into_iter().map(GitListEntry::Status));
}
}
self.entries
.extend(changed_entries.into_iter().map(GitListEntry::Status));
}
if !new_entries.is_empty() {
self.entries.push(GitListEntry::Header(GitHeaderEntry {
header: Section::New,
}));
self.entries
.extend(new_entries.into_iter().map(GitListEntry::Status));
}
if let Some((repo_path, _)) = max_width_item {
@ -2837,6 +2912,15 @@ 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 => {
let total_staged = self.tracked_staged_count + self.new_staged_count;
(total_staged, total_staged)
}
Section::Unstaged => {
let total_unstaged = (self.tracked_count - self.tracked_staged_count)
+ (self.new_count - self.new_staged_count);
(0, total_unstaged)
}
};
if staged_count == 0 {
ToggleState::Unselected
@ -5499,4 +5583,105 @@ mod tests {
],
);
}
#[gpui::test]
async fn test_staged_unstaged_display_mode(cx: &mut TestAppContext) {
use GitListEntry::*;
init_test(cx);
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<GitPanelSettings>(cx, |s| {
s.display_mode = Some(DisplayMode::StagedUnstaged);
});
});
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/root",
json!({
"project": {
".git": {},
"src": {
"staged_only.rs": "fn staged_only() {}",
"unstaged_only.rs": "fn unstaged_only() {}",
"both_staged_unstaged.rs": "fn both() {}"
},
"new_file.txt": "new content",
"conflict.txt": "conflicted content"
}
}),
)
.await;
fs.set_status_for_repo(
Path::new(path!("/root/project/.git")),
&[
(
Path::new("src/staged_only.rs"),
StatusCode::Modified.index(),
),
(
Path::new("src/unstaged_only.rs"),
StatusCode::Modified.worktree(),
),
(
Path::new("src/staged_only.rs"),
StatusCode::Modified.index(),
),
(Path::new("new_file.txt"), FileStatus::Untracked),
(
Path::new("conflict.txt"),
UnmergedStatus {
first_head: UnmergedStatusCode::Updated,
second_head: UnmergedStatusCode::Updated,
}
.into(),
),
],
);
let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
let workspace =
cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
cx.read(|cx| {
project
.read(cx)
.worktrees(cx)
.next()
.unwrap()
.read(cx)
.as_local()
.unwrap()
.scan_complete()
})
.await;
cx.executor().run_until_parked();
let panel = workspace.update(cx, GitPanel::new).unwrap();
let handle = cx.update_window_entity(&panel, |panel, _, _| {
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
});
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
handle.await;
let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
#[rustfmt::skip]
pretty_assertions::assert_matches!(
entries.as_slice(),
&[
Header(GitHeaderEntry { header: Section::Conflict }),
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
Header(GitHeaderEntry { header: Section::Staged }),
Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
Header(GitHeaderEntry { header: Section::Unstaged }),
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
],
);
}
}

View file

@ -36,6 +36,14 @@ pub enum StatusStyle {
LabelColor,
}
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DisplayMode {
#[default]
TrackedUntracked,
StagedUnstaged,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct GitPanelSettingsContent {
/// Whether to show the panel button in the status bar.
@ -75,6 +83,13 @@ pub struct GitPanelSettingsContent {
///
/// Default: false
pub collapse_untracked_diff: Option<bool>,
/// How to group files in the git panel.
/// - `tracked_untracked`: Groups files by tracked vs untracked status (default)
/// - `staged_unstaged`: Groups files by staged vs unstaged status (like VSCode)
///
/// Default: tracked_untracked
pub display_mode: Option<DisplayMode>,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
@ -87,6 +102,7 @@ pub struct GitPanelSettings {
pub fallback_branch_name: String,
pub sort_by_path: bool,
pub collapse_untracked_diff: bool,
pub display_mode: DisplayMode,
}
impl Settings for GitPanelSettings {