project_panel: Add sort mode (#40160)

Lucas Parry and Smit Barmase created

Closes #4533 (partly at least)

Release Notes:

- Added `project_panel.sort_mode` option to control explorer file sort
(directories first, mixed, files first)

 ## Summary

Adds three sorting modes for the project panel to give users more
control over how files and directories are displayed:

- **`directories_first`** (default): Current behaviour - directories
grouped before files
- **`mixed`**: Files and directories sorted together alphabetically
- **`files_first`**: filed grouped before directories

 ## Motivation

Users coming from different editors and file managers have different
expectations for file sorting. Some prefer directories grouped at the
top (traditional), while others prefer the macOS Finder-style mixed
sorting where "Apple1/", "apple2.tsx" and "Apple3/" appear
alphabetically mixed together.


 ### Screenshots

New sort options in settings:
<img width="515" height="160" alt="image"
src="https://github.com/user-attachments/assets/8f4e6668-6989-4881-a9bd-ed1f4f0beb40"
/>


Directories first | Mixed | Files first
-------------|-----|-----
<img width="328" height="888" alt="image"
src="https://github.com/user-attachments/assets/308e5c7a-6e6a-46ba-813d-6e268222925c"
/> | <img width="327" height="891" alt="image"
src="https://github.com/user-attachments/assets/8274d8ca-b60f-456e-be36-e35a3259483c"
/> | <img width="328" height="890" alt="image"
src="https://github.com/user-attachments/assets/3c3b1332-cf08-4eaf-9bed-527c00b41529"
/>


### Agent usage

Copilot-cli/claude-code/codex-cli helped out a lot. I'm not from a rust
background, but really wanted this solved, and it gave me a chance to
play with some of the coding agents I'm not permitted to use for work
stuff

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

assets/settings/default.json                       |  10 
crates/project_panel/benches/sorting.rs            |  38 +
crates/project_panel/src/project_panel.rs          |  55 +
crates/project_panel/src/project_panel_settings.rs |   8 
crates/project_panel/src/project_panel_tests.rs    | 210 ++++++
crates/settings/src/settings_content/workspace.rs  |  29 
crates/settings/src/vscode_import.rs               |   1 
crates/settings_ui/src/page_data.rs                |  18 
crates/settings_ui/src/settings_ui.rs              |   1 
crates/util/src/paths.rs                           | 488 +++++++++++++++
docs/src/configuring-zed.md                        |  33 +
docs/src/visual-customization.md                   |   2 
12 files changed, 849 insertions(+), 44 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -742,6 +742,16 @@
       //    "never"
       "show": "always"
     },
+    // Sort order for entries in the project panel.
+    // This setting can take three values:
+    //
+    // 1. Show directories first, then files:
+    //    "directories_first"
+    // 2. Mix directories and files together:
+    //    "mixed"
+    // 3. Show files first, then directories:
+    //    "files_first"
+    "sort_mode": "directories_first",
     // Whether to enable drag-and-drop operations in the project panel.
     "drag_and_drop": true,
     // Whether to hide the root entry when only one folder is open in the window.

crates/project_panel/benches/sorting.rs 🔗

@@ -1,13 +1,15 @@
 use criterion::{Criterion, criterion_group, criterion_main};
 use project::{Entry, EntryKind, GitEntry, ProjectEntryId};
-use project_panel::par_sort_worktree_entries;
+use project_panel::par_sort_worktree_entries_with_mode;
+use settings::ProjectPanelSortMode;
 use std::sync::Arc;
 use util::rel_path::RelPath;
 
 fn load_linux_repo_snapshot() -> Vec<GitEntry> {
-    let file = std::fs::read_to_string(
-        "/Users/hiro/Projects/zed/crates/project_panel/benches/linux_repo_snapshot.txt",
-    )
+    let file = std::fs::read_to_string(concat!(
+        env!("CARGO_MANIFEST_DIR"),
+        "/benches/linux_repo_snapshot.txt"
+    ))
     .expect("Failed to read file");
     file.lines()
         .filter_map(|line| {
@@ -42,10 +44,36 @@ fn load_linux_repo_snapshot() -> Vec<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(&mut snapshot),
+            |mut snapshot| {
+                par_sort_worktree_entries_with_mode(
+                    &mut snapshot,
+                    ProjectPanelSortMode::DirectoriesFirst,
+                )
+            },
+            criterion::BatchSize::LargeInput,
+        );
+    });
+
+    c.bench_function("Sort linux worktree snapshot (Mixed)", |b| {
+        b.iter_batched(
+            || snapshot.clone(),
+            |mut snapshot| {
+                par_sort_worktree_entries_with_mode(&mut snapshot, ProjectPanelSortMode::Mixed)
+            },
+            criterion::BatchSize::LargeInput,
+        );
+    });
+
+    c.bench_function("Sort linux worktree snapshot (FilesFirst)", |b| {
+        b.iter_batched(
+            || snapshot.clone(),
+            |mut snapshot| {
+                par_sort_worktree_entries_with_mode(&mut snapshot, ProjectPanelSortMode::FilesFirst)
+            },
             criterion::BatchSize::LargeInput,
         );
     });

crates/project_panel/src/project_panel.rs 🔗

@@ -703,6 +703,9 @@ impl ProjectPanel {
                     if project_panel_settings.hide_hidden != new_settings.hide_hidden {
                         this.update_visible_entries(None, false, false, window, cx);
                     }
+                    if project_panel_settings.sort_mode != new_settings.sort_mode {
+                        this.update_visible_entries(None, false, false, window, cx);
+                    }
                     if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll {
                         this.sticky_items_count = 0;
                     }
@@ -2102,7 +2105,8 @@ impl ProjectPanel {
                 .map(|entry| entry.to_owned())
                 .collect();
 
-        sort_worktree_entries(&mut siblings);
+        let mode = ProjectPanelSettings::get_global(cx).sort_mode;
+        sort_worktree_entries_with_mode(&mut siblings, mode);
         let sibling_entry_index = siblings
             .iter()
             .position(|sibling| sibling.id == latest_entry.id)?;
@@ -3229,6 +3233,7 @@ impl ProjectPanel {
         let settings = ProjectPanelSettings::get_global(cx);
         let auto_collapse_dirs = settings.auto_fold_dirs;
         let hide_gitignore = settings.hide_gitignore;
+        let sort_mode = settings.sort_mode;
         let project = self.project.read(cx);
         let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
 
@@ -3440,7 +3445,10 @@ impl ProjectPanel {
                             entry_iter.advance();
                         }
 
-                        par_sort_worktree_entries(&mut visible_worktree_entries);
+                        par_sort_worktree_entries_with_mode(
+                            &mut visible_worktree_entries,
+                            sort_mode,
+                        );
                         new_state.visible_entries.push(VisibleEntriesForWorktree {
                             worktree_id,
                             entries: visible_worktree_entries,
@@ -6101,21 +6109,42 @@ impl ClipboardEntry {
     }
 }
 
-fn cmp<T: AsRef<Entry>>(lhs: T, rhs: T) -> cmp::Ordering {
-    let entry_a = lhs.as_ref();
-    let entry_b = rhs.as_ref();
-    util::paths::compare_rel_paths(
-        (&entry_a.path, entry_a.is_file()),
-        (&entry_b.path, entry_b.is_file()),
-    )
+#[inline]
+fn cmp_directories_first(a: &Entry, b: &Entry) -> cmp::Ordering {
+    util::paths::compare_rel_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
+}
+
+#[inline]
+fn cmp_mixed(a: &Entry, b: &Entry) -> cmp::Ordering {
+    util::paths::compare_rel_paths_mixed((&a.path, a.is_file()), (&b.path, b.is_file()))
+}
+
+#[inline]
+fn cmp_files_first(a: &Entry, b: &Entry) -> cmp::Ordering {
+    util::paths::compare_rel_paths_files_first((&a.path, a.is_file()), (&b.path, b.is_file()))
+}
+
+#[inline]
+fn cmp_with_mode(a: &Entry, b: &Entry, mode: &settings::ProjectPanelSortMode) -> cmp::Ordering {
+    match mode {
+        settings::ProjectPanelSortMode::DirectoriesFirst => cmp_directories_first(a, b),
+        settings::ProjectPanelSortMode::Mixed => cmp_mixed(a, b),
+        settings::ProjectPanelSortMode::FilesFirst => cmp_files_first(a, b),
+    }
 }
 
-pub fn sort_worktree_entries(entries: &mut [impl AsRef<Entry>]) {
-    entries.sort_by(|lhs, rhs| cmp(lhs, rhs));
+pub fn sort_worktree_entries_with_mode(
+    entries: &mut [impl AsRef<Entry>],
+    mode: settings::ProjectPanelSortMode,
+) {
+    entries.sort_by(|lhs, rhs| cmp_with_mode(lhs.as_ref(), rhs.as_ref(), &mode));
 }
 
-pub fn par_sort_worktree_entries(entries: &mut Vec<GitEntry>) {
-    entries.par_sort_by(|lhs, rhs| cmp(lhs, rhs));
+pub fn par_sort_worktree_entries_with_mode(
+    entries: &mut Vec<GitEntry>,
+    mode: settings::ProjectPanelSortMode,
+) {
+    entries.par_sort_by(|lhs, rhs| cmp_with_mode(lhs, rhs, &mode));
 }
 
 #[cfg(test)]

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -3,8 +3,8 @@ use gpui::Pixels;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
-    DockSide, ProjectPanelEntrySpacing, RegisterSetting, Settings, ShowDiagnostics,
-    ShowIndentGuides,
+    DockSide, ProjectPanelEntrySpacing, ProjectPanelSortMode, RegisterSetting, Settings,
+    ShowDiagnostics, ShowIndentGuides,
 };
 use ui::{
     px,
@@ -33,6 +33,7 @@ pub struct ProjectPanelSettings {
     pub hide_hidden: bool,
     pub drag_and_drop: bool,
     pub auto_open: AutoOpenSettings,
+    pub sort_mode: ProjectPanelSortMode,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -115,6 +116,9 @@ impl Settings for ProjectPanelSettings {
                     on_drop: auto_open.on_drop.unwrap(),
                 }
             },
+            sort_mode: project_panel
+                .sort_mode
+                .unwrap_or(ProjectPanelSortMode::DirectoriesFirst),
         }
     }
 }

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -326,6 +326,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
         ProjectPanelSettings::override_global(
             ProjectPanelSettings {
                 auto_fold_dirs: true,
+                sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst,
                 ..settings
             },
             cx,
@@ -7704,6 +7705,215 @@ fn visible_entries_as_strings(
     result
 }
 
+/// Test that missing sort_mode field defaults to DirectoriesFirst
+#[gpui::test]
+async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
+    let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
+    assert_eq!(
+        default_settings.sort_mode,
+        settings::ProjectPanelSortMode::DirectoriesFirst,
+        "sort_mode should default to DirectoriesFirst"
+    );
+}
+
+/// Test sort modes: DirectoriesFirst (default) vs Mixed
+#[gpui::test]
+async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "zebra.txt": "",
+            "Apple": {},
+            "banana.rs": "",
+            "Carrot": {},
+            "aardvark.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+    cx.run_until_parked();
+
+    // Default sort mode should be DirectoriesFirst
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    > Apple",
+            "    > Carrot",
+            "      aardvark.txt",
+            "      banana.rs",
+            "      zebra.txt",
+        ]
+    );
+}
+
+#[gpui::test]
+async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "Zebra.txt": "",
+            "apple": {},
+            "Banana.rs": "",
+            "carrot": {},
+            "Aardvark.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    // Switch to Mixed mode
+    cx.update(|_, cx| {
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.project_panel.get_or_insert_default().sort_mode =
+                    Some(settings::ProjectPanelSortMode::Mixed);
+            });
+        });
+    });
+
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+    cx.run_until_parked();
+
+    // Mixed mode: case-insensitive sorting
+    // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "      Aardvark.txt",
+            "    > apple",
+            "      Banana.rs",
+            "    > carrot",
+            "      Zebra.txt",
+        ]
+    );
+}
+
+#[gpui::test]
+async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "Zebra.txt": "",
+            "apple": {},
+            "Banana.rs": "",
+            "carrot": {},
+            "Aardvark.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    // Switch to FilesFirst mode
+    cx.update(|_, cx| {
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.project_panel.get_or_insert_default().sort_mode =
+                    Some(settings::ProjectPanelSortMode::FilesFirst);
+            });
+        });
+    });
+
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+    cx.run_until_parked();
+
+    // FilesFirst mode: files first, then directories (both case-insensitive)
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "      Aardvark.txt",
+            "      Banana.rs",
+            "      Zebra.txt",
+            "    > apple",
+            "    > carrot",
+        ]
+    );
+}
+
+#[gpui::test]
+async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "file2.txt": "",
+            "dir1": {},
+            "file1.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+    cx.run_until_parked();
+
+    // Initially DirectoriesFirst
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
+    );
+
+    // Toggle to Mixed
+    cx.update(|_, cx| {
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.project_panel.get_or_insert_default().sort_mode =
+                    Some(settings::ProjectPanelSortMode::Mixed);
+            });
+        });
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
+    );
+
+    // Toggle back to DirectoriesFirst
+    cx.update(|_, cx| {
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.project_panel.get_or_insert_default().sort_mode =
+                    Some(settings::ProjectPanelSortMode::DirectoriesFirst);
+            });
+        });
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
+    );
+}
+
 fn init_test(cx: &mut TestAppContext) {
     cx.update(|cx| {
         let settings_store = SettingsStore::test(cx);

crates/settings/src/settings_content/workspace.rs 🔗

@@ -609,6 +609,10 @@ pub struct ProjectPanelSettingsContent {
     pub drag_and_drop: Option<bool>,
     /// Settings for automatically opening files.
     pub auto_open: Option<ProjectPanelAutoOpenSettings>,
+    /// How to order sibling entries in the project panel.
+    ///
+    /// Default: directories_first
+    pub sort_mode: Option<ProjectPanelSortMode>,
 }
 
 #[derive(
@@ -634,6 +638,31 @@ pub enum ProjectPanelEntrySpacing {
     Standard,
 }
 
+#[derive(
+    Copy,
+    Clone,
+    Debug,
+    Default,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    PartialEq,
+    Eq,
+    strum::VariantArray,
+    strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum ProjectPanelSortMode {
+    /// Show directories first, then files
+    #[default]
+    DirectoriesFirst,
+    /// Mix directories and files together
+    Mixed,
+    /// Show files first, then directories
+    FilesFirst,
+}
+
 #[skip_serializing_none]
 #[derive(
     Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,

crates/settings/src/vscode_import.rs 🔗

@@ -668,6 +668,7 @@ impl VsCodeSettings {
             show_diagnostics: self
                 .read_bool("problems.decorations.enabled")
                 .and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }),
+            sort_mode: None,
             starts_open: None,
             sticky_scroll: None,
             auto_open: None,

crates/settings_ui/src/page_data.rs 🔗

@@ -3822,6 +3822,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                     metadata: None,
                     files: USER,
                 }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Sort Mode",
+                    description: "Sort order for entries in the project panel.",
+                    field: Box::new(SettingField {
+                        pick: |settings_content| {
+                            settings_content.project_panel.as_ref()?.sort_mode.as_ref()
+                        },
+                        write: |settings_content, value| {
+                            settings_content
+                                .project_panel
+                                .get_or_insert_default()
+                                .sort_mode = value;
+                        },
+                        json_path: Some("project_panel.sort_mode"),
+                    }),
+                    metadata: None,
+                    files: USER,
+                }),
                 SettingsPageItem::SectionHeader("Terminal Panel"),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Terminal Dock",

crates/settings_ui/src/settings_ui.rs 🔗

@@ -451,6 +451,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::ShowDiagnostics>(render_dropdown)
         .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::RewrapBehavior>(render_dropdown)
         .add_basic_renderer::<settings::FormatOnSave>(render_dropdown)
         .add_basic_renderer::<settings::IndentGuideColoring>(render_dropdown)

crates/util/src/paths.rs 🔗

@@ -944,36 +944,47 @@ pub fn natural_sort(a: &str, b: &str) -> Ordering {
     }
 }
 
+/// Case-insensitive natural sort without applying the final lowercase/uppercase tie-breaker.
+/// This is useful when comparing individual path components where we want to keep walking
+/// deeper components before deciding on casing.
+fn natural_sort_no_tiebreak(a: &str, b: &str) -> Ordering {
+    if a.eq_ignore_ascii_case(b) {
+        Ordering::Equal
+    } else {
+        natural_sort(a, b)
+    }
+}
+
+fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) {
+    if filename.is_empty() {
+        return (None, None);
+    }
+
+    match filename.rsplit_once('.') {
+        // Case 1: No dot was found. The entire name is the stem.
+        None => (Some(filename), None),
+
+        // Case 2: A dot was found.
+        Some((before, after)) => {
+            // This is the crucial check for dotfiles like ".bashrc".
+            // If `before` is empty, the dot was the first character.
+            // In that case, we revert to the "whole name is the stem" logic.
+            if before.is_empty() {
+                (Some(filename), None)
+            } else {
+                // Otherwise, we have a standard stem and extension.
+                (Some(before), Some(after))
+            }
+        }
+    }
+}
+
 pub fn compare_rel_paths(
     (path_a, a_is_file): (&RelPath, bool),
     (path_b, b_is_file): (&RelPath, bool),
 ) -> Ordering {
     let mut components_a = path_a.components();
     let mut components_b = path_b.components();
-
-    fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) {
-        if filename.is_empty() {
-            return (None, None);
-        }
-
-        match filename.rsplit_once('.') {
-            // Case 1: No dot was found. The entire name is the stem.
-            None => (Some(filename), None),
-
-            // Case 2: A dot was found.
-            Some((before, after)) => {
-                // This is the crucial check for dotfiles like ".bashrc".
-                // If `before` is empty, the dot was the first character.
-                // In that case, we revert to the "whole name is the stem" logic.
-                if before.is_empty() {
-                    (Some(filename), None)
-                } else {
-                    // Otherwise, we have a standard stem and extension.
-                    (Some(before), Some(after))
-                }
-            }
-        }
-    }
     loop {
         match (components_a.next(), components_b.next()) {
             (Some(component_a), Some(component_b)) => {
@@ -1020,6 +1031,156 @@ pub fn compare_rel_paths(
     }
 }
 
+/// Compare two relative paths with mixed files and directories using
+/// case-insensitive natural sorting. For example, "Apple", "aardvark.txt",
+/// and "Zebra" would be sorted as: aardvark.txt, Apple, Zebra
+/// (case-insensitive alphabetical).
+pub fn compare_rel_paths_mixed(
+    (path_a, a_is_file): (&RelPath, bool),
+    (path_b, b_is_file): (&RelPath, bool),
+) -> Ordering {
+    let original_paths_equal = std::ptr::eq(path_a, path_b) || path_a == path_b;
+    let mut components_a = path_a.components();
+    let mut components_b = path_b.components();
+
+    loop {
+        match (components_a.next(), components_b.next()) {
+            (Some(component_a), Some(component_b)) => {
+                let a_leaf_file = a_is_file && components_a.rest().is_empty();
+                let b_leaf_file = b_is_file && components_b.rest().is_empty();
+
+                let (a_stem, a_ext) = a_leaf_file
+                    .then(|| stem_and_extension(component_a))
+                    .unwrap_or_default();
+                let (b_stem, b_ext) = b_leaf_file
+                    .then(|| stem_and_extension(component_b))
+                    .unwrap_or_default();
+                let a_key = if a_leaf_file {
+                    a_stem
+                } else {
+                    Some(component_a)
+                };
+                let b_key = if b_leaf_file {
+                    b_stem
+                } else {
+                    Some(component_b)
+                };
+
+                let ordering = match (a_key, b_key) {
+                    (Some(a), Some(b)) => natural_sort_no_tiebreak(a, b)
+                        .then_with(|| match (a_leaf_file, b_leaf_file) {
+                            (true, false) if a == b => Ordering::Greater,
+                            (false, true) if a == b => Ordering::Less,
+                            _ => Ordering::Equal,
+                        })
+                        .then_with(|| {
+                            if a_leaf_file && b_leaf_file {
+                                let a_ext_str = a_ext.unwrap_or_default().to_lowercase();
+                                let b_ext_str = b_ext.unwrap_or_default().to_lowercase();
+                                b_ext_str.cmp(&a_ext_str)
+                            } else {
+                                Ordering::Equal
+                            }
+                        }),
+                    (Some(_), None) => Ordering::Greater,
+                    (None, Some(_)) => Ordering::Less,
+                    (None, None) => Ordering::Equal,
+                };
+
+                if !ordering.is_eq() {
+                    return ordering;
+                }
+            }
+            (Some(_), None) => return Ordering::Greater,
+            (None, Some(_)) => return Ordering::Less,
+            (None, None) => {
+                // Deterministic tie-break: use natural sort to prefer lowercase when paths
+                // are otherwise equal but still differ in casing.
+                if !original_paths_equal {
+                    return natural_sort(path_a.as_unix_str(), path_b.as_unix_str());
+                }
+                return Ordering::Equal;
+            }
+        }
+    }
+}
+
+/// Compare two relative paths with files before directories using
+/// case-insensitive natural sorting. At each directory level, all files
+/// are sorted before all directories, with case-insensitive alphabetical
+/// ordering within each group.
+pub fn compare_rel_paths_files_first(
+    (path_a, a_is_file): (&RelPath, bool),
+    (path_b, b_is_file): (&RelPath, bool),
+) -> Ordering {
+    let original_paths_equal = std::ptr::eq(path_a, path_b) || path_a == path_b;
+    let mut components_a = path_a.components();
+    let mut components_b = path_b.components();
+
+    loop {
+        match (components_a.next(), components_b.next()) {
+            (Some(component_a), Some(component_b)) => {
+                let a_leaf_file = a_is_file && components_a.rest().is_empty();
+                let b_leaf_file = b_is_file && components_b.rest().is_empty();
+
+                let (a_stem, a_ext) = a_leaf_file
+                    .then(|| stem_and_extension(component_a))
+                    .unwrap_or_default();
+                let (b_stem, b_ext) = b_leaf_file
+                    .then(|| stem_and_extension(component_b))
+                    .unwrap_or_default();
+                let a_key = if a_leaf_file {
+                    a_stem
+                } else {
+                    Some(component_a)
+                };
+                let b_key = if b_leaf_file {
+                    b_stem
+                } else {
+                    Some(component_b)
+                };
+
+                let ordering = match (a_key, b_key) {
+                    (Some(a), Some(b)) => {
+                        if a_leaf_file && !b_leaf_file {
+                            Ordering::Less
+                        } else if !a_leaf_file && b_leaf_file {
+                            Ordering::Greater
+                        } else {
+                            natural_sort_no_tiebreak(a, b).then_with(|| {
+                                if a_leaf_file && b_leaf_file {
+                                    let a_ext_str = a_ext.unwrap_or_default().to_lowercase();
+                                    let b_ext_str = b_ext.unwrap_or_default().to_lowercase();
+                                    a_ext_str.cmp(&b_ext_str)
+                                } else {
+                                    Ordering::Equal
+                                }
+                            })
+                        }
+                    }
+                    (Some(_), None) => Ordering::Greater,
+                    (None, Some(_)) => Ordering::Less,
+                    (None, None) => Ordering::Equal,
+                };
+
+                if !ordering.is_eq() {
+                    return ordering;
+                }
+            }
+            (Some(_), None) => return Ordering::Greater,
+            (None, Some(_)) => return Ordering::Less,
+            (None, None) => {
+                // Deterministic tie-break: use natural sort to prefer lowercase when paths
+                // are otherwise equal but still differ in casing.
+                if !original_paths_equal {
+                    return natural_sort(path_a.as_unix_str(), path_b.as_unix_str());
+                }
+                return Ordering::Equal;
+            }
+        }
+    }
+}
+
 pub fn compare_paths(
     (path_a, a_is_file): (&Path, bool),
     (path_b, b_is_file): (&Path, bool),
@@ -1265,6 +1426,285 @@ mod tests {
         );
     }
 
+    #[perf]
+    fn compare_rel_paths_mixed_case_insensitive() {
+        // Test that mixed mode is case-insensitive
+        let mut paths = vec![
+            (RelPath::unix("zebra.txt").unwrap(), true),
+            (RelPath::unix("Apple").unwrap(), false),
+            (RelPath::unix("banana.rs").unwrap(), true),
+            (RelPath::unix("Carrot").unwrap(), false),
+            (RelPath::unix("aardvark.txt").unwrap(), true),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b));
+        // Case-insensitive: aardvark < Apple < banana < Carrot < zebra
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("aardvark.txt").unwrap(), true),
+                (RelPath::unix("Apple").unwrap(), false),
+                (RelPath::unix("banana.rs").unwrap(), true),
+                (RelPath::unix("Carrot").unwrap(), false),
+                (RelPath::unix("zebra.txt").unwrap(), true),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_files_first_basic() {
+        // Test that files come before directories
+        let mut paths = vec![
+            (RelPath::unix("zebra.txt").unwrap(), true),
+            (RelPath::unix("Apple").unwrap(), false),
+            (RelPath::unix("banana.rs").unwrap(), true),
+            (RelPath::unix("Carrot").unwrap(), false),
+            (RelPath::unix("aardvark.txt").unwrap(), true),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b));
+        // Files first (case-insensitive), then directories (case-insensitive)
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("aardvark.txt").unwrap(), true),
+                (RelPath::unix("banana.rs").unwrap(), true),
+                (RelPath::unix("zebra.txt").unwrap(), true),
+                (RelPath::unix("Apple").unwrap(), false),
+                (RelPath::unix("Carrot").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_files_first_case_insensitive() {
+        // Test case-insensitive sorting within files and directories
+        let mut paths = vec![
+            (RelPath::unix("Zebra.txt").unwrap(), true),
+            (RelPath::unix("apple").unwrap(), false),
+            (RelPath::unix("Banana.rs").unwrap(), true),
+            (RelPath::unix("carrot").unwrap(), false),
+            (RelPath::unix("Aardvark.txt").unwrap(), true),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("Aardvark.txt").unwrap(), true),
+                (RelPath::unix("Banana.rs").unwrap(), true),
+                (RelPath::unix("Zebra.txt").unwrap(), true),
+                (RelPath::unix("apple").unwrap(), false),
+                (RelPath::unix("carrot").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_files_first_numeric() {
+        // Test natural number sorting with files first
+        let mut paths = vec![
+            (RelPath::unix("file10.txt").unwrap(), true),
+            (RelPath::unix("dir2").unwrap(), false),
+            (RelPath::unix("file2.txt").unwrap(), true),
+            (RelPath::unix("dir10").unwrap(), false),
+            (RelPath::unix("file1.txt").unwrap(), true),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("file1.txt").unwrap(), true),
+                (RelPath::unix("file2.txt").unwrap(), true),
+                (RelPath::unix("file10.txt").unwrap(), true),
+                (RelPath::unix("dir2").unwrap(), false),
+                (RelPath::unix("dir10").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_mixed_case() {
+        // Test case-insensitive sorting with varied capitalization
+        let mut paths = vec![
+            (RelPath::unix("README.md").unwrap(), true),
+            (RelPath::unix("readme.txt").unwrap(), true),
+            (RelPath::unix("ReadMe.rs").unwrap(), true),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b));
+        // All "readme" variants should group together, sorted by extension
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("readme.txt").unwrap(), true),
+                (RelPath::unix("ReadMe.rs").unwrap(), true),
+                (RelPath::unix("README.md").unwrap(), true),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_mixed_files_and_dirs() {
+        // Verify directories and files are still mixed
+        let mut paths = vec![
+            (RelPath::unix("file2.txt").unwrap(), true),
+            (RelPath::unix("Dir1").unwrap(), false),
+            (RelPath::unix("file1.txt").unwrap(), true),
+            (RelPath::unix("dir2").unwrap(), false),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b));
+        // Case-insensitive: dir1, dir2, file1, file2 (all mixed)
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("Dir1").unwrap(), false),
+                (RelPath::unix("dir2").unwrap(), false),
+                (RelPath::unix("file1.txt").unwrap(), true),
+                (RelPath::unix("file2.txt").unwrap(), true),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_mixed_with_nested_paths() {
+        // Test that nested paths still work correctly
+        let mut paths = vec![
+            (RelPath::unix("src/main.rs").unwrap(), true),
+            (RelPath::unix("Cargo.toml").unwrap(), true),
+            (RelPath::unix("src").unwrap(), false),
+            (RelPath::unix("target").unwrap(), false),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("Cargo.toml").unwrap(), true),
+                (RelPath::unix("src").unwrap(), false),
+                (RelPath::unix("src/main.rs").unwrap(), true),
+                (RelPath::unix("target").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_files_first_with_nested() {
+        // Files come before directories, even with nested paths
+        let mut paths = vec![
+            (RelPath::unix("src/lib.rs").unwrap(), true),
+            (RelPath::unix("README.md").unwrap(), true),
+            (RelPath::unix("src").unwrap(), false),
+            (RelPath::unix("tests").unwrap(), false),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("README.md").unwrap(), true),
+                (RelPath::unix("src").unwrap(), false),
+                (RelPath::unix("src/lib.rs").unwrap(), true),
+                (RelPath::unix("tests").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_mixed_dotfiles() {
+        // Test that dotfiles are handled correctly in mixed mode
+        let mut paths = vec![
+            (RelPath::unix(".gitignore").unwrap(), true),
+            (RelPath::unix("README.md").unwrap(), true),
+            (RelPath::unix(".github").unwrap(), false),
+            (RelPath::unix("src").unwrap(), false),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix(".github").unwrap(), false),
+                (RelPath::unix(".gitignore").unwrap(), true),
+                (RelPath::unix("README.md").unwrap(), true),
+                (RelPath::unix("src").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_files_first_dotfiles() {
+        // Test that dotfiles come first when they're files
+        let mut paths = vec![
+            (RelPath::unix(".gitignore").unwrap(), true),
+            (RelPath::unix("README.md").unwrap(), true),
+            (RelPath::unix(".github").unwrap(), false),
+            (RelPath::unix("src").unwrap(), false),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix(".gitignore").unwrap(), true),
+                (RelPath::unix("README.md").unwrap(), true),
+                (RelPath::unix(".github").unwrap(), false),
+                (RelPath::unix("src").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_mixed_same_stem_different_extension() {
+        // Files with same stem but different extensions should sort by extension
+        let mut paths = vec![
+            (RelPath::unix("file.rs").unwrap(), true),
+            (RelPath::unix("file.md").unwrap(), true),
+            (RelPath::unix("file.txt").unwrap(), true),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("file.txt").unwrap(), true),
+                (RelPath::unix("file.rs").unwrap(), true),
+                (RelPath::unix("file.md").unwrap(), true),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_files_first_same_stem() {
+        // Same stem files should still sort by extension with files_first
+        let mut paths = vec![
+            (RelPath::unix("main.rs").unwrap(), true),
+            (RelPath::unix("main.c").unwrap(), true),
+            (RelPath::unix("main").unwrap(), false),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_files_first(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("main.c").unwrap(), true),
+                (RelPath::unix("main.rs").unwrap(), true),
+                (RelPath::unix("main").unwrap(), false),
+            ]
+        );
+    }
+
+    #[perf]
+    fn compare_rel_paths_mixed_deep_nesting() {
+        // Test sorting with deeply nested paths
+        let mut paths = vec![
+            (RelPath::unix("a/b/c.txt").unwrap(), true),
+            (RelPath::unix("A/B.txt").unwrap(), true),
+            (RelPath::unix("a.txt").unwrap(), true),
+            (RelPath::unix("A.txt").unwrap(), true),
+        ];
+        paths.sort_by(|&a, &b| compare_rel_paths_mixed(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (RelPath::unix("A/B.txt").unwrap(), true),
+                (RelPath::unix("a/b/c.txt").unwrap(), true),
+                (RelPath::unix("a.txt").unwrap(), true),
+                (RelPath::unix("A.txt").unwrap(), true),
+            ]
+        );
+    }
+
     #[perf]
     fn path_with_position_parse_posix_path() {
         // Test POSIX filename edge cases

docs/src/configuring-zed.md 🔗

@@ -4298,6 +4298,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
     "indent_guides": {
       "show": "always"
     },
+    "sort_mode": "directories_first",
     "hide_root": false,
     "hide_hidden": false,
     "starts_open": true,
@@ -4514,6 +4515,38 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 }
 ```
 
+### Sort Mode
+
+- Description: Sort order for entries in the project panel
+- Setting: `sort_mode`
+- Default: `directories_first`
+
+**Options**
+
+1. Show directories first, then files
+
+```json [settings]
+{
+  "sort_mode": "directories_first"
+}
+```
+
+2. Mix directories and files together
+
+```json [settings]
+{
+  "sort_mode": "mixed"
+}
+```
+
+3. Show files first, then directories
+
+```json [settings]
+{
+  "sort_mode": "files_first"
+}
+```
+
 ### Auto Open
 
 - Description: Control whether files are opened automatically after different creation flows in the project panel.

docs/src/visual-customization.md 🔗

@@ -457,6 +457,8 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k
       // When to show indent guides in the project panel. (always, never)
       "show": "always"
     },
+    // Sort order for entries (directories_first, mixed, files_first)
+    "sort_mode": "directories_first",
     // Whether to hide the root entry when only one folder is open in the window.
     "hide_root": false,
     // Whether to hide the hidden entries in the project panel.