@@ -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(¤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<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;
}