project_panel: Add `sort_order` settings (#50221)

Dionys Steffen and Smit Barmase created

_(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 <heysmitbarmase@gmail.com>

Change summary

assets/settings/default.json                       |  15 
crates/project_panel/benches/sorting.rs            |  60 
crates/project_panel/src/project_panel.rs          |  52 
crates/project_panel/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(-)

Detailed changes

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.

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<GitEntry> {
 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);

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<Entry>],
     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<GitEntry>,
     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)> {

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(),
         }

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,

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,

crates/settings_content/src/workspace.rs 🔗

@@ -746,6 +746,12 @@ pub struct ProjectPanelSettingsContent {
     ///
     /// Default: directories_first
     pub sort_mode: Option<ProjectPanelSortMode>,
+    /// 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<ProjectPanelSortOrder>,
     /// 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<ProjectPanelSortMode> 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<ProjectPanelSortOrder> 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,

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.",

crates/settings_ui/src/settings_ui.rs 🔗

@@ -486,6 +486,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::ShowCloseButton>(render_dropdown)
         .add_basic_renderer::<settings::ProjectPanelEntrySpacing>(render_dropdown)
         .add_basic_renderer::<settings::ProjectPanelSortMode>(render_dropdown)
+        .add_basic_renderer::<settings::ProjectPanelSortOrder>(render_dropdown)
         .add_basic_renderer::<settings::RewrapBehavior>(render_dropdown)
         .add_basic_renderer::<settings::FormatOnSave>(render_dropdown)
         .add_basic_renderer::<settings::IndentGuideColoring>(render_dropdown)

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

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.

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,