Detailed changes
@@ -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.
@@ -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,
);
});
@@ -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)]
@@ -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),
}
}
}
@@ -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);
@@ -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,
@@ -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,
@@ -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",
@@ -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)
@@ -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
@@ -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.
@@ -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.