From 320cef37f897b8618921873c2506c65ed1513ac5 Mon Sep 17 00:00:00 2001 From: Dionys Steffen Date: Wed, 8 Apr 2026 15:03:00 +0200 Subject: [PATCH] project_panel: Add `sort_order` settings (#50221) _(Feature Requests #24962)_ _"Before you mark this PR as ready for review, make sure that you have:"_ * [x] Added a solid test coverage and/or screenshots from doing manual testing * [x] Done a self-review taking into account security and performance aspects * [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Added a `sort_order` to `project_panel` settings which dictates how files and directories are sorted relative to each other in a `sort_mode`. --------- Co-authored-by: Smit Barmase --- assets/settings/default.json | 15 + crates/project_panel/benches/sorting.rs | 60 +- crates/project_panel/src/project_panel.rs | 52 +- .../src/project_panel_settings.rs | 6 +- crates/settings/src/settings_store.rs | 24 + crates/settings/src/vscode_import.rs | 14 +- crates/settings_content/src/workspace.rs | 58 ++ crates/settings_ui/src/page_data.rs | 20 +- crates/settings_ui/src/settings_ui.rs | 1 + crates/util/src/paths.rs | 638 +++++++++++++----- docs/src/reference/all-settings.md | 48 ++ docs/src/visual-customization.md | 6 + 12 files changed, 721 insertions(+), 221 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 8d6f067c9af4e4e02ce1b613911d0dbc59077526..07d2ea111e3b2b9480979a7189094e445b21b655 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -792,6 +792,21 @@ // 3. Show files first, then directories: // "files_first" "sort_mode": "directories_first", + // Whether to sort file and folder names case-sensitively in the project panel. + // This setting can take four values: + // + // 1. Case-insensitive natural sort with lowercase preferred in ties (default): + // "default" + // 2. Uppercase names are grouped before lowercase names, + // with case-insensitive natural sort within each group: + // "upper" + // 3. Lowercase names are grouped before uppercase names, + // with case-insensitive natural sort within each group: + // "lower" + // 4. Pure Unicode codepoint comparison. + // No case folding, no natural number sorting: + // "unicode" + "sort_order": "default", // Whether to show error and warning count badges next to file names in the project panel. "diagnostic_badges": false, // Whether to show the git status indicator next to file names in the project panel. diff --git a/crates/project_panel/benches/sorting.rs b/crates/project_panel/benches/sorting.rs index df6740d346631fcd7745df44f32a5ed39fbdb521..0c3f498fcd20255f1f744dbc07e2a55847469f41 100644 --- a/crates/project_panel/benches/sorting.rs +++ b/crates/project_panel/benches/sorting.rs @@ -1,7 +1,7 @@ use criterion::{Criterion, criterion_group, criterion_main}; use project::{Entry, EntryKind, GitEntry, ProjectEntryId}; -use project_panel::par_sort_worktree_entries_with_mode; -use settings::ProjectPanelSortMode; +use project_panel::par_sort_worktree_entries; +use settings::{ProjectPanelSortMode, ProjectPanelSortOrder}; use std::sync::Arc; use util::rel_path::RelPath; @@ -45,38 +45,32 @@ 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_with_mode( - &mut snapshot, - ProjectPanelSortMode::DirectoriesFirst, - ) - }, - criterion::BatchSize::LargeInput, - ); - }); + let modes = [ + ("DirectoriesFirst", ProjectPanelSortMode::DirectoriesFirst), + ("Mixed", ProjectPanelSortMode::Mixed), + ("FilesFirst", ProjectPanelSortMode::FilesFirst), + ]; + let orders = [ + ("Default", ProjectPanelSortOrder::Default), + ("Upper", ProjectPanelSortOrder::Upper), + ("Lower", ProjectPanelSortOrder::Lower), + ("Unicode", ProjectPanelSortOrder::Unicode), + ]; - 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, - ); - }); + for (mode_name, mode) in &modes { + for (order_name, order) in &orders { + c.bench_function( + &format!("Sort linux worktree snapshot ({mode_name}, {order_name})"), + |b| { + b.iter_batched( + || snapshot.clone(), + |mut snapshot| par_sort_worktree_entries(&mut snapshot, *mode, *order), + criterion::BatchSize::LargeInput, + ); + }, + ); + } + } } criterion_group!(benches, criterion_benchmark); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c2f1bb7131ad31ea75aee84bad17b7971d489a09..afb0d811fa378185f991ffbeb40a5d302ce565a6 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -847,6 +847,9 @@ impl ProjectPanel { if project_panel_settings.sort_mode != new_settings.sort_mode { this.update_visible_entries(None, false, false, window, cx); } + if project_panel_settings.sort_order != new_settings.sort_order { + this.update_visible_entries(None, false, false, window, cx); + } if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll { this.sticky_items_count = 0; } @@ -2491,8 +2494,9 @@ impl ProjectPanel { .map(|entry| entry.to_owned()) .collect(); - let mode = ProjectPanelSettings::get_global(cx).sort_mode; - sort_worktree_entries_with_mode(&mut siblings, mode); + let sort_mode = ProjectPanelSettings::get_global(cx).sort_mode; + let sort_order = ProjectPanelSettings::get_global(cx).sort_order; + sort_worktree_entries(&mut siblings, sort_mode, sort_order); let sibling_entry_index = siblings .iter() .position(|sibling| sibling.id == latest_entry.id)?; @@ -3921,6 +3925,7 @@ impl ProjectPanel { let auto_collapse_dirs = settings.auto_fold_dirs; let hide_gitignore = settings.hide_gitignore; let sort_mode = settings.sort_mode; + let sort_order = settings.sort_order; let project = self.project.read(cx); let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx); @@ -4152,9 +4157,10 @@ impl ProjectPanel { entry_iter.advance(); } - par_sort_worktree_entries_with_mode( + par_sort_worktree_entries( &mut visible_worktree_entries, sort_mode, + sort_order, ); new_state.visible_entries.push(VisibleEntriesForWorktree { worktree_id, @@ -7273,41 +7279,31 @@ impl ClipboardEntry { } #[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), - } +fn cmp_worktree_entries( + a: &Entry, + b: &Entry, + mode: &settings::ProjectPanelSortMode, + order: &settings::ProjectPanelSortOrder, +) -> cmp::Ordering { + let a = (&*a.path, a.is_file()); + let b = (&*b.path, b.is_file()); + util::paths::compare_rel_paths_by(a, b, (*mode).into(), (*order).into()) } -pub fn sort_worktree_entries_with_mode( +pub fn sort_worktree_entries( entries: &mut [impl AsRef], mode: settings::ProjectPanelSortMode, + order: settings::ProjectPanelSortOrder, ) { - entries.sort_by(|lhs, rhs| cmp_with_mode(lhs.as_ref(), rhs.as_ref(), &mode)); + entries.sort_by(|lhs, rhs| cmp_worktree_entries(lhs.as_ref(), rhs.as_ref(), &mode, &order)); } -pub fn par_sort_worktree_entries_with_mode( +pub fn par_sort_worktree_entries( entries: &mut Vec, mode: settings::ProjectPanelSortMode, + order: settings::ProjectPanelSortOrder, ) { - entries.par_sort_by(|lhs, rhs| cmp_with_mode(lhs, rhs, &mode)); + entries.par_sort_by(|lhs, rhs| cmp_worktree_entries(lhs, rhs, &mode, &order)); } fn git_status_indicator(git_status: GitSummary) -> Option<(&'static str, Color)> { diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 64f3ea42928399201c497ba58041ed0bf6ed5ba1..8c464d2880629b920c88f07b9720f5c977640b40 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, ProjectPanelSortMode, RegisterSetting, Settings, - ShowDiagnostics, ShowIndentGuides, + DockSide, ProjectPanelEntrySpacing, ProjectPanelSortMode, ProjectPanelSortOrder, + RegisterSetting, Settings, ShowDiagnostics, ShowIndentGuides, }; use ui::{ px, @@ -35,6 +35,7 @@ pub struct ProjectPanelSettings { pub drag_and_drop: bool, pub auto_open: AutoOpenSettings, pub sort_mode: ProjectPanelSortMode, + pub sort_order: ProjectPanelSortOrder, pub diagnostic_badges: bool, pub git_status_indicator: bool, } @@ -141,6 +142,7 @@ impl Settings for ProjectPanelSettings { } }, sort_mode: project_panel.sort_mode.unwrap(), + sort_order: project_panel.sort_order.unwrap(), diagnostic_badges: project_panel.diagnostic_badges.unwrap(), git_status_indicator: project_panel.git_status_indicator.unwrap(), } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 577ba43e1dd566d32eeec8993ec135633146b020..6e37c22afe0087c54c5574e17275218d8468ae05 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2027,6 +2027,30 @@ mod tests { cx, ); + // explorer sort settings + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ + "explorer.sortOrder": "mixed", + "explorer.sortOrderLexicographicOptions": "lower" + }"# + .unindent(), + r#"{ + "project_panel": { + "sort_mode": "mixed", + "sort_order": "lower" + }, + "base_keymap": "VSCode" + } + "# + .unindent(), + cx, + ); + // font-family check_vscode_import( &mut store, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 4c7ce085aed5ad0cf7c48308b4211815cf5aad75..5ebf0ba6abd1749ef13b9d8fcd26ac8caa608e51 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -804,7 +804,19 @@ impl VsCodeSettings { show_diagnostics: self .read_bool("problems.decorations.enabled") .and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }), - sort_mode: None, + sort_mode: self.read_enum("explorer.sortOrder", |s| match s { + "default" | "foldersNestsFiles" => Some(ProjectPanelSortMode::DirectoriesFirst), + "mixed" => Some(ProjectPanelSortMode::Mixed), + "filesFirst" => Some(ProjectPanelSortMode::FilesFirst), + _ => None, + }), + sort_order: self.read_enum("explorer.sortOrderLexicographicOptions", |s| match s { + "default" => Some(ProjectPanelSortOrder::Default), + "upper" => Some(ProjectPanelSortOrder::Upper), + "lower" => Some(ProjectPanelSortOrder::Lower), + "unicode" => Some(ProjectPanelSortOrder::Unicode), + _ => None, + }), starts_open: None, sticky_scroll: None, auto_open: None, diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 0bae7c260f6607f2015f750e5bb9dec7cc26342d..02ec229cb37bfa39aded1764f0f1c5235e081ba6 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -746,6 +746,12 @@ pub struct ProjectPanelSettingsContent { /// /// Default: directories_first pub sort_mode: Option, + /// Whether to sort file and folder names case-sensitively in the project panel. + /// This works in combination with `sort_mode`. `sort_mode` controls how files and + /// directories are grouped, while this setting controls how names are compared. + /// + /// Default: default + pub sort_order: Option, /// Whether to show error and warning count badges next to file names in the project panel. /// /// Default: false @@ -804,6 +810,58 @@ pub enum ProjectPanelSortMode { FilesFirst, } +#[derive( + Copy, + Clone, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ProjectPanelSortOrder { + /// Case-insensitive natural sort with lowercase preferred in ties. + /// Numbers in file names are compared by value (e.g., `file2` before `file10`). + #[default] + Default, + /// Uppercase names are grouped before lowercase names, with case-insensitive + /// natural sort within each group. Dot-prefixed names sort before both groups. + Upper, + /// Lowercase names are grouped before uppercase names, with case-insensitive + /// natural sort within each group. Dot-prefixed names sort before both groups. + Lower, + /// Pure Unicode codepoint comparison. No case folding, no natural number sorting. + /// Uppercase ASCII sorts before lowercase. Accented characters sort after ASCII. + Unicode, +} + +impl From for util::paths::SortMode { + fn from(mode: ProjectPanelSortMode) -> Self { + match mode { + ProjectPanelSortMode::DirectoriesFirst => Self::DirectoriesFirst, + ProjectPanelSortMode::Mixed => Self::Mixed, + ProjectPanelSortMode::FilesFirst => Self::FilesFirst, + } + } +} + +impl From for util::paths::SortOrder { + fn from(order: ProjectPanelSortOrder) -> Self { + match order { + ProjectPanelSortOrder::Default => Self::Default, + ProjectPanelSortOrder::Upper => Self::Upper, + ProjectPanelSortOrder::Lower => Self::Lower, + ProjectPanelSortOrder::Unicode => Self::Unicode, + } + } +} + #[with_fallible_options] #[derive( Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index ac8636c985b0e3427f6048b5f3133a995a186298..2fa48198dacaf9d9862ffd6e753e0ed735a6ca7b 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4450,7 +4450,7 @@ fn window_and_layout_page() -> SettingsPage { } fn panels_page() -> SettingsPage { - fn project_panel_section() -> [SettingsPageItem; 28] { + fn project_panel_section() -> [SettingsPageItem; 29] { [ SettingsPageItem::SectionHeader("Project Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -4948,6 +4948,24 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Sort Order", + description: "Whether to sort file and folder names case-sensitively in the project panel.", + field: Box::new(SettingField { + pick: |settings_content| { + settings_content.project_panel.as_ref()?.sort_order.as_ref() + }, + write: |settings_content, value| { + settings_content + .project_panel + .get_or_insert_default() + .sort_order = value; + }, + json_path: Some("project_panel.sort_order"), + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Auto Open Files On Create", description: "Whether to automatically open newly created files in the editor.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 4c7a98f6c0fa94e659a6db4e00aa28e2b4516e13..bbe05a3c23113b1faa968fdd9c084f604debc0c4 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -486,6 +486,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 3ff07c67a8d2def75e4e7f756c4a466ea2b68ed0..94877af090fb776b0ee1d066efd28d09c1b327af 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1109,141 +1109,95 @@ fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) { } } -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(); - loop { - match (components_a.next(), components_b.next()) { - (Some(component_a), Some(component_b)) => { - let a_is_file = a_is_file && components_a.rest().is_empty(); - let b_is_file = b_is_file && components_b.rest().is_empty(); - - let ordering = a_is_file.cmp(&b_is_file).then_with(|| { - let (a_stem, a_extension) = a_is_file - .then(|| stem_and_extension(component_a)) - .unwrap_or_default(); - let path_string_a = if a_is_file { a_stem } else { Some(component_a) }; - - let (b_stem, b_extension) = b_is_file - .then(|| stem_and_extension(component_b)) - .unwrap_or_default(); - let path_string_b = if b_is_file { b_stem } else { Some(component_b) }; - - let compare_components = match (path_string_a, path_string_b) { - (Some(a), Some(b)) => natural_sort(&a, &b), - (Some(_), None) => Ordering::Greater, - (None, Some(_)) => Ordering::Less, - (None, None) => Ordering::Equal, - }; +/// Controls the lexicographic sorting of file and folder names. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum SortOrder { + /// Case-insensitive natural sort with lowercase preferred in ties. + /// Numbers in file names are compared by value (e.g., `file2` before `file10`). + #[default] + Default, + /// Uppercase names are grouped before lowercase names, with case-insensitive + /// natural sort within each group. Dot-prefixed names sort before both groups. + Upper, + /// Lowercase names are grouped before uppercase names, with case-insensitive + /// natural sort within each group. Dot-prefixed names sort before both groups. + Lower, + /// Pure Unicode codepoint comparison. No case folding, no natural number sorting. + /// Uppercase ASCII sorts before lowercase. Accented characters sort after ASCII. + Unicode, +} - compare_components.then_with(|| { - if a_is_file && b_is_file { - let ext_a = a_extension.unwrap_or_default(); - let ext_b = b_extension.unwrap_or_default(); - ext_a.cmp(ext_b) - } else { - Ordering::Equal - } - }) - }); +/// Controls how files and directories are ordered relative to each other. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum SortMode { + /// Directories are listed before files at each level. + #[default] + DirectoriesFirst, + /// Files and directories are interleaved alphabetically. + Mixed, + /// Files are listed before directories at each level. + FilesFirst, +} - if !ordering.is_eq() { - return ordering; - } +fn case_group_key(name: &str, order: SortOrder) -> u8 { + let first = match name.chars().next() { + Some(c) => c, + None => return 0, + }; + match order { + SortOrder::Upper => { + if first.is_lowercase() { + 1 + } else { + 0 + } + } + SortOrder::Lower => { + if first.is_uppercase() { + 1 + } else { + 0 } - (Some(_), None) => break Ordering::Greater, - (None, Some(_)) => break Ordering::Less, - (None, None) => break Ordering::Equal, } + _ => 0, + } +} + +fn compare_strings(a: &str, b: &str, order: SortOrder) -> Ordering { + match order { + SortOrder::Unicode => a.cmp(b), + _ => natural_sort(a, b), } } -/// 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( +fn compare_strings_no_tiebreak(a: &str, b: &str, order: SortOrder) -> Ordering { + match order { + SortOrder::Unicode => a.cmp(b), + _ => natural_sort_no_tiebreak(a, b), + } +} + +pub fn compare_rel_paths( (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.eq_ignore_ascii_case(b) => Ordering::Greater, - (false, true) if a.eq_ignore_ascii_case(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_rel_paths_by( + (path_a, a_is_file), + (path_b, b_is_file), + SortMode::DirectoriesFirst, + SortOrder::Default, + ) } -/// 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( +pub fn compare_rel_paths_by( (path_a, a_is_file): (&RelPath, bool), (path_b, b_is_file): (&RelPath, bool), + mode: SortMode, + order: SortOrder, ) -> Ordering { - let original_paths_equal = std::ptr::eq(path_a, path_b) || path_a == path_b; + let needs_final_tiebreak = + mode != SortMode::DirectoriesFirst && !(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(); @@ -1253,6 +1207,16 @@ pub fn compare_rel_paths_files_first( 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 file_dir_ordering = match mode { + SortMode::DirectoriesFirst => a_leaf_file.cmp(&b_leaf_file), + SortMode::FilesFirst => b_leaf_file.cmp(&a_leaf_file), + SortMode::Mixed => Ordering::Equal, + }; + + if !file_dir_ordering.is_eq() { + return file_dir_ordering; + } + let (a_stem, a_ext) = a_leaf_file .then(|| stem_and_extension(component_a)) .unwrap_or_default(); @@ -1272,21 +1236,39 @@ pub fn compare_rel_paths_files_first( 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 + let name_cmp = case_group_key(a, order) + .cmp(&case_group_key(b, order)) + .then_with(|| match mode { + SortMode::DirectoriesFirst => compare_strings(a, b, order), + _ => compare_strings_no_tiebreak(a, b, order), + }); + + let name_cmp = if mode == SortMode::Mixed { + name_cmp.then_with(|| match (a_leaf_file, b_leaf_file) { + (true, false) if a.eq_ignore_ascii_case(b) => Ordering::Greater, + (false, true) if a.eq_ignore_ascii_case(b) => Ordering::Less, + _ => Ordering::Equal, + }) } 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 + name_cmp + }; + + name_cmp.then_with(|| { + if a_leaf_file && b_leaf_file { + match order { + SortOrder::Unicode => { + a_ext.unwrap_or_default().cmp(b_ext.unwrap_or_default()) + } + _ => { + 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, @@ -1300,10 +1282,8 @@ pub fn compare_rel_paths_files_first( (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()); + if needs_final_tiebreak { + return compare_strings(path_a.as_unix_str(), path_b.as_unix_str(), order); } return Ordering::Equal; } @@ -1586,6 +1566,19 @@ mod tests { use super::*; use util_macros::perf; + fn rel_path_entry(path: &'static str, is_file: bool) -> (&'static RelPath, bool) { + (RelPath::unix(path).unwrap(), is_file) + } + + fn sorted_rel_paths( + mut paths: Vec<(&'static RelPath, bool)>, + mode: SortMode, + order: SortOrder, + ) -> Vec<(&'static RelPath, bool)> { + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, mode, order)); + paths + } + #[perf] fn compare_paths_with_dots() { let mut paths = vec![ @@ -1715,7 +1708,7 @@ mod tests { (RelPath::unix("Carrot").unwrap(), false), (RelPath::unix("aardvark.txt").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); // Case-insensitive: aardvark < Apple < banana < Carrot < zebra assert_eq!( paths, @@ -1739,7 +1732,8 @@ mod tests { (RelPath::unix("Carrot").unwrap(), false), (RelPath::unix("aardvark.txt").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); // Files first (case-insensitive), then directories (case-insensitive) assert_eq!( paths, @@ -1763,7 +1757,8 @@ mod tests { (RelPath::unix("carrot").unwrap(), false), (RelPath::unix("Aardvark.txt").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1786,7 +1781,8 @@ mod tests { (RelPath::unix("dir10").unwrap(), false), (RelPath::unix("file1.txt").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1807,14 +1803,14 @@ mod tests { (RelPath::unix("readme.txt").unwrap(), true), (RelPath::unix("ReadMe.rs").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); // 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), + (RelPath::unix("ReadMe.rs").unwrap(), true), + (RelPath::unix("readme.txt").unwrap(), true), ] ); } @@ -1828,7 +1824,7 @@ mod tests { (RelPath::unix("file1.txt").unwrap(), true), (RelPath::unix("dir2").unwrap(), false), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); // Case-insensitive: dir1, dir2, file1, file2 (all mixed) assert_eq!( paths, @@ -1847,7 +1843,7 @@ mod tests { (RelPath::unix("Hello.txt").unwrap(), true), (RelPath::unix("hello").unwrap(), false), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1860,7 +1856,7 @@ mod tests { (RelPath::unix("hello").unwrap(), false), (RelPath::unix("Hello.txt").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1879,7 +1875,7 @@ mod tests { (RelPath::unix("src").unwrap(), false), (RelPath::unix("target").unwrap(), false), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1900,7 +1896,8 @@ mod tests { (RelPath::unix("src").unwrap(), false), (RelPath::unix("tests").unwrap(), false), ]; - paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1921,7 +1918,7 @@ mod tests { (RelPath::unix(".github").unwrap(), false), (RelPath::unix("src").unwrap(), false), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1942,7 +1939,8 @@ mod tests { (RelPath::unix(".github").unwrap(), false), (RelPath::unix("src").unwrap(), false), ]; - paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); assert_eq!( paths, vec![ @@ -1962,13 +1960,13 @@ mod tests { (RelPath::unix("file.md").unwrap(), true), (RelPath::unix("file.txt").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); assert_eq!( paths, vec![ - (RelPath::unix("file.txt").unwrap(), true), - (RelPath::unix("file.rs").unwrap(), true), (RelPath::unix("file.md").unwrap(), true), + (RelPath::unix("file.rs").unwrap(), true), + (RelPath::unix("file.txt").unwrap(), true), ] ); } @@ -1981,7 +1979,8 @@ mod tests { (RelPath::unix("main.c").unwrap(), true), (RelPath::unix("main").unwrap(), false), ]; - paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b)); + paths + .sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::FilesFirst, SortOrder::Default)); assert_eq!( paths, vec![ @@ -2001,7 +2000,7 @@ mod tests { (RelPath::unix("a.txt").unwrap(), true), (RelPath::unix("A.txt").unwrap(), true), ]; - paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b)); + paths.sort_by(|&a, &b| compare_rel_paths_by(a, b, SortMode::Mixed, SortOrder::Default)); assert_eq!( paths, vec![ @@ -2013,6 +2012,333 @@ mod tests { ); } + #[perf] + fn compare_rel_paths_upper() { + let directories_only_paths = vec![ + rel_path_entry("mixedCase", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("dog", false), + rel_path_entry(".hidden", false), + rel_path_entry("Carrot", false), + ]; + assert_eq!( + sorted_rel_paths( + directories_only_paths, + SortMode::DirectoriesFirst, + SortOrder::Upper, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("Carrot", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("dog", false), + rel_path_entry("mixedCase", false), + ] + ); + + let file_and_directory_paths = vec![ + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("file1.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + ]; + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::DirectoriesFirst, + SortOrder::Upper, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::Mixed, + SortOrder::Upper, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("banana", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths, + SortMode::FilesFirst, + SortOrder::Upper, + ), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + ] + ); + + let natural_sort_paths = vec![ + rel_path_entry("file10.txt", true), + rel_path_entry("file1.txt", true), + rel_path_entry("file20.txt", true), + rel_path_entry("file2.txt", true), + ]; + assert_eq!( + sorted_rel_paths(natural_sort_paths, SortMode::Mixed, SortOrder::Upper,), + vec![ + rel_path_entry("file1.txt", true), + rel_path_entry("file2.txt", true), + rel_path_entry("file10.txt", true), + rel_path_entry("file20.txt", true), + ] + ); + + let accented_paths = vec![ + rel_path_entry("\u{00C9}something.txt", true), + rel_path_entry("zebra.txt", true), + rel_path_entry("Apple.txt", true), + ]; + assert_eq!( + sorted_rel_paths(accented_paths, SortMode::Mixed, SortOrder::Upper), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("\u{00C9}something.txt", true), + rel_path_entry("zebra.txt", true), + ] + ); + } + + #[perf] + fn compare_rel_paths_lower() { + let directories_only_paths = vec![ + rel_path_entry("mixedCase", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("dog", false), + rel_path_entry(".hidden", false), + rel_path_entry("Carrot", false), + ]; + assert_eq!( + sorted_rel_paths( + directories_only_paths, + SortMode::DirectoriesFirst, + SortOrder::Lower, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("dog", false), + rel_path_entry("mixedCase", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("Carrot", false), + rel_path_entry("Zebra", false), + ] + ); + + let file_and_directory_paths = vec![ + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("file1.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + ]; + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::DirectoriesFirst, + SortOrder::Lower, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::Mixed, + SortOrder::Lower, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths, + SortMode::FilesFirst, + SortOrder::Lower, + ), + vec![ + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + ] + ); + } + + #[perf] + fn compare_rel_paths_unicode() { + let directories_only_paths = vec![ + rel_path_entry("mixedCase", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("dog", false), + rel_path_entry(".hidden", false), + rel_path_entry("Carrot", false), + ]; + assert_eq!( + sorted_rel_paths( + directories_only_paths, + SortMode::DirectoriesFirst, + SortOrder::Unicode, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple", false), + rel_path_entry("Carrot", false), + rel_path_entry("Zebra", false), + rel_path_entry("banana", false), + rel_path_entry("dog", false), + rel_path_entry("mixedCase", false), + ] + ); + + let file_and_directory_paths = vec![ + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("ALLCAPS", false), + rel_path_entry("file1.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry(".hidden", false), + ]; + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::DirectoriesFirst, + SortOrder::Unicode, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths.clone(), + SortMode::Mixed, + SortOrder::Unicode, + ), + vec![ + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("banana", false), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + ] + ); + assert_eq!( + sorted_rel_paths( + file_and_directory_paths, + SortMode::FilesFirst, + SortOrder::Unicode, + ), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("File2.txt", true), + rel_path_entry("dog.md", true), + rel_path_entry("file1.txt", true), + rel_path_entry(".hidden", false), + rel_path_entry("ALLCAPS", false), + rel_path_entry("banana", false), + ] + ); + + let numeric_paths = vec![ + rel_path_entry("file10.txt", true), + rel_path_entry("file1.txt", true), + rel_path_entry("file2.txt", true), + rel_path_entry("file20.txt", true), + ]; + assert_eq!( + sorted_rel_paths(numeric_paths, SortMode::Mixed, SortOrder::Unicode,), + vec![ + rel_path_entry("file1.txt", true), + rel_path_entry("file10.txt", true), + rel_path_entry("file2.txt", true), + rel_path_entry("file20.txt", true), + ] + ); + + let accented_paths = vec![ + rel_path_entry("\u{00C9}something.txt", true), + rel_path_entry("zebra.txt", true), + rel_path_entry("Apple.txt", true), + ]; + assert_eq!( + sorted_rel_paths(accented_paths, SortMode::Mixed, SortOrder::Unicode), + vec![ + rel_path_entry("Apple.txt", true), + rel_path_entry("zebra.txt", true), + rel_path_entry("\u{00C9}something.txt", true), + ] + ); + } + #[perf] fn path_with_position_parse_posix_path() { // Test POSIX filename edge cases diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 3c944e0807ff1a6b0cda46c3416ad4e2dbc5a279..cb731de2e11888393ab00aef32b0722765a1ede7 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5021,6 +5021,54 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a } ``` +### Sort Order + +- Description: Whether to sort file and folder names case-sensitively in the project panel. This setting works in combination with `sort_mode`. `sort_mode` controls how files and directories are grouped (e.g., directories first), while this setting controls how names are compared within those groups. +- Setting: `sort_order` +- Default: `default` + +**Options** + +1. Case-insensitive natural sort with lowercase preferred in ties. Numbers in file names are compared by their numeric value (e.g., `file2` sorts before `file10`). Names that differ only in casing are sorted with lowercase first (e.g., `apple` before `Apple`). + +```json [settings] +{ + "project_panel": { + "sort_order": "default" + } +} +``` + +2. Uppercase names are grouped before lowercase names, with case-insensitive natural sort within each group. Dot-prefixed names (e.g., `.gitignore`) sort before both groups. Accented uppercase letters like `É` are treated as uppercase. + +```json [settings] +{ + "project_panel": { + "sort_order": "upper" + } +} +``` + +3. Lowercase names are grouped before uppercase names, with case-insensitive natural sort within each group. Dot-prefixed names sort before both groups. + +```json [settings] +{ + "project_panel": { + "sort_order": "lower" + } +} +``` + +4. Pure Unicode codepoint comparison. No case folding and no natural number sorting. Uppercase ASCII letters (`A`–`Z`) sort before lowercase (`a`–`z`) as a natural consequence of their codepoint values. Accented characters like `É` (U+00C9) sort after all ASCII letters. Numbers are compared lexicographically (`file10` sorts before `file2`). + +```json [settings] +{ + "project_panel": { + "sort_order": "unicode" + } +} +``` + ### 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 7597cdac293dd842b6a6a9f5747551a6f172bbf3..cabfe01dc6822ef22b48a4b28cea9843842e7644 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -470,6 +470,12 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k }, // Sort order for entries (directories_first, mixed, files_first) "sort_mode": "directories_first", + // Whether to sort file and folder names case-sensitively. + // "default": Case-insensitive natural sort, lowercase preferred in ties. + // "upper": Uppercase names grouped before lowercase, natural sort within. + // "lower": Lowercase names grouped before uppercase, natural sort within. + // "unicode": Pure Unicode codepoint comparison, no case folding. + "sort_order": "default", // Whether to hide the root entry when only one folder is open in the window; // this also affects how file paths appear in the file finder history. "hide_root": false,