diff --git a/assets/settings/default.json b/assets/settings/default.json index 7841c0e3ebe..9b023b82c1c 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -974,10 +974,14 @@ // // Default: main "fallback_branch_name": "main", - // Whether to sort entries in the panel by path or by status (the default). + // How to sort entries in the git panel. // - // Default: false - "sort_by_path": false, + // Default: path + "sort_by": "path", + // How to group entries in the git panel. + // + // Default: status + "group_by": "status", // Whether to collapse untracked files in the diff panel. // // Default: false diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index c5716b9ba92..4382af63dbc 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -62,7 +62,9 @@ use project::{ use prompt_store::RULES_FILE_NAMES; use proto::RpcError; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore, StatusStyle, update_settings_file}; +use settings::{ + GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore, StatusStyle, update_settings_file, +}; use smallvec::SmallVec; use std::future::Future; use std::ops::Range; @@ -72,9 +74,9 @@ use strum::{IntoEnumIterator, VariantNames}; use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, ContextMenu, Divider, ElevationIndex, IndentGuideColors, KeyBinding, - PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, - TintColor, Tooltip, WithScrollbar, prelude::*, + ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, + IndentGuideColors, KeyBinding, PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, + Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; @@ -111,8 +113,14 @@ actions!( LastEntry, /// Toggles automatic co-author suggestions. ToggleFillCoAuthors, - /// Toggles sorting entries by path vs status. - ToggleSortByPath, + /// Sorts entries by path. + SetSortByPath, + /// Sorts entries by name. + SetSortByName, + /// Disables grouping entries by status. + SetGroupByNone, + /// Groups entries by status. + SetGroupByStatus, /// Toggles showing entries in tree vs flat view. ToggleTreeView, /// Expands the selected entry to show its children. @@ -159,7 +167,8 @@ struct GitMenuState { has_staged_changes: bool, has_unstaged_changes: bool, has_new_changes: bool, - sort_by_path: bool, + sort_by: GitPanelSortBy, + group_by: GitPanelGroupBy, has_stash_items: bool, tree_view: bool, } @@ -204,27 +213,79 @@ fn git_panel_context_menu( "Trash Untracked Files", TrashUntrackedFiles.boxed_clone(), ) - .separator() - .entry( - if state.tree_view { - "Flat View" - } else { - "Tree View" - }, - Some(Box::new(ToggleTreeView)), - move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx), + }) +} + +fn git_panel_view_options_menu( + focus_handle: FocusHandle, + state: GitMenuState, + window: &mut Window, + cx: &mut App, +) -> Entity { + ContextMenu::build(window, cx, move |context_menu, _, _| { + context_menu + .context(focus_handle) + .header("View") + .item( + ContextMenuEntry::new("List") + .toggle(IconPosition::End, !state.tree_view) + .handler(move |window, cx| { + if state.tree_view { + window.dispatch_action(Box::new(ToggleTreeView), cx); + } + }), + ) + .item( + ContextMenuEntry::new("Tree") + .toggle(IconPosition::End, state.tree_view) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(ToggleTreeView), cx); + } + }), + ) + .separator() + .header("Sort By") + .item( + ContextMenuEntry::new("Path") + .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Path) + .disabled(state.tree_view) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(SetSortByPath), cx); + } + }), + ) + .item( + ContextMenuEntry::new("Name") + .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Name) + .disabled(state.tree_view) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(SetSortByName), cx); + } + }), + ) + .separator() + .header("Group By") + .item( + ContextMenuEntry::new("None") + .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::None) + .handler(move |window, cx| { + if state.group_by != GitPanelGroupBy::None { + window.dispatch_action(Box::new(SetGroupByNone), cx); + } + }), + ) + .item( + ContextMenuEntry::new("Status") + .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::Status) + .handler(move |window, cx| { + if state.group_by != GitPanelGroupBy::Status { + window.dispatch_action(Box::new(SetGroupByStatus), 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), - ) - }) }) } @@ -388,7 +449,7 @@ struct TreeViewState { // 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, + expanded_dirs: HashMap, directory_descendants: HashMap>, } @@ -463,8 +524,8 @@ impl TreeViewState { let (child_flattened, mut child_statuses) = self.flatten_tree(terminal, section, depth + 1, seen_directories); let key = TreeKey { section, path }; - let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); - self.expanded_dirs.entry(key.clone()).or_insert(true); + let expanded = *self.expanded_dirs.get(&key.path).unwrap_or(&true); + self.expanded_dirs.entry(key.path.clone()).or_insert(true); seen_directories.insert(key.clone()); self.directory_descendants @@ -646,6 +707,7 @@ pub struct GitPanel { generate_commit_message_task: Option>>, entries: Vec, view_mode: GitPanelViewMode, + tree_expanded_dirs: HashMap, entries_indices: HashMap, single_staged_entry: Option, single_tracked_entry: Option, @@ -746,24 +808,39 @@ impl GitPanel { let focus_handle = cx.focus_handle(); 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_sort_by = GitPanelSettings::get_global(cx).sort_by; + let mut was_group_by = GitPanelSettings::get_global(cx).group_by; let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; let mut was_file_icons = GitPanelSettings::get_global(cx).file_icons; let mut was_folder_icons = GitPanelSettings::get_global(cx).folder_icons; let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats; cx.observe_global_in::(window, move |this, window, cx| { let settings = GitPanelSettings::get_global(cx); - let sort_by_path = settings.sort_by_path; + let sort_by = settings.sort_by; + let group_by = settings.group_by; let tree_view = settings.tree_view; let file_icons = settings.file_icons; let folder_icons = settings.folder_icons; let diff_stats = settings.diff_stats; if tree_view != was_tree_view { - this.view_mode = GitPanelViewMode::from_settings(cx); + match (&mut this.view_mode, tree_view) { + (GitPanelViewMode::Tree(state), false) => { + this.tree_expanded_dirs = state.expanded_dirs.clone(); + this.view_mode = GitPanelViewMode::Flat; + } + (GitPanelViewMode::Flat, true) => { + this.view_mode = GitPanelViewMode::Tree(TreeViewState { + expanded_dirs: this.tree_expanded_dirs.clone(), + ..Default::default() + }); + } + _ => {} + } } let mut update_entries = false; - if sort_by_path != was_sort_by_path || tree_view != was_tree_view { + if sort_by != was_sort_by || group_by != was_group_by || tree_view != was_tree_view + { this.bulk_staging.take(); update_entries = true; } @@ -773,7 +850,8 @@ impl GitPanel { if file_icons != was_file_icons || folder_icons != was_folder_icons { cx.notify(); } - was_sort_by_path = sort_by_path; + was_sort_by = sort_by; + was_group_by = group_by; was_tree_view = tree_view; was_file_icons = file_icons; was_folder_icons = folder_icons; @@ -846,6 +924,7 @@ impl GitPanel { generate_commit_message_task: None, entries: Vec::new(), view_mode: GitPanelViewMode::from_settings(cx), + tree_expanded_dirs: HashMap::default(), entries_indices: HashMap::default(), focus_handle: cx.focus_handle(), fs, @@ -938,8 +1017,8 @@ impl GitPanel { path: RepoPath::from_rel_path(dir), }; - if tree_state.expanded_dirs.get(&key) == Some(&false) { - tree_state.expanded_dirs.insert(key, true); + if tree_state.expanded_dirs.get(&key.path) == Some(&false) { + tree_state.expanded_dirs.insert(key.path.clone(), true); needs_rebuild = true; } @@ -3477,20 +3556,56 @@ impl GitPanel { cx.notify(); } - fn toggle_sort_by_path( - &mut self, - _: &ToggleSortByPath, - _: &mut Window, - cx: &mut Context, - ) { - let current_setting = GitPanelSettings::get_global(cx).sort_by_path; + fn set_sort_by_path(&mut self, _: &SetSortByPath, _: &mut Window, cx: &mut Context) { 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().sort_by_path = - Some(!current_setting); + settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Path); + }); + }); + } + } + + fn set_sort_by_name(&mut self, _: &SetSortByName, _: &mut Window, cx: &mut Context) { + 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().sort_by = Some(GitPanelSortBy::Name); + }); + }); + } + } + + fn set_group_by_none(&mut self, _: &SetGroupByNone, _: &mut Window, cx: &mut Context) { + 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().group_by = + Some(GitPanelGroupBy::None); + }); + }); + } + } + + fn set_group_by_status( + &mut self, + _: &SetGroupByStatus, + _: &mut Window, + cx: &mut Context, + ) { + 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().group_by = + Some(GitPanelGroupBy::Status); }); }); } @@ -3559,8 +3674,9 @@ impl GitPanel { 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); + let expanded = state.expanded_dirs.entry(key.path.clone()).or_insert(true); *expanded = !*expanded; + self.tree_expanded_dirs = state.expanded_dirs.clone(); self.update_visible_entries(window, cx); } else { util::debug_panic!("Attempted to toggle directory in flat Git Panel state"); @@ -3718,9 +3834,10 @@ impl GitPanel { self.max_width_item_index = None; self.git_access = GitAccess::Yes; - let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let settings = GitPanelSettings::get_global(cx); + let sort_by = settings.sort_by; + let group_by_status = settings.group_by == GitPanelGroupBy::Status; let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); - let group_by_status = is_tree_view || !sort_by_path; if let Some(active_repo) = self.active_repository.as_ref() { let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx)); @@ -3831,6 +3948,22 @@ impl GitPanel { self.single_tracked_entry = changed_entries.first().cloned(); } + if !is_tree_view { + let sort_entries = |entries: &mut Vec| match sort_by { + GitPanelSortBy::Path => entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)), + GitPanelSortBy::Name => entries.sort_by(|a, b| { + a.repo_path + .file_name() + .cmp(&b.repo_path.file_name()) + .then_with(|| a.repo_path.cmp(&b.repo_path)) + }), + }; + + sort_entries(&mut conflict_entries); + sort_entries(&mut changed_entries); + sort_entries(&mut new_entries); + } + let mut push_entry = |this: &mut Self, entry: GitListEntry, @@ -3881,12 +4014,14 @@ impl GitPanel { continue; } - push_entry( - self, - GitListEntry::Header(GitHeaderEntry { header: section }), - true, - Some(&mut tree_state.logical_indices), - ); + if section != Section::Tracked || group_by_status { + 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, &mut seen_directories) @@ -3900,9 +4035,14 @@ impl GitPanel { } } + let seen_directory_paths = seen_directories + .iter() + .map(|directory| directory.path.clone()) + .collect::>(); tree_state .expanded_dirs - .retain(|key, _| seen_directories.contains(key)); + .retain(|path, _| seen_directory_paths.contains(path)); + self.tree_expanded_dirs = tree_state.expanded_dirs.clone(); self.view_mode = GitPanelViewMode::Tree(tree_state); } GitPanelViewMode::Flat => { @@ -3911,7 +4051,7 @@ impl GitPanel { continue; } - if section != Section::Tracked || !sort_by_path { + if section != Section::Tracked || group_by_status { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), @@ -4291,7 +4431,42 @@ impl GitPanel { has_staged_changes, has_unstaged_changes, has_new_changes, - sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, + sort_by: GitPanelSettings::get_global(cx).sort_by, + group_by: GitPanelSettings::get_global(cx).group_by, + has_stash_items, + tree_view: GitPanelSettings::get_global(cx).tree_view, + }, + window, + cx, + )) + }) + .anchor(Anchor::TopRight) + } + + fn render_view_options_menu(&self, id: impl Into) -> impl IntoElement { + let focus_handle = self.focus_handle.clone(); + let has_tracked_changes = self.has_tracked_changes(); + let has_staged_changes = self.has_staged_changes(); + let has_unstaged_changes = self.has_unstaged_changes(); + let has_new_changes = self.new_count > 0; + let has_stash_items = self.stash_entries.entries.len() > 0; + + PopoverMenu::new(id.into()) + .trigger( + IconButton::new("view-options-menu-trigger", IconName::Sliders) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("View Options")), + ) + .menu(move |window, cx| { + Some(git_panel_view_options_menu( + focus_handle.clone(), + GitMenuState { + has_tracked_changes, + has_staged_changes, + has_unstaged_changes, + has_new_changes, + sort_by: GitPanelSettings::get_global(cx).sort_by, + group_by: GitPanelSettings::get_global(cx).group_by, has_stash_items, tree_view: GitPanelSettings::get_global(cx).tree_view, }, @@ -4620,6 +4795,7 @@ impl GitPanel { .child( h_flex() .gap_1() + .child(self.render_view_options_menu("view_options_menu")) .child(self.render_ellipsis_menu("overflow_menu")) .child( Button::new("stage_unstage_all", text) @@ -6063,7 +6239,8 @@ impl GitPanel { has_staged_changes: self.has_staged_changes(), has_unstaged_changes: self.has_unstaged_changes(), has_new_changes: self.new_count > 0, - sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, + sort_by: GitPanelSettings::get_global(cx).sort_by, + group_by: GitPanelSettings::get_global(cx).group_by, has_stash_items: self.stash_entries.entries.len() > 0, tree_view: GitPanelSettings::get_global(cx).tree_view, }, @@ -6782,7 +6959,10 @@ impl Render for GitPanel { .when(has_write_access && has_co_authors, |git_panel| { 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::set_sort_by_path)) + .on_action(cx.listener(Self::set_sort_by_name)) + .on_action(cx.listener(Self::set_group_by_none)) + .on_action(cx.listener(Self::set_group_by_status)) .on_action(cx.listener(Self::toggle_tree_view)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) @@ -7766,7 +7946,7 @@ mod tests { cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Path); }) }); }); @@ -8377,7 +8557,8 @@ mod tests { cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::None); }) }); }); @@ -8688,13 +8869,14 @@ mod tests { let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); let panel = workspace.update_in(cx, GitPanel::new); - // Enable the `sort_by_path` setting and wait for entries to be updated, + // Disable status grouping and wait for entries to be updated, // as there should no longer be separators between Tracked and Untracked // files. cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::None); }) }); }); @@ -8724,6 +8906,116 @@ mod tests { }); } + #[gpui::test] + async fn test_tree_view_without_status_grouping_combines_statuses(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}", + "utils.rs": "pub fn util() {}", + }, + "tests": { + "main_test.rs": "#[test] fn test_main() {}", + }, + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("src/main.rs", StatusCode::Modified.worktree()), + ("src/utils.rs", FileStatus::Untracked), + ("tests/main_test.rs", StatusCode::Modified.worktree()), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let git_panel = settings.git_panel.get_or_insert_default(); + git_panel.tree_view = Some(true); + git_panel.group_by = Some(GitPanelGroupBy::None); + }) + }); + }); + + let panel = workspace.update_in(cx, GitPanel::new); + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + panel.read_with(cx, |panel, _| { + assert!( + panel + .entries + .iter() + .all(|entry| !matches!(entry, GitListEntry::Header(_))), + "status headers should not be shown when grouping is disabled", + ); + + let tree_state = panel + .view_mode + .tree_state() + .expect("tree view state should exist"); + let src_key = panel + .entries + .iter() + .find_map(|entry| match entry { + GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => { + Some(&dir.key) + } + _ => None, + }) + .expect("src directory should exist in tree view"); + let src_descendants = tree_state + .directory_descendants + .get(src_key) + .expect("src descendants should be tracked"); + + assert!( + src_descendants + .iter() + .any(|entry| entry.repo_path == repo_path("src/main.rs")) + ); + assert!( + src_descendants + .iter() + .any(|entry| entry.repo_path == repo_path("src/utils.rs")) + ); + }); + } + #[gpui::test] async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path( cx: &mut TestAppContext, @@ -8816,7 +9108,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(false)); }); let worktree_id = @@ -8835,7 +9127,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true)); + assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(true)); let selected_ix = panel.selected_entry.expect("selection should be set"); assert!(state.logical_indices.contains(&selected_ix)); @@ -8944,7 +9236,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&foo_key.path).copied(), Some(false)); let foo_idx = panel .entries diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index caf4b22dc45..5d104d3fc78 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -2,7 +2,7 @@ use editor::{EditorSettings, ui_scrollbar_settings_from_raw}; use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{RegisterSetting, Settings, StatusStyle}; +use settings::{GitPanelGroupBy, GitPanelSortBy, RegisterSetting, Settings, StatusStyle}; use ui::{ px, scrollbars::{ScrollbarVisibility, ShowScrollbar}, @@ -24,7 +24,8 @@ pub struct GitPanelSettings { pub folder_icons: bool, pub scrollbar: ScrollbarSettings, pub fallback_branch_name: String, - pub sort_by_path: bool, + pub sort_by: GitPanelSortBy, + pub group_by: GitPanelGroupBy, pub collapse_untracked_diff: bool, pub tree_view: bool, pub diff_stats: bool, @@ -71,7 +72,8 @@ impl Settings for GitPanelSettings { .map(ui_scrollbar_settings_from_raw), }, fallback_branch_name: git_panel.fallback_branch_name.unwrap(), - sort_by_path: git_panel.sort_by_path.unwrap(), + sort_by: git_panel.sort_by.unwrap(), + group_by: git_panel.group_by.unwrap(), collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), tree_view: git_panel.tree_view.unwrap(), diff_stats: git_panel.diff_stats.unwrap(), diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index cd929c22e67..9da57975bea 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -33,7 +33,7 @@ use project::{ branch_diff::{self, BranchDiffEvent, DiffBase}, }, }; -use settings::{Settings, SettingsStore}; +use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; use std::any::{Any, TypeId}; use std::collections::BTreeMap; use std::sync::Arc; @@ -92,10 +92,6 @@ pub struct ProjectDiff { _subscription: Subscription, } -const CONFLICT_SORT_PREFIX: u64 = 1; -const TRACKED_SORT_PREFIX: u64 = 2; -const NEW_SORT_PREFIX: u64 = 3; - impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); @@ -575,14 +571,20 @@ impl ProjectDiff { }, ); - let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; + let mut was_group_by = GitPanelSettings::get_global(cx).group_by; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; let mut was_collapse_untracked_diff = GitPanelSettings::get_global(cx).collapse_untracked_diff; cx.observe_global_in::(window, move |this, window, cx| { - let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - let is_collapse_untracked_diff = - GitPanelSettings::get_global(cx).collapse_untracked_diff; - if is_sort_by_path != was_sort_by_path + let settings = GitPanelSettings::get_global(cx); + let sort_by = settings.sort_by; + let group_by = settings.group_by; + let tree_view = settings.tree_view; + let is_collapse_untracked_diff = settings.collapse_untracked_diff; + if sort_by != was_sort_by + || group_by != was_group_by + || tree_view != was_tree_view || is_collapse_untracked_diff != was_collapse_untracked_diff { this._task = { @@ -592,7 +594,9 @@ impl ProjectDiff { }) } } - was_sort_by_path = is_sort_by_path; + was_sort_by = sort_by; + was_group_by = group_by; + was_tree_view = tree_view; was_collapse_untracked_diff = is_collapse_untracked_diff; }) .detach(); @@ -634,7 +638,7 @@ impl ProjectDiff { return; }; let repo = git_repo.read(cx); - let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx); + let sort_prefix = project_diff_sort_prefix(repo, &entry.repo_path, entry.status, cx); let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone()); self.move_to_path(path_key, window, cx) @@ -660,7 +664,7 @@ impl ProjectDiff { .status_for_path(&repo_path) .map(|entry| entry.status) .unwrap_or(FileStatus::Untracked); - let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx); + let sort_prefix = project_diff_sort_prefix(&git_repo.read(cx), &repo_path, status, cx); let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone()); self.move_to_path(path_key, window, cx) } @@ -817,7 +821,7 @@ impl ProjectDiff { conflict_set: Entity, window: &mut Window, cx: &mut Context, - ) -> Option { + ) -> (BufferId, bool) { let diff_subscription = cx.subscribe_in(&diff, window, { let path_key = path_key.clone(); let buffer = buffer.clone(); @@ -890,7 +894,8 @@ impl ProjectDiff { } }; - let mut needs_fold = None; + let buffer_id = snapshot.text.remote_id(); + let mut needs_fold = false; let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); @@ -927,7 +932,7 @@ impl ProjectDiff { || (file_status.is_untracked() && GitPanelSettings::get_global(cx).collapse_untracked_diff)) { - needs_fold = Some(snapshot.text.remote_id()); + needs_fold = true; } }) }); @@ -949,7 +954,7 @@ impl ProjectDiff { self.move_to_path(path_key, window, cx); } - needs_fold + (buffer_id, needs_fold) } fn buffer_ranges_changed( @@ -990,14 +995,38 @@ impl ProjectDiff { .buffers_with_paths() .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) .collect::>(); + let previous_folded_paths = { + let snapshot = this.multibuffer.read(cx).snapshot(cx); + let rhs_editor = this.editor.read(cx).rhs_editor().clone(); + snapshot + .buffers_with_paths() + .map(|(buffer_snapshot, path_key)| { + ( + path_key.path.clone(), + rhs_editor + .read(cx) + .is_buffer_folded(buffer_snapshot.remote_id(), cx), + ) + }) + .collect::>() + }; let mut entries = BTreeMap::new(); if let Some(repo) = repo { let repo = repo.read(cx); + let sort_prefixes = project_diff_sort_prefixes( + &repo, + buffers_to_load + .iter() + .map(|diff_buffer| (&diff_buffer.repo_path, diff_buffer.file_status)), + cx, + ); for diff_buffer in buffers_to_load { - let sort_prefix = - sort_prefix(&repo, &diff_buffer.repo_path, diff_buffer.file_status, cx); + let sort_prefix = sort_prefixes + .get(&diff_buffer.repo_path) + .copied() + .unwrap_or(u64::MAX); let path_key = PathKey::with_sort_prefix( sort_prefix, diff_buffer.repo_path.as_ref().clone(), @@ -1019,10 +1048,12 @@ impl ProjectDiff { } }); - entries + (entries, previous_folded_paths) })?; + let (entries, previous_folded_paths) = entries; let mut buffers_to_fold = Vec::new(); + let mut buffers_to_unfold = Vec::new(); for (path_key, entry) in entries { if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() { @@ -1031,7 +1062,8 @@ impl ProjectDiff { yield_now().await; cx.update(|window, cx| { this.update(cx, |this, cx| { - if let Some(buffer_id) = this.register_buffer( + let path = path_key.path.clone(); + let (buffer_id, needs_fold) = this.register_buffer( path_key, entry.file_status, buffer, @@ -1039,7 +1071,14 @@ impl ProjectDiff { conflict_set, window, cx, - ) { + ); + if let Some(was_folded) = previous_folded_paths.get(&path) { + if *was_folded { + buffers_to_fold.push(buffer_id); + } else { + buffers_to_unfold.push(buffer_id); + } + } else if needs_fold { buffers_to_fold.push(buffer_id); } }) @@ -1055,6 +1094,15 @@ impl ProjectDiff { .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); }); } + if !buffers_to_unfold.is_empty() { + this.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + for buffer_id in buffers_to_unfold { + editor.unfold_buffer(buffer_id, cx); + } + }); + }); + } this.pending_scroll.take(); cx.notify(); })?; @@ -1085,17 +1133,125 @@ impl ProjectDiff { } } -fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { - let settings = GitPanelSettings::get_global(cx); +fn project_diff_sort_prefix( + repo: &Repository, + repo_path: &RepoPath, + status: FileStatus, + cx: &App, +) -> u64 { + let mut entries = repo + .cached_status() + .map(|entry| (entry.repo_path.clone(), entry.status)) + .collect::>(); + if !entries + .iter() + .any(|(entry_path, _status)| entry_path == repo_path) + { + entries.push((repo_path.clone(), status)); + } - if settings.sort_by_path && !settings.tree_view { - TRACKED_SORT_PREFIX - } else if repo.had_conflict_on_last_merge_head_change(repo_path) { - CONFLICT_SORT_PREFIX - } else if status.is_created() { - NEW_SORT_PREFIX - } else { - TRACKED_SORT_PREFIX + project_diff_sort_prefixes( + repo, + entries.iter().map(|(path, status)| (path, *status)), + cx, + ) + .get(repo_path) + .copied() + .unwrap_or(u64::MAX) +} + +fn project_diff_sort_prefixes<'a>( + repo: &Repository, + entries: impl IntoIterator, + cx: &App, +) -> HashMap { + let settings = GitPanelSettings::get_global(cx); + let group_by_status = settings.group_by == GitPanelGroupBy::Status; + + let mut conflict_entries = Vec::new(); + let mut tracked_entries = Vec::new(); + let mut new_entries = Vec::new(); + + for (repo_path, status) in entries { + let entry = (repo_path.clone(), status); + if group_by_status && repo.had_conflict_on_last_merge_head_change(repo_path) { + conflict_entries.push(entry); + } else if group_by_status && status.is_created() { + new_entries.push(entry); + } else { + tracked_entries.push(entry); + } + } + + let mut ordered_paths = Vec::new(); + for mut section_entries in [conflict_entries, tracked_entries, new_entries] { + if settings.tree_view { + append_tree_order(&mut ordered_paths, section_entries); + } else { + sort_flat_entries(&mut section_entries, settings.sort_by); + ordered_paths.extend( + section_entries + .into_iter() + .map(|(repo_path, _status)| repo_path), + ); + } + } + + ordered_paths + .into_iter() + .enumerate() + .map(|(index, repo_path)| (repo_path, index as u64 + 1)) + .collect() +} + +fn sort_flat_entries(entries: &mut [(RepoPath, FileStatus)], sort_by: GitPanelSortBy) { + match sort_by { + GitPanelSortBy::Path => entries.sort_by(|(a_path, _), (b_path, _)| a_path.cmp(b_path)), + GitPanelSortBy::Name => entries.sort_by(|(a_path, _), (b_path, _)| { + a_path + .file_name() + .cmp(&b_path.file_name()) + .then_with(|| a_path.cmp(b_path)) + }), + } +} + +fn append_tree_order(ordered_paths: &mut Vec, mut entries: Vec<(RepoPath, FileStatus)>) { + entries.sort_by(|(a_path, _), (b_path, _)| a_path.cmp(b_path)); + + let mut root = ProjectDiffTreeNode::default(); + for (repo_path, _status) in entries { + let components: Vec<&str> = repo_path.components().collect(); + if components.is_empty() { + root.files.push(repo_path); + continue; + } + + let mut current = &mut root; + for (index, component) in components.iter().enumerate() { + if index == components.len() - 1 { + current.files.push(repo_path.clone()); + } else { + current = current.children.entry((*component).into()).or_default(); + } + } + } + + root.append_ordered_paths(ordered_paths); +} + +#[derive(Default)] +struct ProjectDiffTreeNode { + children: BTreeMap, + files: Vec, +} + +impl ProjectDiffTreeNode { + fn append_ordered_paths(self, ordered_paths: &mut Vec) { + for child in self.children.into_values() { + child.append_ordered_paths(ordered_paths); + } + ordered_paths.extend(self.files); } } @@ -2119,7 +2275,7 @@ mod tests { let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()), + PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), window, cx, ); @@ -2140,7 +2296,7 @@ mod tests { let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()), + PathKey::with_sort_prefix(1, rel_path("bar").into_arc()), window, cx, ); @@ -2667,7 +2823,15 @@ mod tests { &editor, cx, &" - ˇnine + ˇone + two + three + four + five + six + seven + eight + nine ten - eleven + ELEVEN @@ -2714,13 +2878,16 @@ mod tests { &editor, cx, &" - one + ˇone - two + TWO three four five - ˇnine + six + seven + eight + nine ten - eleven + ELEVEN diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index fcb0ddcc650..b32a86ca45d 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -678,11 +678,15 @@ pub struct GitPanelSettingsContent { /// Default: main pub fallback_branch_name: Option, - /// Whether to sort entries in the panel by path - /// or by status (the default). + /// How to sort entries in the git panel. /// - /// Default: false - pub sort_by_path: Option, + /// Default: path + pub sort_by: Option, + + /// How to group entries in the git panel. + /// + /// Default: status + pub group_by: Option, /// Whether to collapse untracked files in the diff panel. /// @@ -716,6 +720,48 @@ pub struct GitPanelSettingsContent { pub commit_title_max_length: Option, } +#[derive( + Copy, + Clone, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum GitPanelSortBy { + #[default] + Path, + Name, +} + +#[derive( + Copy, + Clone, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum GitPanelGroupBy { + None, + #[default] + Status, +} + #[derive( Default, Copy, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 83833a4a787..80261bb0da9 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -5776,7 +5776,7 @@ fn panels_page() -> SettingsPage { ] } - fn git_panel_section() -> [SettingsPageItem; 15] { + fn git_panel_section() -> [SettingsPageItem; 16] { [ SettingsPageItem::SectionHeader("Git Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5869,19 +5869,28 @@ fn panels_page() -> SettingsPage { files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "Sort By Path", - description: "Enable to sort entries in the panel by path, disable to sort by status.", + title: "Sort By", + description: "How to sort entries in the git panel.", field: Box::new(SettingField { organization_override: None, - json_path: Some("git_panel.sort_by_path"), - pick: |settings_content| { - settings_content.git_panel.as_ref()?.sort_by_path.as_ref() - }, + json_path: Some("git_panel.sort_by"), + pick: |settings_content| settings_content.git_panel.as_ref()?.sort_by.as_ref(), write: |settings_content, value, _| { - settings_content - .git_panel - .get_or_insert_default() - .sort_by_path = value; + settings_content.git_panel.get_or_insert_default().sort_by = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Group By", + description: "How to group entries in the git panel.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("git_panel.group_by"), + pick: |settings_content| settings_content.git_panel.as_ref()?.group_by.as_ref(), + write: |settings_content, value, _| { + settings_content.git_panel.get_or_insert_default().group_by = value; }, }), metadata: None, diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 8e98af5cca4..6e45adc0f4b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -600,6 +600,8 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index c78e7c92eb0..1045c09996a 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5386,7 +5386,8 @@ See the [debugger page](../debugger.md) for more information about debugging sup "default_width": 360, "status_style": "icon", "fallback_branch_name": "main", - "sort_by_path": false, + "sort_by": "path", + "group_by": "status", "collapse_untracked_diff": false, "scrollbar": { "show": null @@ -5403,7 +5404,8 @@ See the [debugger page](../debugger.md) for more information about debugging sup - `default_width`: Default width of the git panel - `status_style`: How to display git status. Can be `label_color` or `icon` - `fallback_branch_name`: What branch name to use if `init.defaultBranch` is not set -- `sort_by_path`: Whether to sort entries in the panel by path or by status (the default) +- `sort_by`: How to sort entries in the git panel. Can be `path` or `name` +- `group_by`: How to group entries in the git panel. Can be `none` or `status` - `collapse_untracked_diff`: Whether to collapse untracked files in the diff panel - `scrollbar`: When to show the scrollbar in the git panel - `starts_open`: Whether the git panel should open on startup diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index c05a4916a86..bff03dea5dd 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -559,7 +559,8 @@ See [Terminal settings](./reference/all-settings.md#terminal) for additional non "dock": "left", // Where to dock: left, right "default_width": 360, // Default width of the git panel. "status_style": "icon", // label_color, icon - "sort_by_path": false, // Sort by path (false) or status (true) + "sort_by": "path", // path, name + "group_by": "status", // none, status "scrollbar": { "show": null // Show/hide: (auto, system, always, never) }