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

Lucas Parry and Smit Barmase created

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>

Change summary

crates/util/src/paths.rs | 78 ++++++++++++++++++++++++++++++-----------
1 file changed, 56 insertions(+), 22 deletions(-)

Detailed changes

crates/util/src/paths.rs 🔗

@@ -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]