project_panel: Make natural sort ordering consistent with other apps (#41080)

The existing sorting approach when faced with `Dir1`, `dir2`, `Dir3`,
would only get as far as comparing the stems without numbers (`dir` and
`Dir`), and then the lowercase-first tie breaker in that function would
determine that `dir2` should come first, resulting in an undesirable
order of `dir2`, `Dir1`, `Dir3`.

This patch defers tie-breaking until it's determined that there's no
other difference in the strings outside of case to order on, at which
point we tie-break to provide a stable sort.

Natural number sorting is still preserved, and mixing different cases
alphabetically (as opposed to all lowercase alpha, followed by all
uppercase alpha) is preserved.

Closes #41080


Release Notes:

- Fixed: ProjectPanel sorting bug

Screenshots:

Before | After
----|---
<img width="237" height="325" alt="image"
src="https://github.com/user-attachments/assets/6e92e8c0-2172-4a8f-a058-484749da047b"
/> | <img width="239" height="325" alt="image"
src="https://github.com/user-attachments/assets/874ad29f-7238-4bfc-b89b-fd64f9b8889a"
/>

I'm having trouble reasoning through what was previously going wrong
with `docs` in the before screenshot, but it also seems to now appear
alphabetically where you'd expect it with this patch

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Lucas Parry
2025-11-13 02:04:40 +05:30
committed by GitHub
co-authored by Smit Barmase
parent 6b239c3a9a
commit fd837b348f
+56 -22
View File
@@ -800,22 +800,6 @@ impl Default for 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
@@ -916,21 +900,25 @@ where
/// * 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)
/// * When strings are equal case-insensitively, lowercase is prioritized (lowercase < uppercase)
///
/// # Algorithm
///
/// The function works by:
/// 1. Processing strings character by character
/// 1. Processing strings character by character in a case-insensitive manner
/// 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
/// 4. For non-numeric characters, using case-insensitive comparison
/// 5. If everything is equal case-insensitively, using case-sensitive comparison as final tie-breaker
pub 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, None) => {
return b.cmp(a);
}
(None, _) => return Ordering::Less,
(_, None) => return Ordering::Greater,
(Some(&a_char), Some(&b_char)) => {
@@ -940,7 +928,10 @@ pub fn natural_sort(a: &str, b: &str) -> Ordering {
ordering => return ordering,
}
} else {
match compare_chars(a_char, b_char) {
match a_char
.to_ascii_lowercase()
.cmp(&b_char.to_ascii_lowercase())
{
Ordering::Equal => {
a_iter.next();
b_iter.next();
@@ -952,6 +943,7 @@ pub fn natural_sort(a: &str, b: &str) -> Ordering {
}
}
}
pub fn compare_rel_paths(
(path_a, a_is_file): (&RelPath, bool),
(path_b, b_is_file): (&RelPath, bool),
@@ -1246,6 +1238,33 @@ mod tests {
);
}
#[perf]
fn compare_paths_mixed_case_numeric_ordering() {
let mut entries = [
(Path::new(".config"), false),
(Path::new("Dir1"), false),
(Path::new("dir01"), false),
(Path::new("dir2"), false),
(Path::new("Dir02"), false),
(Path::new("dir10"), false),
(Path::new("Dir10"), false),
];
entries.sort_by(|&a, &b| compare_paths(a, b));
let ordered: Vec<&str> = entries
.iter()
.map(|(path, _)| path.to_str().unwrap())
.collect();
assert_eq!(
ordered,
vec![
".config", "Dir1", "dir01", "dir2", "Dir02", "dir10", "Dir10"
]
);
}
#[perf]
fn path_with_position_parse_posix_path() {
// Test POSIX filename edge cases
@@ -1917,10 +1936,25 @@ mod tests {
),
Ordering::Less
);
}
// Mixed case with numbers
assert_eq!(natural_sort("File1", "file2"), Ordering::Greater);
#[perf]
fn test_natural_sort_case_sensitive() {
// Numerically smaller values come first.
assert_eq!(natural_sort("File1", "file2"), Ordering::Less);
assert_eq!(natural_sort("file1", "File2"), Ordering::Less);
// Numerically equal values: the case-insensitive comparison decides first.
// Case-sensitive comparison only occurs when both are equal case-insensitively.
assert_eq!(natural_sort("Dir1", "dir01"), Ordering::Less);
assert_eq!(natural_sort("dir2", "Dir02"), Ordering::Less);
assert_eq!(natural_sort("dir2", "dir02"), Ordering::Less);
// Numerically equal and case-insensitively equal:
// the lexicographically smaller (case-sensitive) one wins.
assert_eq!(natural_sort("dir1", "Dir1"), Ordering::Less);
assert_eq!(natural_sort("dir02", "Dir02"), Ordering::Less);
assert_eq!(natural_sort("dir10", "Dir10"), Ordering::Less);
}
#[perf]