Make alphabetical sorting the default (#32315)

Follow up of this pr: #25148

Release Notes:

- Improved file sorting.
As described in #20126, I was fed up with lexicographical file sorting
in the project panel. The current sorting behavior doesn't handle
numeric segments properly, leading to unintuitive ordering like
`file_1.rs`, `file_10.rs`, `file_2.rs`.


## Example Sorting Results
Using `lexicographical` (default):
```
.
├── file_01.rs
├── file_1.rs
├── file_10.rs
├── file_1025.rs
├── file_2.rs
```

Using alphabetical (natural) sorting:
```
.
├── file_1.rs
├── file_01.rs
├── file_2.rs
├── file_10.rs
├── file_1025.rs
```
This commit is contained in:
Tom Planche 2025-08-14 00:07:49 +02:00 committed by GitHub
parent 293992f5b1
commit e67b2da20c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 520 additions and 35 deletions

View file

@ -740,9 +740,9 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
" > .git",
" > a",
" v b",
" > [EDITOR: ''] <== selected",
" > 3",
" > 4",
" > [EDITOR: ''] <== selected",
" a-different-filename.tar.gz",
" > C",
" .dockerignore",
@ -765,10 +765,10 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > [PROCESSING: 'new-dir']",
" a-different-filename.tar.gz <== selected",
" > 3 <== selected",
" > 4",
" a-different-filename.tar.gz",
" > C",
" .dockerignore",
]
@ -782,10 +782,10 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
" > .git",
" > a",
" v b",
" > 3",
" > 3 <== selected",
" > 4",
" > new-dir",
" a-different-filename.tar.gz <== selected",
" a-different-filename.tar.gz",
" > C",
" .dockerignore",
]
@ -801,10 +801,10 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
" > .git",
" > a",
" v b",
" > 3",
" > [EDITOR: '3'] <== selected",
" > 4",
" > new-dir",
" [EDITOR: 'a-different-filename.tar.gz'] <== selected",
" a-different-filename.tar.gz",
" > C",
" .dockerignore",
]
@ -819,10 +819,10 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
" > .git",
" > a",
" v b",
" > 3",
" > 3 <== selected",
" > 4",
" > new-dir",
" a-different-filename.tar.gz <== selected",
" a-different-filename.tar.gz",
" > C",
" .dockerignore",
]
@ -837,12 +837,12 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
" > .git",
" > a",
" v b",
" > 3",
" v 3",
" [EDITOR: ''] <== selected",
" Q",
" > 4",
" > new-dir",
" [EDITOR: ''] <== selected",
" a-different-filename.tar.gz",
" > C",
]
);
panel.update_in(cx, |panel, window, cx| {
@ -863,12 +863,12 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) {
" > .git",
" > a",
" v b",
" > 3",
" v 3 <== selected",
" Q",
" > 4",
" > new-dir",
" a-different-filename.tar.gz <== selected",
" a-different-filename.tar.gz",
" > C",
" .dockerignore",
]
);
}

View file

@ -1,4 +1,7 @@
use std::cmp;
use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::path::StripPrefixError;
use std::sync::{Arc, OnceLock};
use std::{
@ -7,12 +10,6 @@ use std::{
sync::LazyLock,
};
use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::NumericPrefixWithSuffix;
/// Returns the path to the user's home directory.
pub fn home_dir() -> &'static PathBuf {
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
@ -545,17 +542,172 @@ impl PathMatcher {
}
}
/// Custom character comparison that prioritizes lowercase for same letters
fn compare_chars(a: char, b: char) -> Ordering {
// First compare case-insensitive
match a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()) {
Ordering::Equal => {
// If same letter, prioritize lowercase (lowercase < uppercase)
match (a.is_ascii_lowercase(), b.is_ascii_lowercase()) {
(true, false) => Ordering::Less, // lowercase comes first
(false, true) => Ordering::Greater, // uppercase comes after
_ => Ordering::Equal, // both same case or both non-ascii
}
}
other => other,
}
}
/// Compares two sequences of consecutive digits for natural sorting.
///
/// This function is a core component of natural sorting that handles numeric comparison
/// in a way that feels natural to humans. It extracts and compares consecutive digit
/// sequences from two iterators, handling various cases like leading zeros and very large numbers.
///
/// # Behavior
///
/// The function implements the following comparison rules:
/// 1. Different numeric values: Compares by actual numeric value (e.g., "2" < "10")
/// 2. Leading zeros: When values are equal, longer sequence wins (e.g., "002" > "2")
/// 3. Large numbers: Falls back to string comparison for numbers that would overflow u128
///
/// # Examples
///
/// ```text
/// "1" vs "2" -> Less (different values)
/// "2" vs "10" -> Less (numeric comparison)
/// "002" vs "2" -> Greater (leading zeros)
/// "10" vs "010" -> Less (leading zeros)
/// "999..." vs "1000..." -> Less (large number comparison)
/// ```
///
/// # Implementation Details
///
/// 1. Extracts consecutive digits into strings
/// 2. Compares sequence lengths for leading zero handling
/// 3. For equal lengths, compares digit by digit
/// 4. For different lengths:
/// - Attempts numeric comparison first (for numbers up to 2^128 - 1)
/// - Falls back to string comparison if numbers would overflow
///
/// The function advances both iterators past their respective numeric sequences,
/// regardless of the comparison result.
fn compare_numeric_segments<I>(
a_iter: &mut std::iter::Peekable<I>,
b_iter: &mut std::iter::Peekable<I>,
) -> Ordering
where
I: Iterator<Item = char>,
{
// Collect all consecutive digits into strings
let mut a_num_str = String::new();
let mut b_num_str = String::new();
while let Some(&c) = a_iter.peek() {
if !c.is_ascii_digit() {
break;
}
a_num_str.push(c);
a_iter.next();
}
while let Some(&c) = b_iter.peek() {
if !c.is_ascii_digit() {
break;
}
b_num_str.push(c);
b_iter.next();
}
// First compare lengths (handle leading zeros)
match a_num_str.len().cmp(&b_num_str.len()) {
Ordering::Equal => {
// Same length, compare digit by digit
match a_num_str.cmp(&b_num_str) {
Ordering::Equal => Ordering::Equal,
ordering => ordering,
}
}
// Different lengths but same value means leading zeros
ordering => {
// Try parsing as numbers first
if let (Ok(a_val), Ok(b_val)) = (a_num_str.parse::<u128>(), b_num_str.parse::<u128>()) {
match a_val.cmp(&b_val) {
Ordering::Equal => ordering, // Same value, longer one is greater (leading zeros)
ord => ord,
}
} else {
// If parsing fails (overflow), compare as strings
a_num_str.cmp(&b_num_str)
}
}
}
}
/// Performs natural sorting comparison between two strings.
///
/// Natural sorting is an ordering that handles numeric sequences in a way that matches human expectations.
/// For example, "file2" comes before "file10" (unlike standard lexicographic sorting).
///
/// # Characteristics
///
/// * Case-sensitive with lowercase priority: When comparing same letters, lowercase comes before uppercase
/// * Numbers are compared by numeric value, not character by character
/// * Leading zeros affect ordering when numeric values are equal
/// * Can handle numbers larger than u128::MAX (falls back to string comparison)
///
/// # Algorithm
///
/// The function works by:
/// 1. Processing strings character by character
/// 2. When encountering digits, treating consecutive digits as a single number
/// 3. Comparing numbers by their numeric value rather than lexicographically
/// 4. For non-numeric characters, using case-sensitive comparison with lowercase priority
fn natural_sort(a: &str, b: &str) -> Ordering {
let mut a_iter = a.chars().peekable();
let mut b_iter = b.chars().peekable();
loop {
match (a_iter.peek(), b_iter.peek()) {
(None, None) => return Ordering::Equal,
(None, _) => return Ordering::Less,
(_, None) => return Ordering::Greater,
(Some(&a_char), Some(&b_char)) => {
if a_char.is_ascii_digit() && b_char.is_ascii_digit() {
match compare_numeric_segments(&mut a_iter, &mut b_iter) {
Ordering::Equal => continue,
ordering => return ordering,
}
} else {
match compare_chars(a_char, b_char) {
Ordering::Equal => {
a_iter.next();
b_iter.next();
}
ordering => return ordering,
}
}
}
}
}
}
pub fn compare_paths(
(path_a, a_is_file): (&Path, bool),
(path_b, b_is_file): (&Path, bool),
) -> cmp::Ordering {
) -> Ordering {
let mut components_a = path_a.components().peekable();
let mut components_b = path_b.components().peekable();
loop {
match (components_a.next(), components_b.next()) {
(Some(component_a), Some(component_b)) => {
let a_is_file = components_a.peek().is_none() && a_is_file;
let b_is_file = components_b.peek().is_none() && b_is_file;
let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
let path_a = Path::new(component_a.as_os_str());
let path_string_a = if a_is_file {
@ -564,9 +716,6 @@ pub fn compare_paths(
path_a.file_name()
}
.map(|s| s.to_string_lossy());
let num_and_remainder_a = path_string_a
.as_deref()
.map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
let path_b = Path::new(component_b.as_os_str());
let path_string_b = if b_is_file {
@ -575,27 +724,32 @@ pub fn compare_paths(
path_b.file_name()
}
.map(|s| s.to_string_lossy());
let num_and_remainder_b = path_string_b
.as_deref()
.map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
num_and_remainder_a.cmp(&num_and_remainder_b).then_with(|| {
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,
};
compare_components.then_with(|| {
if a_is_file && b_is_file {
let ext_a = path_a.extension().unwrap_or_default();
let ext_b = path_b.extension().unwrap_or_default();
ext_a.cmp(ext_b)
} else {
cmp::Ordering::Equal
Ordering::Equal
}
})
});
if !ordering.is_eq() {
return ordering;
}
}
(Some(_), None) => break cmp::Ordering::Greater,
(None, Some(_)) => break cmp::Ordering::Less,
(None, None) => break cmp::Ordering::Equal,
(Some(_), None) => break Ordering::Greater,
(None, Some(_)) => break Ordering::Less,
(None, None) => break Ordering::Equal,
}
}
}
@ -1049,4 +1203,335 @@ mod tests {
"C:\\Users\\someone\\test_file.rs"
);
}
#[test]
fn test_compare_numeric_segments() {
// Helper function to create peekable iterators and test
fn compare(a: &str, b: &str) -> Ordering {
let mut a_iter = a.chars().peekable();
let mut b_iter = b.chars().peekable();
let result = compare_numeric_segments(&mut a_iter, &mut b_iter);
// Verify iterators advanced correctly
assert!(
!a_iter.next().map_or(false, |c| c.is_ascii_digit()),
"Iterator a should have consumed all digits"
);
assert!(
!b_iter.next().map_or(false, |c| c.is_ascii_digit()),
"Iterator b should have consumed all digits"
);
result
}
// Basic numeric comparisons
assert_eq!(compare("0", "0"), Ordering::Equal);
assert_eq!(compare("1", "2"), Ordering::Less);
assert_eq!(compare("9", "10"), Ordering::Less);
assert_eq!(compare("10", "9"), Ordering::Greater);
assert_eq!(compare("99", "100"), Ordering::Less);
// Leading zeros
assert_eq!(compare("0", "00"), Ordering::Less);
assert_eq!(compare("00", "0"), Ordering::Greater);
assert_eq!(compare("01", "1"), Ordering::Greater);
assert_eq!(compare("001", "1"), Ordering::Greater);
assert_eq!(compare("001", "01"), Ordering::Greater);
// Same value different representation
assert_eq!(compare("000100", "100"), Ordering::Greater);
assert_eq!(compare("100", "0100"), Ordering::Less);
assert_eq!(compare("0100", "00100"), Ordering::Less);
// Large numbers
assert_eq!(compare("9999999999", "10000000000"), Ordering::Less);
assert_eq!(
compare(
"340282366920938463463374607431768211455", // u128::MAX
"340282366920938463463374607431768211456"
),
Ordering::Less
);
assert_eq!(
compare(
"340282366920938463463374607431768211456", // > u128::MAX
"340282366920938463463374607431768211455"
),
Ordering::Greater
);
// Iterator advancement verification
let mut a_iter = "123abc".chars().peekable();
let mut b_iter = "456def".chars().peekable();
compare_numeric_segments(&mut a_iter, &mut b_iter);
assert_eq!(a_iter.collect::<String>(), "abc");
assert_eq!(b_iter.collect::<String>(), "def");
}
#[test]
fn test_natural_sort() {
// Basic alphanumeric
assert_eq!(natural_sort("a", "b"), Ordering::Less);
assert_eq!(natural_sort("b", "a"), Ordering::Greater);
assert_eq!(natural_sort("a", "a"), Ordering::Equal);
// Case sensitivity
assert_eq!(natural_sort("a", "A"), Ordering::Less);
assert_eq!(natural_sort("A", "a"), Ordering::Greater);
assert_eq!(natural_sort("aA", "aa"), Ordering::Greater);
assert_eq!(natural_sort("aa", "aA"), Ordering::Less);
// Numbers
assert_eq!(natural_sort("1", "2"), Ordering::Less);
assert_eq!(natural_sort("2", "10"), Ordering::Less);
assert_eq!(natural_sort("02", "10"), Ordering::Less);
assert_eq!(natural_sort("02", "2"), Ordering::Greater);
// Mixed alphanumeric
assert_eq!(natural_sort("a1", "a2"), Ordering::Less);
assert_eq!(natural_sort("a2", "a10"), Ordering::Less);
assert_eq!(natural_sort("a02", "a2"), Ordering::Greater);
assert_eq!(natural_sort("a1b", "a1c"), Ordering::Less);
// Multiple numeric segments
assert_eq!(natural_sort("1a2", "1a10"), Ordering::Less);
assert_eq!(natural_sort("1a10", "1a2"), Ordering::Greater);
assert_eq!(natural_sort("2a1", "10a1"), Ordering::Less);
// Special characters
assert_eq!(natural_sort("a-1", "a-2"), Ordering::Less);
assert_eq!(natural_sort("a_1", "a_2"), Ordering::Less);
assert_eq!(natural_sort("a.1", "a.2"), Ordering::Less);
// Unicode
assert_eq!(natural_sort("文1", "文2"), Ordering::Less);
assert_eq!(natural_sort("文2", "文10"), Ordering::Less);
assert_eq!(natural_sort("🔤1", "🔤2"), Ordering::Less);
// Empty and special cases
assert_eq!(natural_sort("", ""), Ordering::Equal);
assert_eq!(natural_sort("", "a"), Ordering::Less);
assert_eq!(natural_sort("a", ""), Ordering::Greater);
assert_eq!(natural_sort(" ", " "), Ordering::Less);
// Mixed everything
assert_eq!(natural_sort("File-1.txt", "File-2.txt"), Ordering::Less);
assert_eq!(natural_sort("File-02.txt", "File-2.txt"), Ordering::Greater);
assert_eq!(natural_sort("File-2.txt", "File-10.txt"), Ordering::Less);
assert_eq!(natural_sort("File_A1", "File_A2"), Ordering::Less);
assert_eq!(natural_sort("File_a1", "File_A1"), Ordering::Less);
}
#[test]
fn test_compare_paths() {
// Helper function for cleaner tests
fn compare(a: &str, is_a_file: bool, b: &str, is_b_file: bool) -> Ordering {
compare_paths((Path::new(a), is_a_file), (Path::new(b), is_b_file))
}
// Basic path comparison
assert_eq!(compare("a", true, "b", true), Ordering::Less);
assert_eq!(compare("b", true, "a", true), Ordering::Greater);
assert_eq!(compare("a", true, "a", true), Ordering::Equal);
// Files vs Directories
assert_eq!(compare("a", true, "a", false), Ordering::Greater);
assert_eq!(compare("a", false, "a", true), Ordering::Less);
assert_eq!(compare("b", false, "a", true), Ordering::Less);
// Extensions
assert_eq!(compare("a.txt", true, "a.md", true), Ordering::Greater);
assert_eq!(compare("a.md", true, "a.txt", true), Ordering::Less);
assert_eq!(compare("a", true, "a.txt", true), Ordering::Less);
// Nested paths
assert_eq!(compare("dir/a", true, "dir/b", true), Ordering::Less);
assert_eq!(compare("dir1/a", true, "dir2/a", true), Ordering::Less);
assert_eq!(compare("dir/sub/a", true, "dir/a", true), Ordering::Less);
// Case sensitivity in paths
assert_eq!(
compare("Dir/file", true, "dir/file", true),
Ordering::Greater
);
assert_eq!(
compare("dir/File", true, "dir/file", true),
Ordering::Greater
);
assert_eq!(compare("dir/file", true, "Dir/File", true), Ordering::Less);
// Hidden files and special names
assert_eq!(compare(".hidden", true, "visible", true), Ordering::Less);
assert_eq!(compare("_special", true, "normal", true), Ordering::Less);
assert_eq!(compare(".config", false, ".data", false), Ordering::Less);
// Mixed numeric paths
assert_eq!(
compare("dir1/file", true, "dir2/file", true),
Ordering::Less
);
assert_eq!(
compare("dir2/file", true, "dir10/file", true),
Ordering::Less
);
assert_eq!(
compare("dir02/file", true, "dir2/file", true),
Ordering::Greater
);
// Root paths
assert_eq!(compare("/a", true, "/b", true), Ordering::Less);
assert_eq!(compare("/", false, "/a", true), Ordering::Less);
// Complex real-world examples
assert_eq!(
compare("project/src/main.rs", true, "project/src/lib.rs", true),
Ordering::Greater
);
assert_eq!(
compare(
"project/tests/test_1.rs",
true,
"project/tests/test_2.rs",
true
),
Ordering::Less
);
assert_eq!(
compare(
"project/v1.0.0/README.md",
true,
"project/v1.10.0/README.md",
true
),
Ordering::Less
);
}
#[test]
fn test_natural_sort_case_sensitivity() {
// Same letter different case - lowercase should come first
assert_eq!(natural_sort("a", "A"), Ordering::Less);
assert_eq!(natural_sort("A", "a"), Ordering::Greater);
assert_eq!(natural_sort("a", "a"), Ordering::Equal);
assert_eq!(natural_sort("A", "A"), Ordering::Equal);
// Mixed case strings
assert_eq!(natural_sort("aaa", "AAA"), Ordering::Less);
assert_eq!(natural_sort("AAA", "aaa"), Ordering::Greater);
assert_eq!(natural_sort("aAa", "AaA"), Ordering::Less);
// Different letters
assert_eq!(natural_sort("a", "b"), Ordering::Less);
assert_eq!(natural_sort("A", "b"), Ordering::Less);
assert_eq!(natural_sort("a", "B"), Ordering::Less);
}
#[test]
fn test_natural_sort_with_numbers() {
// Basic number ordering
assert_eq!(natural_sort("file1", "file2"), Ordering::Less);
assert_eq!(natural_sort("file2", "file10"), Ordering::Less);
assert_eq!(natural_sort("file10", "file2"), Ordering::Greater);
// Numbers in different positions
assert_eq!(natural_sort("1file", "2file"), Ordering::Less);
assert_eq!(natural_sort("file1text", "file2text"), Ordering::Less);
assert_eq!(natural_sort("text1file", "text2file"), Ordering::Less);
// Multiple numbers in string
assert_eq!(natural_sort("file1-2", "file1-10"), Ordering::Less);
assert_eq!(natural_sort("2-1file", "10-1file"), Ordering::Less);
// Leading zeros
assert_eq!(natural_sort("file002", "file2"), Ordering::Greater);
assert_eq!(natural_sort("file002", "file10"), Ordering::Less);
// Very large numbers
assert_eq!(
natural_sort("file999999999999999999999", "file999999999999999999998"),
Ordering::Greater
);
// u128 edge cases
// Numbers near u128::MAX (340,282,366,920,938,463,463,374,607,431,768,211,455)
assert_eq!(
natural_sort(
"file340282366920938463463374607431768211454",
"file340282366920938463463374607431768211455"
),
Ordering::Less
);
// Equal length numbers that overflow u128
assert_eq!(
natural_sort(
"file340282366920938463463374607431768211456",
"file340282366920938463463374607431768211455"
),
Ordering::Greater
);
// Different length numbers that overflow u128
assert_eq!(
natural_sort(
"file3402823669209384634633746074317682114560",
"file340282366920938463463374607431768211455"
),
Ordering::Greater
);
// Leading zeros with numbers near u128::MAX
assert_eq!(
natural_sort(
"file0340282366920938463463374607431768211455",
"file340282366920938463463374607431768211455"
),
Ordering::Greater
);
// Very large numbers with different lengths (both overflow u128)
assert_eq!(
natural_sort(
"file999999999999999999999999999999999999999999999999",
"file9999999999999999999999999999999999999999999999999"
),
Ordering::Less
);
// Mixed case with numbers
assert_eq!(natural_sort("File1", "file2"), Ordering::Greater);
assert_eq!(natural_sort("file1", "File2"), Ordering::Less);
}
#[test]
fn test_natural_sort_edge_cases() {
// Empty strings
assert_eq!(natural_sort("", ""), Ordering::Equal);
assert_eq!(natural_sort("", "a"), Ordering::Less);
assert_eq!(natural_sort("a", ""), Ordering::Greater);
// Special characters
assert_eq!(natural_sort("file-1", "file_1"), Ordering::Less);
assert_eq!(natural_sort("file.1", "file_1"), Ordering::Less);
assert_eq!(natural_sort("file 1", "file_1"), Ordering::Less);
// Unicode characters
// 9312 vs 9313
assert_eq!(natural_sort("file①", "file②"), Ordering::Less);
// 9321 vs 9313
assert_eq!(natural_sort("file⑩", "file②"), Ordering::Greater);
// 28450 vs 23383
assert_eq!(natural_sort("file漢", "file字"), Ordering::Greater);
// Mixed alphanumeric with special chars
assert_eq!(natural_sort("file-1a", "file-1b"), Ordering::Less);
assert_eq!(natural_sort("file-1.2", "file-1.10"), Ordering::Less);
assert_eq!(natural_sort("file-1.10", "file-1.2"), Ordering::Greater);
}
}