From 9e628505f3c5aaee9cf59546af1d7c86111914d2 Mon Sep 17 00:00:00 2001
From: Xipeng Jin <56369076+xipeng-jin@users.noreply.github.com>
Date: Wed, 10 Dec 2025 15:11:36 -0500
Subject: [PATCH] git: Add tree view support to Git Panel (#44089)
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
#### 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
Co-authored-by: Remco Smits
---
assets/settings/default.json | 4 +
crates/git_ui/src/git_panel.rs | 1148 ++++++++++++++++++-----
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, 966 insertions(+), 216 deletions(-)
diff --git a/assets/settings/default.json b/assets/settings/default.json
index dd51099799abb49325e9a2747ee18f9837e4409b..cef4a79c9281541064efd5e5718cf7687f0fc451 100644
--- a/assets/settings/default.json
+++ b/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.
//
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index 21486ba98383b06388d2dbc214dfcedc1bb350e4..ba051cd26ba7c0ad30652af4a614b502e6ea4efa 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/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,
+ expanded_dirs: HashMap,
+ directory_descendants: HashMap>,
+}
+
+impl TreeViewState {
+ fn build_tree_entries(
+ &mut self,
+ section: Section,
+ mut entries: Vec,
+ repo: &Repository,
+ seen_directories: &mut HashSet,
+ optimistic_staging: &HashMap,
+ ) -> 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(¤t_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,
+ optimistic_staging: &HashMap,
+ ) -> (Vec<(GitListEntry, bool)>, Vec) {
+ 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,
+ ) -> 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,
+ children: BTreeMap,
+ files: Vec,
+}
+
#[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>>,
entries: Vec,
+ view_mode: GitPanelViewMode,
+ entries_indices: HashMap,
single_staged_entry: Option,
single_tracked_entry: Option,
focus_handle: FocusHandle,
fs: Arc,
new_count: usize,
entry_count: usize,
+ changes_count: usize,
new_staged_count: usize,
pending_commit: Option>,
amend_pending: bool,
@@ -374,6 +638,7 @@ pub struct GitPanel {
local_committer_task: Option>,
bulk_staging: Option,
stash_entries: GitStash,
+ optimistic_staging: HashMap,
_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::(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 {
- 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 {
+ 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 {
+ self.entries
+ .iter()
+ .position(|entry| entry.status_entry().is_some())
+ }
+
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) {
- 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.change_all_files_stage(true, cx);
}
@@ -1332,50 +1600,92 @@ impl GitPanel {
_window: &mut Window,
cx: &mut Context,
) {
- 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::>();
+ let mut set_anchor: Option = 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::>();
- (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::>();
+ (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 = 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) {
+ 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::(|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) {
+ 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) {
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>| {
+ 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 = 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;
}
@@ -2996,15 +3469,11 @@ impl GitPanel {
self.new_staged_count = 0;
self.tracked_staged_count = 0;
self.entry_count = 0;
- for entry in &self.entries {
- let Some(status_entry) = entry.status_entry() else {
- continue;
- };
+
+ for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) {
self.entry_count += 1;
- let is_staging_or_staged = repo
- .pending_ops_for_path(&status_entry.repo_path)
- .map(|ops| ops.staging() || ops.staged())
- .unwrap_or(status_entry.staging.has_staged());
+ let is_staging_or_staged = self.is_entry_staged(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 {
@@ -3118,10 +3587,48 @@ impl GitPanel {
self.has_staged_changes()
}
- // eventually we'll need to take depth into account here
- // if we add a tree view
- fn item_width_estimate(path: usize, file_name: usize) -> usize {
- path + file_name
+ fn status_width_estimate(
+ tree_view: bool,
+ entry: &GitStatusEntry,
+ path_style: PathStyle,
+ depth: usize,
+ ) -> usize {
+ if tree_view {
+ Self::item_width_estimate(0, entry.display_name(path_style).len(), depth)
+ } else {
+ Self::item_width_estimate(
+ entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0),
+ entry.display_name(path_style).len(),
+ 0,
+ )
+ }
+ }
+
+ fn width_estimate_for_list_entry(
+ &self,
+ tree_view: bool,
+ entry: &GitListEntry,
+ path_style: PathStyle,
+ ) -> Option {
+ match entry {
+ GitListEntry::Status(status) => Some(Self::status_width_estimate(
+ tree_view, status, path_style, 0,
+ )),
+ GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate(
+ tree_view,
+ &status.entry,
+ path_style,
+ status.depth,
+ )),
+ GitListEntry::Directory(dir) => {
+ Some(Self::item_width_estimate(0, dir.name.len(), dir.depth))
+ }
+ GitListEntry::Header(_) => None,
+ }
+ }
+
+ fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize {
+ path + file_name + depth * 2
}
fn render_overflow_menu(&self, id: impl Into) -> impl IntoElement {
@@ -3148,6 +3655,7 @@ impl GitPanel {
has_new_changes,
sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
has_stash_items,
+ tree_view: GitPanelSettings::get_global(cx).tree_view,
},
window,
cx,
@@ -3382,10 +3890,10 @@ impl GitPanel {
("Stage All", StageAll.boxed_clone(), true, "git add --all")
};
- let change_string = match self.entry_count {
+ let change_string = match self.changes_count {
0 => "No Changes".to_string(),
1 => "1 Change".to_string(),
- _ => format!("{} Changes", self.entry_count),
+ count => format!("{} Changes", count),
};
Some(
@@ -3807,7 +4315,7 @@ impl GitPanel {
let repo = self.active_repository.as_ref()?.read(cx);
let project_path = (file.worktree_id(cx), file.path().clone()).into();
let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
- let ix = self.entry_by_path(&repo_path, cx)?;
+ let ix = self.entry_by_path(&repo_path)?;
let entry = self.entries.get(ix)?;
let is_staging_or_staged = repo
@@ -3858,7 +4366,10 @@ impl GitPanel {
window: &mut Window,
cx: &mut Context,
) -> impl IntoElement {
- let entry_count = self.entries.len();
+ let (is_tree_view, entry_count) = match &self.view_mode {
+ GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()),
+ GitPanelViewMode::Flat => (false, self.entries.len()),
+ };
v_flex()
.flex_1()
@@ -3878,10 +4389,33 @@ impl GitPanel {
cx.processor(move |this, range: Range, window, cx| {
let mut items = Vec::with_capacity(range.end - range.start);
- for ix in range {
+ for ix in range.into_iter().map(|ix| match &this.view_mode {
+ GitPanelViewMode::Tree(state) => state.logical_indices[ix],
+ GitPanelViewMode::Flat => ix,
+ }) {
match &this.entries.get(ix) {
Some(GitListEntry::Status(entry)) => {
- items.push(this.render_entry(
+ items.push(this.render_status_entry(
+ ix,
+ entry,
+ 0,
+ has_write_access,
+ window,
+ cx,
+ ));
+ }
+ Some(GitListEntry::TreeStatus(entry)) => {
+ items.push(this.render_status_entry(
+ ix,
+ &entry.entry,
+ entry.depth,
+ has_write_access,
+ window,
+ cx,
+ ));
+ }
+ Some(GitListEntry::Directory(entry)) => {
+ items.push(this.render_directory_entry(
ix,
entry,
has_write_access,
@@ -3905,6 +4439,51 @@ impl GitPanel {
items
}),
)
+ .when(is_tree_view, |list| {
+ let indent_size = px(TREE_INDENT);
+ list.with_decoration(
+ ui::indent_guides(indent_size, IndentGuideColors::panel(cx))
+ .with_compute_indents_fn(
+ cx.entity(),
+ |this, range, _window, _cx| {
+ range
+ .map(|ix| match this.entries.get(ix) {
+ Some(GitListEntry::Directory(dir)) => dir.depth,
+ Some(GitListEntry::TreeStatus(status)) => {
+ status.depth
+ }
+ _ => 0,
+ })
+ .collect()
+ },
+ )
+ .with_render_fn(cx.entity(), |_, params, _, _| {
+ let left_offset = px(TREE_INDENT_GUIDE_OFFSET);
+ let indent_size = params.indent_size;
+ let item_height = params.item_height;
+
+ params
+ .indent_guides
+ .into_iter()
+ .map(|layout| {
+ let bounds = Bounds::new(
+ point(
+ layout.offset.x * indent_size + left_offset,
+ layout.offset.y * item_height,
+ ),
+ size(px(1.), layout.length * item_height),
+ );
+ RenderedIndentGuide {
+ bounds,
+ layout,
+ is_active: false,
+ hitbox: None,
+ }
+ })
+ .collect()
+ }),
+ )
+ })
.size_full()
.flex_grow()
.with_sizing_behavior(ListSizingBehavior::Auto)
@@ -4038,6 +4617,7 @@ impl GitPanel {
has_new_changes: self.new_count > 0,
sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
has_stash_items: self.stash_entries.entries.len() > 0,
+ tree_view: GitPanelSettings::get_global(cx).tree_view,
},
window,
cx,
@@ -4069,14 +4649,16 @@ impl GitPanel {
cx.notify();
}
- fn render_entry(
+ fn render_status_entry(
&self,
ix: usize,
entry: &GitStatusEntry,
+ depth: usize,
has_write_access: bool,
window: &Window,
cx: &Context,
) -> AnyElement {
+ let tree_view = GitPanelSettings::get_global(cx).tree_view;
let path_style = self.project.read(cx).path_style(cx);
let git_path_style = ProjectSettings::get_global(cx).git.path_style;
let display_name = entry.display_name(path_style);
@@ -4123,22 +4705,7 @@ impl GitPanel {
.active_repository(cx)
.expect("active repository must be set");
let repo = active_repo.read(cx);
- // 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.
- let is_staging_or_staged = 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())
- })
- .or_else(|| entry.staging.as_bool());
+ let is_staging_or_staged = self.is_entry_staged(entry, &repo);
let mut is_staged: ToggleState = is_staging_or_staged.into();
if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
is_staged = ToggleState::Selected;
@@ -4178,6 +4745,39 @@ impl GitPanel {
} else {
cx.theme().colors().ghost_element_active
};
+
+ let mut name_row = h_flex()
+ .items_center()
+ .gap_1()
+ .flex_1()
+ .pl(if tree_view {
+ px(depth as f32 * TREE_INDENT)
+ } else {
+ px(0.)
+ })
+ .child(git_status_icon(status));
+
+ name_row = if tree_view {
+ name_row.child(
+ self.entry_label(display_name, label_color)
+ .when(status.is_deleted(), Label::strikethrough)
+ .truncate(),
+ )
+ } else {
+ name_row.child(h_flex().items_center().flex_1().map(|this| {
+ self.path_formatted(
+ this,
+ entry.parent_dir(path_style),
+ path_color,
+ display_name,
+ label_color,
+ path_style,
+ git_path_style,
+ status.is_deleted(),
+ )
+ }))
+ };
+
h_flex()
.id(id)
.h(self.list_item_height())
@@ -4223,6 +4823,7 @@ impl GitPanel {
cx.stop_propagation();
},
)
+ .child(name_row)
.child(
div()
.id(checkbox_wrapper_id)
@@ -4245,11 +4846,16 @@ impl GitPanel {
if click.modifiers().shift {
this.stage_bulk(ix, cx);
} else {
- this.toggle_staged_for_entry(
- &GitListEntry::Status(entry.clone()),
- window,
- cx,
- );
+ let list_entry =
+ if GitPanelSettings::get_global(cx).tree_view {
+ GitListEntry::TreeStatus(GitTreeStatusEntry {
+ entry: entry.clone(),
+ depth,
+ })
+ } else {
+ GitListEntry::Status(entry.clone())
+ };
+ this.toggle_staged_for_entry(&list_entry, window, cx);
}
cx.stop_propagation();
})
@@ -4259,7 +4865,7 @@ 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.unwrap_or(false) {
+ let action = if is_staging_or_staged {
"Unstage"
} else {
"Stage"
@@ -4270,23 +4876,134 @@ impl GitPanel {
}),
),
)
- .child(git_status_icon(status))
+ .into_any_element()
+ }
+
+ fn render_directory_entry(
+ &self,
+ ix: usize,
+ entry: &GitTreeDirEntry,
+ has_write_access: bool,
+ window: &Window,
+ cx: &Context,
+ ) -> AnyElement {
+ // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that
+ let selected = self.selected_entry == Some(ix);
+ let label_color = Color::Muted;
+
+ let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into());
+ let checkbox_id: ElementId =
+ ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into());
+ let checkbox_wrapper_id: ElementId =
+ ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into());
+
+ let selected_bg_alpha = 0.08;
+ let state_opacity_step = 0.04;
+
+ let base_bg = if selected {
+ cx.theme().status().info.alpha(selected_bg_alpha)
+ } else {
+ cx.theme().colors().ghost_element_background
+ };
+
+ let hover_bg = if selected {
+ cx.theme()
+ .status()
+ .info
+ .alpha(selected_bg_alpha + state_opacity_step)
+ } else {
+ cx.theme().colors().ghost_element_hover
+ };
+
+ let active_bg = if selected {
+ cx.theme()
+ .status()
+ .info
+ .alpha(selected_bg_alpha + state_opacity_step * 2.0)
+ } else {
+ cx.theme().colors().ghost_element_active
+ };
+ let folder_icon = if entry.expanded {
+ IconName::FolderOpen
+ } else {
+ IconName::Folder
+ };
+ let staged_state = entry.staged_state;
+
+ let name_row = h_flex()
+ .items_center()
+ .gap_1()
+ .flex_1()
+ .pl(px(entry.depth as f32 * TREE_INDENT))
.child(
- h_flex()
- .items_center()
- .flex_1()
- .child(h_flex().items_center().flex_1().map(|this| {
- self.path_formatted(
- this,
- entry.parent_dir(path_style),
- path_color,
- display_name,
- label_color,
- path_style,
- git_path_style,
- status.is_deleted(),
- )
- })),
+ Icon::new(folder_icon)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(self.entry_label(entry.name.clone(), label_color).truncate());
+
+ h_flex()
+ .id(id)
+ .h(self.list_item_height())
+ .w_full()
+ .items_center()
+ .border_1()
+ .when(selected && self.focus_handle.is_focused(window), |el| {
+ el.border_color(cx.theme().colors().border_focused)
+ })
+ .px(rems(0.75))
+ .overflow_hidden()
+ .flex_none()
+ .gap_1p5()
+ .bg(base_bg)
+ .hover(|this| this.bg(hover_bg))
+ .active(|this| this.bg(active_bg))
+ .on_click({
+ let key = entry.key.clone();
+ cx.listener(move |this, _event: &ClickEvent, window, cx| {
+ this.selected_entry = Some(ix);
+ this.toggle_directory(&key, window, cx);
+ })
+ })
+ .child(name_row)
+ .child(
+ div()
+ .id(checkbox_wrapper_id)
+ .flex_none()
+ .occlude()
+ .cursor_pointer()
+ .child(
+ Checkbox::new(checkbox_id, staged_state)
+ .disabled(!has_write_access)
+ .fill()
+ .elevation(ElevationIndex::Surface)
+ .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.toggle_staged_for_entry(
+ &GitListEntry::Directory(entry.clone()),
+ window,
+ cx,
+ );
+ cx.stop_propagation();
+ })
+ .ok();
+ }
+ })
+ .tooltip(move |_window, cx| {
+ let action = if staged_state.selected() {
+ "Unstage"
+ } else {
+ "Stage"
+ };
+ Tooltip::simple(format!("{action} folder"), cx)
+ }),
+ ),
)
.into_any_element()
}
@@ -4433,7 +5150,7 @@ impl GitPanel {
let Some(op) = self.bulk_staging.as_ref() else {
return;
};
- let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
+ let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else {
return;
};
if let Some(entry) = self.entries.get(index)
@@ -4528,6 +5245,7 @@ impl Render for GitPanel {
git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
})
.on_action(cx.listener(Self::toggle_sort_by_path))
+ .on_action(cx.listener(Self::toggle_tree_view))
.size_full()
.overflow_hidden()
.bg(cx.theme().colors().panel_background)
diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs
index 2a6c1e8882b3f9cce02060dbf8efb6a4826b6995..6b5334e55544b465864fe3afb780c4673bb5961e 100644
--- a/crates/git_ui/src/git_panel_settings.rs
+++ b/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(),
}
}
}
diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs
index e560bba0d36ad9901fffa9b5aad4dbd88e3108b6..f40d70da6494cf8491c1d3d7909a288e5f99023c 100644
--- a/crates/git_ui/src/project_diff.rs
+++ b/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
diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs
index 36c8520f9313c48408b37caabe61dd29106cacae..743e22b04d9cf87a0d09a73aef879c781a50cca2 100644
--- a/crates/settings/src/settings_content.rs
+++ b/crates/settings/src/settings_content.rs
@@ -511,6 +511,11 @@ pub struct GitPanelSettingsContent {
///
/// Default: false
pub collapse_untracked_diff: Option,
+
+ /// Whether to show entries with tree or flat view in the panel
+ ///
+ /// Default: false
+ pub tree_view: Option,
}
#[derive(
diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs
index 0c383970c990c3ba19eab7aa5d3b7c699f8a195e..8652ccf68b48e8e858b96e4fe69edecd8ae29d25 100644
--- a/crates/settings_ui/src/page_data.rs
+++ b/crates/settings_ui/src/page_data.rs
@@ -4314,6 +4314,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec {
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.",