git: Add tree view support to Git Panel (#44089)

Xipeng Jin , Anthony Eid , and Remco Smits created

Closes #35803

This PR adds tree view support to the git panel UI as an additional
setting and moves git entry checkboxes to the right. Tree view only
supports sorting by paths behavior since sorting by status can become
noisy, due to having to duplicate directories that have entries with
different statuses.

### Tree vs Flat View
<img width="358" height="250" alt="image"
src="https://github.com/user-attachments/assets/c6b95d57-12fc-4c5e-8537-ee129963e50c"
/>
<img width="362" height="152" alt="image"
src="https://github.com/user-attachments/assets/0a69e00f-3878-4807-ae45-65e2d54174fc"
/>


#### Architecture changes

Before this PR, `GitPanel::entries` represented all entries and all
visible entries because both sets were equal to one another. However,
this equality isn't true for tree view, because entries can be
collapsed. To fix this, `TreeState` was added as a logical indices field
that is used to filter out non-visible entries. A benefit of this field
is that it could be used in the future to implement searching in the
GitPanel.

Another significant thing this PR changed was adding a HashMap field
`entries_by_indices` on `GitPanel`. We did this because `entry_by_path`
used binary search, which becomes overly complicated to implement for
tree view. The performance of this function matters because it's a hot
code path, so a linear search wasn't ideal either. The solution was
using a hash map to improve time complexity from O(log n) to O(1), where
n is the count of entries.

#### Follow-ups
In the future, we could use `ui::ListItem` to render entries in the tree
view to improve UI consistency.
 
Release Notes:

- Added tree view for Git panel. Users are able to switch between Flat
and Tree view in Git panel.

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>
Co-authored-by: Remco Smits <djsmits12@gmail.com>

Change summary

assets/settings/default.json            |   4 
crates/git_ui/src/git_panel.rs          | 787 +++++++++++++++++++++-----
crates/git_ui/src/git_panel_settings.rs |   2 
crates/git_ui/src/project_diff.rs       |   5 
crates/settings/src/settings_content.rs |   5 
crates/settings_ui/src/page_data.rs     |  18 
6 files changed, 663 insertions(+), 158 deletions(-)

Detailed changes

assets/settings/default.json đź”—

@@ -870,6 +870,10 @@
     //
     // Default: false
     "collapse_untracked_diff": false,
+    /// Whether to show entries with tree or flat view in the panel
+    ///
+    /// Default: false
+    "tree_view": false,
     "scrollbar": {
       // When to show the scrollbar in the git panel.
       //

crates/git_ui/src/git_panel.rs đź”—

@@ -13,6 +13,7 @@ use agent_settings::AgentSettings;
 use anyhow::Context as _;
 use askpass::AskPassDelegate;
 use cloud_llm_client::CompletionIntent;
+use collections::{BTreeMap, HashMap, HashSet};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{
     Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
@@ -33,10 +34,11 @@ use git::{
     TrashUntrackedFiles, UnstageAll,
 };
 use gpui::{
-    Action, AsyncApp, AsyncWindowContext, ClickEvent, Corner, DismissEvent, Entity, EventEmitter,
-    FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
-    MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task,
-    UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list,
+    Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity,
+    EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
+    ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
+    Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point,
+    size, uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, File};
@@ -60,12 +62,13 @@ use settings::{Settings, SettingsStore, StatusStyle};
 use std::future::Future;
 use std::ops::Range;
 use std::path::Path;
-use std::{collections::HashSet, sync::Arc, time::Duration, usize};
+use std::{sync::Arc, time::Duration, usize};
 use strum::{IntoEnumIterator, VariantNames};
 use time::OffsetDateTime;
 use ui::{
-    ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu, ScrollAxes,
-    Scrollbars, SplitButton, Tooltip, WithScrollbar, prelude::*,
+    ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors,
+    PopoverMenu, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar,
+    prelude::*,
 };
 use util::paths::PathStyle;
 use util::{ResultExt, TryFutureExt, maybe};
@@ -92,6 +95,8 @@ actions!(
         ToggleFillCoAuthors,
         /// Toggles sorting entries by path vs status.
         ToggleSortByPath,
+        /// Toggles showing entries in tree vs flat view.
+        ToggleTreeView,
     ]
 );
 
@@ -122,6 +127,7 @@ struct GitMenuState {
     has_new_changes: bool,
     sort_by_path: bool,
     has_stash_items: bool,
+    tree_view: bool,
 }
 
 fn git_panel_context_menu(
@@ -166,20 +172,34 @@ fn git_panel_context_menu(
             )
             .separator()
             .entry(
-                if state.sort_by_path {
-                    "Sort by Status"
+                if state.tree_view {
+                    "Flat View"
                 } else {
-                    "Sort by Path"
+                    "Tree View"
                 },
-                Some(Box::new(ToggleSortByPath)),
-                move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx),
+                Some(Box::new(ToggleTreeView)),
+                move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx),
             )
+            .when(!state.tree_view, |this| {
+                this.entry(
+                    if state.sort_by_path {
+                        "Sort by Status"
+                    } else {
+                        "Sort by Path"
+                    },
+                    Some(Box::new(ToggleSortByPath)),
+                    move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx),
+                )
+            })
     })
 }
 
 const GIT_PANEL_KEY: &str = "GitPanel";
 
 const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
+// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
+const TREE_INDENT: f32 = 12.0;
+const TREE_INDENT_GUIDE_OFFSET: f32 = 16.0;
 
 pub fn register(workspace: &mut Workspace) {
     workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
@@ -204,7 +224,7 @@ struct SerializedGitPanel {
     signoff_enabled: bool,
 }
 
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
 enum Section {
     Conflict,
     Tracked,
@@ -240,6 +260,8 @@ impl GitHeaderEntry {
 #[derive(Debug, PartialEq, Eq, Clone)]
 enum GitListEntry {
     Status(GitStatusEntry),
+    TreeStatus(GitTreeStatusEntry),
+    Directory(GitTreeDirEntry),
     Header(GitHeaderEntry),
 }
 
@@ -247,11 +269,250 @@ impl GitListEntry {
     fn status_entry(&self) -> Option<&GitStatusEntry> {
         match self {
             GitListEntry::Status(entry) => Some(entry),
+            GitListEntry::TreeStatus(entry) => Some(&entry.entry),
             _ => None,
         }
     }
 }
 
+enum GitPanelViewMode {
+    Flat,
+    Tree(TreeViewState),
+}
+
+impl GitPanelViewMode {
+    fn from_settings(cx: &App) -> Self {
+        if GitPanelSettings::get_global(cx).tree_view {
+            GitPanelViewMode::Tree(TreeViewState::default())
+        } else {
+            GitPanelViewMode::Flat
+        }
+    }
+
+    fn tree_state(&self) -> Option<&TreeViewState> {
+        match self {
+            GitPanelViewMode::Tree(state) => Some(state),
+            GitPanelViewMode::Flat => None,
+        }
+    }
+
+    fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> {
+        match self {
+            GitPanelViewMode::Tree(state) => Some(state),
+            GitPanelViewMode::Flat => None,
+        }
+    }
+}
+
+#[derive(Default)]
+struct TreeViewState {
+    // Maps visible index to actual entry index.
+    // Length equals the number of visible entries.
+    // This is needed because some entries (like collapsed directories) may be hidden.
+    logical_indices: Vec<usize>,
+    expanded_dirs: HashMap<TreeKey, bool>,
+    directory_descendants: HashMap<TreeKey, Vec<GitStatusEntry>>,
+}
+
+impl TreeViewState {
+    fn build_tree_entries(
+        &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();
+        }
+
+        entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
+
+        let mut root = TreeNode::default();
+        for entry in entries {
+            let components: Vec<&str> = entry.repo_path.components().collect();
+            if components.is_empty() {
+                root.files.push(entry);
+                continue;
+            }
+
+            let mut current = &mut root;
+            let mut current_path = String::new();
+
+            for (ix, component) in components.iter().enumerate() {
+                if ix == components.len() - 1 {
+                    current.files.push(entry.clone());
+                } else {
+                    if !current_path.is_empty() {
+                        current_path.push('/');
+                    }
+                    current_path.push_str(component);
+                    let dir_path = RepoPath::new(&current_path)
+                        .expect("repo path from status entry component");
+
+                    let component = SharedString::from(component.to_string());
+
+                    current = current
+                        .children
+                        .entry(component.clone())
+                        .or_insert_with(|| TreeNode {
+                            name: component,
+                            path: Some(dir_path),
+                            ..Default::default()
+                        });
+                }
+            }
+        }
+
+        let (flattened, _) = self.flatten_tree(
+            &root,
+            section,
+            0,
+            repo,
+            seen_directories,
+            optimistic_staging,
+        );
+        flattened
+    }
+
+    fn flatten_tree(
+        &mut self,
+        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();
+
+        for child in node.children.values() {
+            let (terminal, name) = Self::compact_directory_chain(child);
+            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 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());
+
+            flattened.push((
+                GitListEntry::Directory(GitTreeDirEntry {
+                    key,
+                    name,
+                    depth,
+                    staged_state,
+                    expanded,
+                }),
+                true,
+            ));
+
+            if expanded {
+                flattened.extend(child_flattened);
+            } else {
+                flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false)));
+            }
+
+            all_statuses.append(&mut child_statuses);
+        }
+
+        for file in &node.files {
+            all_statuses.push(file.clone());
+            flattened.push((
+                GitListEntry::TreeStatus(GitTreeStatusEntry {
+                    entry: file.clone(),
+                    depth,
+                }),
+                true,
+            ));
+        }
+
+        (flattened, all_statuses)
+    }
+
+    fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) {
+        let mut parts = vec![node.name.clone()];
+        while node.files.is_empty() && node.children.len() == 1 {
+            let Some(child) = node.children.values().next() else {
+                continue;
+            };
+            if child.path.is_none() {
+                break;
+            }
+            parts.push(child.name.clone());
+            node = child;
+        }
+        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)]
+struct GitTreeStatusEntry {
+    entry: GitStatusEntry,
+    depth: usize,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Hash)]
+struct TreeKey {
+    section: Section,
+    path: RepoPath,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+struct GitTreeDirEntry {
+    key: TreeKey,
+    name: SharedString,
+    depth: usize,
+    staged_state: ToggleState,
+    expanded: bool,
+}
+
+#[derive(Default)]
+struct TreeNode {
+    name: SharedString,
+    path: Option<RepoPath>,
+    children: BTreeMap<SharedString, TreeNode>,
+    files: Vec<GitStatusEntry>,
+}
+
 #[derive(Debug, PartialEq, Eq, Clone)]
 pub struct GitStatusEntry {
     pub(crate) repo_path: RepoPath,
@@ -345,12 +606,15 @@ pub struct GitPanel {
     add_coauthors: bool,
     generate_commit_message_task: Option<Task<Option<()>>>,
     entries: Vec<GitListEntry>,
+    view_mode: GitPanelViewMode,
+    entries_indices: HashMap<RepoPath, usize>,
     single_staged_entry: Option<GitStatusEntry>,
     single_tracked_entry: Option<GitStatusEntry>,
     focus_handle: FocusHandle,
     fs: Arc<dyn Fs>,
     new_count: usize,
     entry_count: usize,
+    changes_count: usize,
     new_staged_count: usize,
     pending_commit: Option<Task<()>>,
     amend_pending: bool,
@@ -374,6 +638,7 @@ pub struct GitPanel {
     local_committer_task: Option<Task<()>>,
     bulk_staging: Option<BulkStaging>,
     stash_entries: GitStash,
+    optimistic_staging: HashMap<RepoPath, bool>,
     _settings_subscription: Subscription,
 }
 
@@ -433,14 +698,19 @@ impl GitPanel {
             cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 
             let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
+            let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
             cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
-                let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
-                if is_sort_by_path != was_sort_by_path {
-                    this.entries.clear();
+                let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
+                let tree_view = GitPanelSettings::get_global(cx).tree_view;
+                if tree_view != was_tree_view {
+                    this.view_mode = GitPanelViewMode::from_settings(cx);
+                }
+                if sort_by_path != was_sort_by_path || tree_view != was_tree_view {
                     this.bulk_staging.take();
                     this.update_visible_entries(window, cx);
                 }
-                was_sort_by_path = is_sort_by_path
+                was_sort_by_path = sort_by_path;
+                was_tree_view = tree_view;
             })
             .detach();
 
@@ -506,10 +776,13 @@ impl GitPanel {
                 add_coauthors: true,
                 generate_commit_message_task: None,
                 entries: Vec::new(),
+                view_mode: GitPanelViewMode::from_settings(cx),
+                entries_indices: HashMap::default(),
                 focus_handle: cx.focus_handle(),
                 fs,
                 new_count: 0,
                 new_staged_count: 0,
+                changes_count: 0,
                 pending_commit: None,
                 amend_pending: false,
                 original_commit_message: None,
@@ -535,6 +808,7 @@ impl GitPanel {
                 entry_count: 0,
                 bulk_staging: None,
                 stash_entries: Default::default(),
+                optimistic_staging: HashMap::default(),
                 _settings_subscription,
             };
 
@@ -543,51 +817,8 @@ impl GitPanel {
         })
     }
 
-    pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
-        if GitPanelSettings::get_global(cx).sort_by_path {
-            return self
-                .entries
-                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(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);
-            }
-        }
-        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);
-            }
-        }
-        None
+    pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
+        self.entries_indices.get(path).copied()
     }
 
     pub fn select_entry_by_path(
@@ -602,7 +833,7 @@ impl GitPanel {
         let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
             return;
         };
-        let Some(ix) = self.entry_by_path(&repo_path, cx) else {
+        let Some(ix) = self.entry_by_path(&repo_path) else {
             return;
         };
         self.selected_entry = Some(ix);
@@ -702,9 +933,15 @@ impl GitPanel {
         cx.notify();
     }
 
+    fn first_status_entry_index(&self) -> Option<usize> {
+        self.entries
+            .iter()
+            .position(|entry| entry.status_entry().is_some())
+    }
+
     fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
-        if !self.entries.is_empty() {
-            self.selected_entry = Some(1);
+        if let Some(first_entry) = self.first_status_entry_index() {
+            self.selected_entry = Some(first_entry);
             self.scroll_to_selected_entry(cx);
         }
     }
@@ -791,7 +1028,7 @@ impl GitPanel {
             .as_ref()
             .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
         if have_entries && self.selected_entry.is_none() {
-            self.selected_entry = Some(1);
+            self.selected_entry = self.first_status_entry_index();
             self.scroll_to_selected_entry(cx);
             cx.notify();
         }
@@ -1318,6 +1555,37 @@ impl GitPanel {
         .detach();
     }
 
+    fn is_entry_staged(&self, entry: &GitStatusEntry, repo: &Repository) -> bool {
+        // 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
+        //    for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry`
+        //    is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on
+        //    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())
+            .or_else(|| {
+                repo.status_for_path(&entry.repo_path)
+                    .and_then(|status| status.status.staging().as_bool())
+            })
+            .unwrap_or(entry.staging.has_staged())
+    }
+
+    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
+        } else {
+            ToggleState::Indeterminate
+        }
+    }
+
     pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
         self.change_all_files_stage(true, cx);
     }
@@ -1332,50 +1600,92 @@ impl GitPanel {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(active_repository) = self.active_repository.as_ref() else {
+        let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
-        let repo = active_repository.read(cx);
-        let (stage, repo_paths) = match entry {
-            GitListEntry::Status(status_entry) => {
-                let repo_paths = vec![status_entry.clone()];
-                let stage = if repo
-                    .pending_ops_for_path(&status_entry.repo_path)
-                    .map(|ops| ops.staging() || ops.staged())
-                    .or_else(|| {
-                        repo.status_for_path(&status_entry.repo_path)
-                            .map(|status| status.status.staging().has_staged())
-                    })
-                    .unwrap_or(status_entry.staging.has_staged())
-                {
-                    if let Some(op) = self.bulk_staging.clone()
-                        && op.anchor == status_entry.repo_path
-                    {
-                        self.bulk_staging = None;
-                    }
-                    false
-                } else {
-                    self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx);
-                    true
-                };
-                (stage, repo_paths)
-            }
-            GitListEntry::Header(section) => {
-                let goal_staged_state = !self.header_state(section.header).selected();
-                let entries = self
-                    .entries
-                    .iter()
-                    .filter_map(|entry| entry.status_entry())
-                    .filter(|status_entry| {
-                        section.contains(status_entry, repo)
-                            && status_entry.staging.as_bool() != Some(goal_staged_state)
-                    })
-                    .cloned()
-                    .collect::<Vec<_>>();
+        let mut set_anchor: Option<RepoPath> = None;
+        let mut clear_anchor = None;
+
+        let (stage, repo_paths) = {
+            let repo = active_repository.read(cx);
+            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);
+                        }
+                        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);
+                        }
+                        false
+                    } else {
+                        set_anchor = Some(status_entry.entry.repo_path.clone());
+                        true
+                    };
+                    (stage, repo_paths)
+                }
+                GitListEntry::Header(section) => {
+                    let goal_staged_state = !self.header_state(section.header).selected();
+                    let entries = self
+                        .entries
+                        .iter()
+                        .filter_map(|entry| entry.status_entry())
+                        .filter(|status_entry| {
+                            section.contains(status_entry, &repo)
+                                && status_entry.staging.as_bool() != Some(goal_staged_state)
+                        })
+                        .cloned()
+                        .collect::<Vec<_>>();
 
-                (goal_staged_state, entries)
+                    (goal_staged_state, entries)
+                }
+                GitListEntry::Directory(entry) => {
+                    let goal_staged_state = entry.staged_state != ToggleState::Selected;
+                    let entries = self
+                        .view_mode
+                        .tree_state()
+                        .and_then(|state| state.directory_descendants.get(&entry.key))
+                        .cloned()
+                        .unwrap_or_default()
+                        .into_iter()
+                        .filter(|status_entry| {
+                            self.is_entry_staged(status_entry, &repo) != goal_staged_state
+                        })
+                        .collect::<Vec<_>>();
+                    (goal_staged_state, entries)
+                }
             }
         };
+        if let Some(anchor) = clear_anchor {
+            if let Some(op) = self.bulk_staging.clone()
+                && op.anchor == anchor
+            {
+                self.bulk_staging = None;
+            }
+        }
+        if let Some(anchor) = set_anchor {
+            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);
     }
 
@@ -1420,6 +1730,81 @@ 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 directory’s 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
     }
@@ -2690,6 +3075,29 @@ impl GitPanel {
         }
     }
 
+    fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context<Self>) {
+        let current_setting = GitPanelSettings::get_global(cx).tree_view;
+        if let Some(workspace) = self.workspace.upgrade() {
+            let workspace = workspace.read(cx);
+            let fs = workspace.app_state().fs.clone();
+            cx.update_global::<SettingsStore, _>(|store, _cx| {
+                store.update_settings_file(fs, move |settings, _cx| {
+                    settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting);
+                });
+            })
+        }
+    }
+
+    fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(state) = self.view_mode.tree_state_mut() {
+            let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true);
+            *expanded = !*expanded;
+            self.update_visible_entries(window, cx);
+        } else {
+            util::debug_panic!("Attempted to toggle directory in flat Git Panel state");
+        }
+    }
+
     fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
         const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
 
@@ -2799,27 +3207,34 @@ impl GitPanel {
         let bulk_staging = self.bulk_staging.take();
         let last_staged_path_prev_index = bulk_staging
             .as_ref()
-            .and_then(|op| self.entry_by_path(&op.anchor, cx));
+            .and_then(|op| self.entry_by_path(&op.anchor));
 
         self.entries.clear();
+        self.entries_indices.clear();
         self.single_staged_entry.take();
         self.single_tracked_entry.take();
         self.conflicted_count = 0;
         self.conflicted_staged_count = 0;
+        self.changes_count = 0;
         self.new_count = 0;
         self.tracked_count = 0;
         self.new_staged_count = 0;
         self.tracked_staged_count = 0;
         self.entry_count = 0;
+        self.max_width_item_index = None;
 
         let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
+        let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
+        let group_by_status = is_tree_view || !sort_by_path;
 
         let mut changed_entries = Vec::new();
         let mut new_entries = Vec::new();
         let mut conflict_entries = Vec::new();
         let mut single_staged_entry = None;
         let mut staged_count = 0;
-        let mut max_width_item: Option<(RepoPath, usize)> = None;
+        let mut seen_directories = HashSet::default();
+        let mut max_width_estimate = 0usize;
+        let mut max_width_item_index = None;
 
         let Some(repo) = self.active_repository.as_ref() else {
             // Just clear entries if no repository is active.
@@ -2832,6 +3247,7 @@ impl GitPanel {
         self.stash_entries = repo.cached_stash();
 
         for entry in repo.cached_status() {
+            self.changes_count += 1;
             let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
             let is_new = entry.status.is_created();
             let staging = entry.status.staging();
@@ -2856,26 +3272,9 @@ impl GitPanel {
                 single_staged_entry = Some(entry.clone());
             }
 
-            let width_estimate = Self::item_width_estimate(
-                entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0),
-                entry.display_name(path_style).len(),
-            );
-
-            match max_width_item.as_mut() {
-                Some((repo_path, estimate)) => {
-                    if width_estimate > *estimate {
-                        *repo_path = entry.repo_path.clone();
-                        *estimate = width_estimate;
-                    }
-                }
-                None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
-            }
-
-            if sort_by_path {
-                changed_entries.push(entry);
-            } else if is_conflict {
+            if group_by_status && is_conflict {
                 conflict_entries.push(entry);
-            } else if is_new {
+            } else if group_by_status && is_new {
                 new_entries.push(entry);
             } else {
                 changed_entries.push(entry);
@@ -2910,52 +3309,126 @@ impl GitPanel {
             self.single_tracked_entry = changed_entries.first().cloned();
         }
 
-        if !conflict_entries.is_empty() {
-            self.entries.push(GitListEntry::Header(GitHeaderEntry {
-                header: Section::Conflict,
-            }));
-            self.entries
-                .extend(conflict_entries.into_iter().map(GitListEntry::Status));
+        let mut push_entry =
+            |this: &mut Self,
+             entry: GitListEntry,
+             is_visible: bool,
+             logical_indices: Option<&mut Vec<usize>>| {
+                if let Some(estimate) =
+                    this.width_estimate_for_list_entry(is_tree_view, &entry, path_style)
+                {
+                    if estimate > max_width_estimate {
+                        max_width_estimate = estimate;
+                        max_width_item_index = Some(this.entries.len());
+                    }
+                }
+
+                if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone())
+                {
+                    this.entries_indices.insert(repo_path, this.entries.len());
+                }
+
+                if let (Some(indices), true) = (logical_indices, is_visible) {
+                    indices.push(this.entries.len());
+                }
+
+                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)),
+                ]
+            };
         }
 
-        if !changed_entries.is_empty() {
-            if !sort_by_path {
-                self.entries.push(GitListEntry::Header(GitHeaderEntry {
-                    header: Section::Tracked,
-                }));
+        match &mut self.view_mode {
+            GitPanelViewMode::Tree(tree_state) => {
+                tree_state.logical_indices.clear();
+                tree_state.directory_descendants.clear();
+
+                // This is just to get around the borrow checker
+                // because push_entry mutably borrows self
+                let mut tree_state = std::mem::take(tree_state);
+
+                for (section, entries) in take_section_entries!() {
+                    if entries.is_empty() {
+                        continue;
+                    }
+
+                    push_entry(
+                        self,
+                        GitListEntry::Header(GitHeaderEntry { header: section }),
+                        true,
+                        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,
+                    ) {
+                        push_entry(
+                            self,
+                            entry,
+                            is_visible,
+                            Some(&mut tree_state.logical_indices),
+                        );
+                    }
+                }
+
+                tree_state
+                    .expanded_dirs
+                    .retain(|key, _| seen_directories.contains(key));
+                self.view_mode = GitPanelViewMode::Tree(tree_state);
             }
-            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));
-        }
+            GitPanelViewMode::Flat => {
+                for (section, entries) in take_section_entries!() {
+                    if entries.is_empty() {
+                        continue;
+                    }
 
-        if let Some((repo_path, _)) = max_width_item {
-            self.max_width_item_index = self.entries.iter().position(|entry| match entry {
-                GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path,
-                GitListEntry::Header(_) => false,
-            });
+                    if section != Section::Tracked || !sort_by_path {
+                        push_entry(
+                            self,
+                            GitListEntry::Header(GitHeaderEntry { header: section }),
+                            true,
+                            None,
+                        );
+                    }
+
+                    for entry in entries {
+                        push_entry(self, GitListEntry::Status(entry), true, None);
+                    }
+                }
+            }
         }
 
+        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()
             .filter(|op| op.repo_id == repo.id)
-            .and_then(|op| self.entry_by_path(&op.anchor, cx));
+            .and_then(|op| self.entry_by_path(&op.anchor));
         if bulk_staging_anchor_new_index == last_staged_path_prev_index
             && let Some(index) = bulk_staging_anchor_new_index
             && let Some(entry) = self.entries.get(index)
             && let Some(entry) = entry.status_entry()
-            && repo
-                .pending_ops_for_path(&entry.repo_path)
-                .map(|ops| ops.staging() || ops.staged())
-                .unwrap_or(entry.staging.has_staged())
+            && self.is_entry_staged(entry, &repo)
         {
             self.bulk_staging = bulk_staging;
         }

crates/git_ui/src/git_panel_settings.rs đź”—

@@ -24,6 +24,7 @@ pub struct GitPanelSettings {
     pub fallback_branch_name: String,
     pub sort_by_path: bool,
     pub collapse_untracked_diff: bool,
+    pub tree_view: bool,
 }
 
 impl ScrollbarVisibility for GitPanelSettings {
@@ -56,6 +57,7 @@ impl Settings for GitPanelSettings {
             fallback_branch_name: git_panel.fallback_branch_name.unwrap(),
             sort_by_path: git_panel.sort_by_path.unwrap(),
             collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(),
+            tree_view: git_panel.tree_view.unwrap(),
         }
     }
 }

crates/git_ui/src/project_diff.rs đź”—

@@ -644,7 +644,10 @@ impl ProjectDiff {
 }
 
 fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
-    if GitPanelSettings::get_global(cx).sort_by_path {
+    let settings = GitPanelSettings::get_global(cx);
+
+    // Tree view can only sort by path
+    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

crates/settings/src/settings_content.rs đź”—

@@ -511,6 +511,11 @@ pub struct GitPanelSettingsContent {
     ///
     /// Default: false
     pub collapse_untracked_diff: Option<bool>,
+
+    /// Whether to show entries with tree or flat view in the panel
+    ///
+    /// Default: false
+    pub tree_view: Option<bool>,
 }
 
 #[derive(

crates/settings_ui/src/page_data.rs đź”—

@@ -4314,6 +4314,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                     metadata: None,
                     files: USER,
                 }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Tree View",
+                    description: "Enable to show entries in tree view list, disable to show in flat view list.",
+                    field: Box::new(SettingField {
+                        json_path: Some("git_panel.tree_view"),
+                        pick: |settings_content| {
+                            settings_content.git_panel.as_ref()?.tree_view.as_ref()
+                        },
+                        write: |settings_content, value| {
+                            settings_content
+                                .git_panel
+                                .get_or_insert_default()
+                                .tree_view = value;
+                        },
+                    }),
+                    metadata: None,
+                    files: USER,
+                }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Scroll Bar",
                     description: "How and when the scrollbar should be displayed.",