diff --git a/assets/settings/default.json b/assets/settings/default.json index f1b8d9e76bc600de6fd41834c08f40a9b2d51b42..6a04adf88e4593b4e04eda9a0bf64525293b2b0f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -742,6 +742,16 @@ // "never" "show": "always" }, + // Sort order for entries in the project panel. + // This setting can take three values: + // + // 1. Show directories first, then files: + // "directories_first" + // 2. Mix directories and files together: + // "mixed" + // 3. Show files first, then directories: + // "files_first" + "sort_mode": "directories_first", // Whether to enable drag-and-drop operations in the project panel. "drag_and_drop": true, // Whether to hide the root entry when only one folder is open in the window. diff --git a/crates/project_panel/benches/sorting.rs b/crates/project_panel/benches/sorting.rs index 73d92ccd4913a008020a1480422c020117a723ca..df6740d346631fcd7745df44f32a5ed39fbdb521 100644 --- a/crates/project_panel/benches/sorting.rs +++ b/crates/project_panel/benches/sorting.rs @@ -1,13 +1,15 @@ use criterion::{Criterion, criterion_group, criterion_main}; use project::{Entry, EntryKind, GitEntry, ProjectEntryId}; -use project_panel::par_sort_worktree_entries; +use project_panel::par_sort_worktree_entries_with_mode; +use settings::ProjectPanelSortMode; use std::sync::Arc; use util::rel_path::RelPath; fn load_linux_repo_snapshot() -> Vec { - let file = std::fs::read_to_string( - "/Users/hiro/Projects/zed/crates/project_panel/benches/linux_repo_snapshot.txt", - ) + let file = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/benches/linux_repo_snapshot.txt" + )) .expect("Failed to read file"); file.lines() .filter_map(|line| { @@ -42,10 +44,36 @@ fn load_linux_repo_snapshot() -> Vec { } fn criterion_benchmark(c: &mut Criterion) { let snapshot = load_linux_repo_snapshot(); + c.bench_function("Sort linux worktree snapshot", |b| { b.iter_batched( || snapshot.clone(), - |mut snapshot| par_sort_worktree_entries(&mut snapshot), + |mut snapshot| { + par_sort_worktree_entries_with_mode( + &mut snapshot, + ProjectPanelSortMode::DirectoriesFirst, + ) + }, + criterion::BatchSize::LargeInput, + ); + }); + + c.bench_function("Sort linux worktree snapshot (Mixed)", |b| { + b.iter_batched( + || snapshot.clone(), + |mut snapshot| { + par_sort_worktree_entries_with_mode(&mut snapshot, ProjectPanelSortMode::Mixed) + }, + criterion::BatchSize::LargeInput, + ); + }); + + c.bench_function("Sort linux worktree snapshot (FilesFirst)", |b| { + b.iter_batched( + || snapshot.clone(), + |mut snapshot| { + par_sort_worktree_entries_with_mode(&mut snapshot, ProjectPanelSortMode::FilesFirst) + }, criterion::BatchSize::LargeInput, ); }); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index eef1fab802180aaa3681a9d6ca3c2e3156c930b1..d1c3c96c0ccd02d9696f8bfcedfd5af6e6e1da45 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -703,6 +703,9 @@ impl ProjectPanel { if project_panel_settings.hide_hidden != new_settings.hide_hidden { this.update_visible_entries(None, false, false, window, cx); } + if project_panel_settings.sort_mode != new_settings.sort_mode { + this.update_visible_entries(None, false, false, window, cx); + } if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll { this.sticky_items_count = 0; } @@ -2102,7 +2105,8 @@ impl ProjectPanel { .map(|entry| entry.to_owned()) .collect(); - sort_worktree_entries(&mut siblings); + let mode = ProjectPanelSettings::get_global(cx).sort_mode; + sort_worktree_entries_with_mode(&mut siblings, mode); let sibling_entry_index = siblings .iter() .position(|sibling| sibling.id == latest_entry.id)?; @@ -3229,6 +3233,7 @@ impl ProjectPanel { let settings = ProjectPanelSettings::get_global(cx); let auto_collapse_dirs = settings.auto_fold_dirs; let hide_gitignore = settings.hide_gitignore; + let sort_mode = settings.sort_mode; let project = self.project.read(cx); let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx); @@ -3440,7 +3445,10 @@ impl ProjectPanel { entry_iter.advance(); } - par_sort_worktree_entries(&mut visible_worktree_entries); + par_sort_worktree_entries_with_mode( + &mut visible_worktree_entries, + sort_mode, + ); new_state.visible_entries.push(VisibleEntriesForWorktree { worktree_id, entries: visible_worktree_entries, @@ -6101,21 +6109,42 @@ impl ClipboardEntry { } } -fn cmp>(lhs: T, rhs: T) -> cmp::Ordering { - let entry_a = lhs.as_ref(); - let entry_b = rhs.as_ref(); - util::paths::compare_rel_paths( - (&entry_a.path, entry_a.is_file()), - (&entry_b.path, entry_b.is_file()), - ) +#[inline] +fn cmp_directories_first(a: &Entry, b: &Entry) -> cmp::Ordering { + util::paths::compare_rel_paths((&a.path, a.is_file()), (&b.path, b.is_file())) +} + +#[inline] +fn cmp_mixed(a: &Entry, b: &Entry) -> cmp::Ordering { + util::paths::compare_rel_paths_mixed((&a.path, a.is_file()), (&b.path, b.is_file())) +} + +#[inline] +fn cmp_files_first(a: &Entry, b: &Entry) -> cmp::Ordering { + util::paths::compare_rel_paths_files_first((&a.path, a.is_file()), (&b.path, b.is_file())) +} + +#[inline] +fn cmp_with_mode(a: &Entry, b: &Entry, mode: &settings::ProjectPanelSortMode) -> cmp::Ordering { + match mode { + settings::ProjectPanelSortMode::DirectoriesFirst => cmp_directories_first(a, b), + settings::ProjectPanelSortMode::Mixed => cmp_mixed(a, b), + settings::ProjectPanelSortMode::FilesFirst => cmp_files_first(a, b), + } } -pub fn sort_worktree_entries(entries: &mut [impl AsRef]) { - entries.sort_by(|lhs, rhs| cmp(lhs, rhs)); +pub fn sort_worktree_entries_with_mode( + entries: &mut [impl AsRef], + mode: settings::ProjectPanelSortMode, +) { + entries.sort_by(|lhs, rhs| cmp_with_mode(lhs.as_ref(), rhs.as_ref(), &mode)); } -pub fn par_sort_worktree_entries(entries: &mut Vec) { - entries.par_sort_by(|lhs, rhs| cmp(lhs, rhs)); +pub fn par_sort_worktree_entries_with_mode( + entries: &mut Vec, + mode: settings::ProjectPanelSortMode, +) { + entries.par_sort_by(|lhs, rhs| cmp_with_mode(lhs, rhs, &mode)); } #[cfg(test)] diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 266ab761a103fa4ca2a2e9a4e09b96514bfd25c1..b0316270340203177278edebaececd0d86e39869 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -3,8 +3,8 @@ use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DockSide, ProjectPanelEntrySpacing, RegisterSetting, Settings, ShowDiagnostics, - ShowIndentGuides, + DockSide, ProjectPanelEntrySpacing, ProjectPanelSortMode, RegisterSetting, Settings, + ShowDiagnostics, ShowIndentGuides, }; use ui::{ px, @@ -33,6 +33,7 @@ pub struct ProjectPanelSettings { pub hide_hidden: bool, pub drag_and_drop: bool, pub auto_open: AutoOpenSettings, + pub sort_mode: ProjectPanelSortMode, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -115,6 +116,9 @@ impl Settings for ProjectPanelSettings { on_drop: auto_open.on_drop.unwrap(), } }, + sort_mode: project_panel + .sort_mode + .unwrap_or(ProjectPanelSortMode::DirectoriesFirst), } } } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index eb4c6280ccfb76134767f1de70112106a0594dc6..a85ba36c5297d7f40eb08ff42ddf086408a01316 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -326,6 +326,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { ProjectPanelSettings::override_global( ProjectPanelSettings { auto_fold_dirs: true, + sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst, ..settings }, cx, @@ -7704,6 +7705,215 @@ fn visible_entries_as_strings( result } +/// Test that missing sort_mode field defaults to DirectoriesFirst +#[gpui::test] +async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) { + init_test(cx); + + // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst + let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx)); + assert_eq!( + default_settings.sort_mode, + settings::ProjectPanelSortMode::DirectoriesFirst, + "sort_mode should default to DirectoriesFirst" + ); +} + +/// Test sort modes: DirectoriesFirst (default) vs Mixed +#[gpui::test] +async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "zebra.txt": "", + "Apple": {}, + "banana.rs": "", + "Carrot": {}, + "aardvark.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + cx.run_until_parked(); + + // Default sort mode should be DirectoriesFirst + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root", + " > Apple", + " > Carrot", + " aardvark.txt", + " banana.rs", + " zebra.txt", + ] + ); +} + +#[gpui::test] +async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "Zebra.txt": "", + "apple": {}, + "Banana.rs": "", + "carrot": {}, + "Aardvark.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Switch to Mixed mode + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project_panel.get_or_insert_default().sort_mode = + Some(settings::ProjectPanelSortMode::Mixed); + }); + }); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + cx.run_until_parked(); + + // Mixed mode: case-insensitive sorting + // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive) + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root", + " Aardvark.txt", + " > apple", + " Banana.rs", + " > carrot", + " Zebra.txt", + ] + ); +} + +#[gpui::test] +async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "Zebra.txt": "", + "apple": {}, + "Banana.rs": "", + "carrot": {}, + "Aardvark.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Switch to FilesFirst mode + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project_panel.get_or_insert_default().sort_mode = + Some(settings::ProjectPanelSortMode::FilesFirst); + }); + }); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + cx.run_until_parked(); + + // FilesFirst mode: files first, then directories (both case-insensitive) + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root", + " Aardvark.txt", + " Banana.rs", + " Zebra.txt", + " > apple", + " > carrot", + ] + ); +} + +#[gpui::test] +async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file2.txt": "", + "dir1": {}, + "file1.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + cx.run_until_parked(); + + // Initially DirectoriesFirst + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &["v root", " > dir1", " file1.txt", " file2.txt",] + ); + + // Toggle to Mixed + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project_panel.get_or_insert_default().sort_mode = + Some(settings::ProjectPanelSortMode::Mixed); + }); + }); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &["v root", " > dir1", " file1.txt", " file2.txt",] + ); + + // Toggle back to DirectoriesFirst + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project_panel.get_or_insert_default().sort_mode = + Some(settings::ProjectPanelSortMode::DirectoriesFirst); + }); + }); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &["v root", " > dir1", " file1.txt", " file2.txt",] + ); +} + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/settings/src/settings_content/workspace.rs b/crates/settings/src/settings_content/workspace.rs index 01c40528cb4a9b614270efbbf0d39b1b424bb7dc..fc4c7fdbda553c2a959ba1062ee0f43d675b2f54 100644 --- a/crates/settings/src/settings_content/workspace.rs +++ b/crates/settings/src/settings_content/workspace.rs @@ -609,6 +609,10 @@ pub struct ProjectPanelSettingsContent { pub drag_and_drop: Option, /// Settings for automatically opening files. pub auto_open: Option, + /// How to order sibling entries in the project panel. + /// + /// Default: directories_first + pub sort_mode: Option, } #[derive( @@ -634,6 +638,31 @@ pub enum ProjectPanelEntrySpacing { Standard, } +#[derive( + Copy, + Clone, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ProjectPanelSortMode { + /// Show directories first, then files + #[default] + DirectoriesFirst, + /// Mix directories and files together + Mixed, + /// Show files first, then directories + FilesFirst, +} + #[skip_serializing_none] #[derive( Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0de37b5daecadb6d8da42d553bffa30d1ffeb1a7..4b87d6f5f30c075f90a6da698396bc4576a56b92 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -668,6 +668,7 @@ impl VsCodeSettings { show_diagnostics: self .read_bool("problems.decorations.enabled") .and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }), + sort_mode: None, starts_open: None, sticky_scroll: None, auto_open: None, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 611ce10a75e5e2e52c28b88d6583108a006e63b3..d776b9787eb804a77f2d4a6b6c605846eb6604ea 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3822,6 +3822,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Sort Mode", + description: "Sort order for entries in the project panel.", + field: Box::new(SettingField { + pick: |settings_content| { + settings_content.project_panel.as_ref()?.sort_mode.as_ref() + }, + write: |settings_content, value| { + settings_content + .project_panel + .get_or_insert_default() + .sort_mode = value; + }, + json_path: Some("project_panel.sort_mode"), + }), + metadata: None, + files: USER, + }), SettingsPageItem::SectionHeader("Terminal Panel"), SettingsPageItem::SettingItem(SettingItem { title: "Terminal Dock", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 3911a4e0cd3023524df9e023cfdc670fc7c24a8a..329679bccd4c10aed3398ac60a6c05f7922d9a9f 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -451,6 +451,7 @@ 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) diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index d4081d2edc113b58795845680feb00f703622364..5813c444af555dc90c65ce6f1584067b446cc79b 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -944,36 +944,47 @@ pub fn natural_sort(a: &str, b: &str) -> Ordering { } } +/// Case-insensitive natural sort without applying the final lowercase/uppercase tie-breaker. +/// This is useful when comparing individual path components where we want to keep walking +/// deeper components before deciding on casing. +fn natural_sort_no_tiebreak(a: &str, b: &str) -> Ordering { + if a.eq_ignore_ascii_case(b) { + Ordering::Equal + } else { + natural_sort(a, b) + } +} + +fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) { + if filename.is_empty() { + return (None, None); + } + + match filename.rsplit_once('.') { + // Case 1: No dot was found. The entire name is the stem. + None => (Some(filename), None), + + // Case 2: A dot was found. + Some((before, after)) => { + // This is the crucial check for dotfiles like ".bashrc". + // If `before` is empty, the dot was the first character. + // In that case, we revert to the "whole name is the stem" logic. + if before.is_empty() { + (Some(filename), None) + } else { + // Otherwise, we have a standard stem and extension. + (Some(before), Some(after)) + } + } + } +} + pub fn compare_rel_paths( (path_a, a_is_file): (&RelPath, bool), (path_b, b_is_file): (&RelPath, bool), ) -> Ordering { let mut components_a = path_a.components(); let mut components_b = path_b.components(); - - fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) { - if filename.is_empty() { - return (None, None); - } - - match filename.rsplit_once('.') { - // Case 1: No dot was found. The entire name is the stem. - None => (Some(filename), None), - - // Case 2: A dot was found. - Some((before, after)) => { - // This is the crucial check for dotfiles like ".bashrc". - // If `before` is empty, the dot was the first character. - // In that case, we revert to the "whole name is the stem" logic. - if before.is_empty() { - (Some(filename), None) - } else { - // Otherwise, we have a standard stem and extension. - (Some(before), Some(after)) - } - } - } - } loop { match (components_a.next(), components_b.next()) { (Some(component_a), Some(component_b)) => { @@ -1020,6 +1031,156 @@ pub fn compare_rel_paths( } } +/// Compare two relative paths with mixed files and directories using +/// case-insensitive natural sorting. For example, "Apple", "aardvark.txt", +/// and "Zebra" would be sorted as: aardvark.txt, Apple, Zebra +/// (case-insensitive alphabetical). +pub fn compare_rel_paths_mixed( + (path_a, a_is_file): (&RelPath, bool), + (path_b, b_is_file): (&RelPath, bool), +) -> Ordering { + let original_paths_equal = std::ptr::eq(path_a, path_b) || path_a == path_b; + let mut components_a = path_a.components(); + let mut components_b = path_b.components(); + + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_leaf_file = a_is_file && components_a.rest().is_empty(); + let b_leaf_file = b_is_file && components_b.rest().is_empty(); + + let (a_stem, a_ext) = a_leaf_file + .then(|| stem_and_extension(component_a)) + .unwrap_or_default(); + let (b_stem, b_ext) = b_leaf_file + .then(|| stem_and_extension(component_b)) + .unwrap_or_default(); + let a_key = if a_leaf_file { + a_stem + } else { + Some(component_a) + }; + let b_key = if b_leaf_file { + b_stem + } else { + Some(component_b) + }; + + let ordering = match (a_key, b_key) { + (Some(a), Some(b)) => natural_sort_no_tiebreak(a, b) + .then_with(|| match (a_leaf_file, b_leaf_file) { + (true, false) if a == b => Ordering::Greater, + (false, true) if a == b => Ordering::Less, + _ => Ordering::Equal, + }) + .then_with(|| { + if a_leaf_file && b_leaf_file { + let a_ext_str = a_ext.unwrap_or_default().to_lowercase(); + let b_ext_str = b_ext.unwrap_or_default().to_lowercase(); + b_ext_str.cmp(&a_ext_str) + } else { + Ordering::Equal + } + }), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => { + // Deterministic tie-break: use natural sort to prefer lowercase when paths + // are otherwise equal but still differ in casing. + if !original_paths_equal { + return natural_sort(path_a.as_unix_str(), path_b.as_unix_str()); + } + return Ordering::Equal; + } + } + } +} + +/// Compare two relative paths with files before directories using +/// case-insensitive natural sorting. At each directory level, all files +/// are sorted before all directories, with case-insensitive alphabetical +/// ordering within each group. +pub fn compare_rel_paths_files_first( + (path_a, a_is_file): (&RelPath, bool), + (path_b, b_is_file): (&RelPath, bool), +) -> Ordering { + let original_paths_equal = std::ptr::eq(path_a, path_b) || path_a == path_b; + let mut components_a = path_a.components(); + let mut components_b = path_b.components(); + + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_leaf_file = a_is_file && components_a.rest().is_empty(); + let b_leaf_file = b_is_file && components_b.rest().is_empty(); + + let (a_stem, a_ext) = a_leaf_file + .then(|| stem_and_extension(component_a)) + .unwrap_or_default(); + let (b_stem, b_ext) = b_leaf_file + .then(|| stem_and_extension(component_b)) + .unwrap_or_default(); + let a_key = if a_leaf_file { + a_stem + } else { + Some(component_a) + }; + let b_key = if b_leaf_file { + b_stem + } else { + Some(component_b) + }; + + let ordering = match (a_key, b_key) { + (Some(a), Some(b)) => { + if a_leaf_file && !b_leaf_file { + Ordering::Less + } else if !a_leaf_file && b_leaf_file { + Ordering::Greater + } else { + natural_sort_no_tiebreak(a, b).then_with(|| { + if a_leaf_file && b_leaf_file { + let a_ext_str = a_ext.unwrap_or_default().to_lowercase(); + let b_ext_str = b_ext.unwrap_or_default().to_lowercase(); + a_ext_str.cmp(&b_ext_str) + } else { + Ordering::Equal + } + }) + } + } + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => { + // Deterministic tie-break: use natural sort to prefer lowercase when paths + // are otherwise equal but still differ in casing. + if !original_paths_equal { + return natural_sort(path_a.as_unix_str(), path_b.as_unix_str()); + } + return Ordering::Equal; + } + } + } +} + pub fn compare_paths( (path_a, a_is_file): (&Path, bool), (path_b, b_is_file): (&Path, bool), @@ -1265,6 +1426,285 @@ mod tests { ); } + #[perf] + fn compare_rel_paths_mixed_case_insensitive() { + // Test that mixed mode is case-insensitive + let mut paths = vec![ + (RelPath::unix("zebra.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("Carrot").unwrap(), false), + (RelPath::unix("aardvark.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + // Case-insensitive: aardvark < Apple < banana < Carrot < zebra + assert_eq!( + paths, + vec![ + (RelPath::unix("aardvark.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("Carrot").unwrap(), false), + (RelPath::unix("zebra.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_basic() { + // Test that files come before directories + let mut paths = vec![ + (RelPath::unix("zebra.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("Carrot").unwrap(), false), + (RelPath::unix("aardvark.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + // Files first (case-insensitive), then directories (case-insensitive) + assert_eq!( + paths, + vec![ + (RelPath::unix("aardvark.txt").unwrap(), true), + (RelPath::unix("banana.rs").unwrap(), true), + (RelPath::unix("zebra.txt").unwrap(), true), + (RelPath::unix("Apple").unwrap(), false), + (RelPath::unix("Carrot").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_case_insensitive() { + // Test case-insensitive sorting within files and directories + let mut paths = vec![ + (RelPath::unix("Zebra.txt").unwrap(), true), + (RelPath::unix("apple").unwrap(), false), + (RelPath::unix("Banana.rs").unwrap(), true), + (RelPath::unix("carrot").unwrap(), false), + (RelPath::unix("Aardvark.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("Aardvark.txt").unwrap(), true), + (RelPath::unix("Banana.rs").unwrap(), true), + (RelPath::unix("Zebra.txt").unwrap(), true), + (RelPath::unix("apple").unwrap(), false), + (RelPath::unix("carrot").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_numeric() { + // Test natural number sorting with files first + let mut paths = vec![ + (RelPath::unix("file10.txt").unwrap(), true), + (RelPath::unix("dir2").unwrap(), false), + (RelPath::unix("file2.txt").unwrap(), true), + (RelPath::unix("dir10").unwrap(), false), + (RelPath::unix("file1.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("file1.txt").unwrap(), true), + (RelPath::unix("file2.txt").unwrap(), true), + (RelPath::unix("file10.txt").unwrap(), true), + (RelPath::unix("dir2").unwrap(), false), + (RelPath::unix("dir10").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_case() { + // Test case-insensitive sorting with varied capitalization + let mut paths = vec![ + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("readme.txt").unwrap(), true), + (RelPath::unix("ReadMe.rs").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + // All "readme" variants should group together, sorted by extension + assert_eq!( + paths, + vec![ + (RelPath::unix("readme.txt").unwrap(), true), + (RelPath::unix("ReadMe.rs").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_files_and_dirs() { + // Verify directories and files are still mixed + let mut paths = vec![ + (RelPath::unix("file2.txt").unwrap(), true), + (RelPath::unix("Dir1").unwrap(), false), + (RelPath::unix("file1.txt").unwrap(), true), + (RelPath::unix("dir2").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + // Case-insensitive: dir1, dir2, file1, file2 (all mixed) + assert_eq!( + paths, + vec![ + (RelPath::unix("Dir1").unwrap(), false), + (RelPath::unix("dir2").unwrap(), false), + (RelPath::unix("file1.txt").unwrap(), true), + (RelPath::unix("file2.txt").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_with_nested_paths() { + // Test that nested paths still work correctly + let mut paths = vec![ + (RelPath::unix("src/main.rs").unwrap(), true), + (RelPath::unix("Cargo.toml").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("target").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("Cargo.toml").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("src/main.rs").unwrap(), true), + (RelPath::unix("target").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_with_nested() { + // Files come before directories, even with nested paths + let mut paths = vec![ + (RelPath::unix("src/lib.rs").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("tests").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + (RelPath::unix("src/lib.rs").unwrap(), true), + (RelPath::unix("tests").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_dotfiles() { + // Test that dotfiles are handled correctly in mixed mode + let mut paths = vec![ + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix("src").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix("src").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_dotfiles() { + // Test that dotfiles come first when they're files + let mut paths = vec![ + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix("src").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix(".gitignore").unwrap(), true), + (RelPath::unix("README.md").unwrap(), true), + (RelPath::unix(".github").unwrap(), false), + (RelPath::unix("src").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_same_stem_different_extension() { + // Files with same stem but different extensions should sort by extension + let mut paths = vec![ + (RelPath::unix("file.rs").unwrap(), true), + (RelPath::unix("file.md").unwrap(), true), + (RelPath::unix("file.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("file.txt").unwrap(), true), + (RelPath::unix("file.rs").unwrap(), true), + (RelPath::unix("file.md").unwrap(), true), + ] + ); + } + + #[perf] + fn compare_rel_paths_files_first_same_stem() { + // Same stem files should still sort by extension with files_first + let mut paths = vec![ + (RelPath::unix("main.rs").unwrap(), true), + (RelPath::unix("main.c").unwrap(), true), + (RelPath::unix("main").unwrap(), false), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("main.c").unwrap(), true), + (RelPath::unix("main.rs").unwrap(), true), + (RelPath::unix("main").unwrap(), false), + ] + ); + } + + #[perf] + fn compare_rel_paths_mixed_deep_nesting() { + // Test sorting with deeply nested paths + let mut paths = vec![ + (RelPath::unix("a/b/c.txt").unwrap(), true), + (RelPath::unix("A/B.txt").unwrap(), true), + (RelPath::unix("a.txt").unwrap(), true), + (RelPath::unix("A.txt").unwrap(), true), + ]; + paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + assert_eq!( + paths, + vec![ + (RelPath::unix("A/B.txt").unwrap(), true), + (RelPath::unix("a/b/c.txt").unwrap(), true), + (RelPath::unix("a.txt").unwrap(), true), + (RelPath::unix("A.txt").unwrap(), true), + ] + ); + } + #[perf] fn path_with_position_parse_posix_path() { // Test POSIX filename edge cases diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 43c36767f0ebb526ec6f12649d0d03b027eab636..13d42a5c4c99f3a4aba3709d829f289e9e9826f8 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -4298,6 +4298,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "indent_guides": { "show": "always" }, + "sort_mode": "directories_first", "hide_root": false, "hide_hidden": false, "starts_open": true, @@ -4514,6 +4515,38 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a } ``` +### Sort Mode + +- Description: Sort order for entries in the project panel +- Setting: `sort_mode` +- Default: `directories_first` + +**Options** + +1. Show directories first, then files + +```json [settings] +{ + "sort_mode": "directories_first" +} +``` + +2. Mix directories and files together + +```json [settings] +{ + "sort_mode": "mixed" +} +``` + +3. Show files first, then directories + +```json [settings] +{ + "sort_mode": "files_first" +} +``` + ### Auto Open - Description: Control whether files are opened automatically after different creation flows in the project panel. diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 98b07797a2f7904acd10fe54b04ab39fe0854667..3e4ff377f3cd54676f0b32f3f4853c9be6de706d 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -457,6 +457,8 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k // When to show indent guides in the project panel. (always, never) "show": "always" }, + // Sort order for entries (directories_first, mixed, files_first) + "sort_mode": "directories_first", // Whether to hide the root entry when only one folder is open in the window. "hide_root": false, // Whether to hide the hidden entries in the project panel.