project_panel: Add sort mode (#40160)

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>
This commit is contained in:
Lucas Parry
2025-11-17 17:52:46 +05:30
committed by GitHub
co-authored by Smit Barmase
parent 175162af4f
commit a2d3e3baf9
12 changed files with 849 additions and 44 deletions
+464 -24
View File
@@ -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