Repository: Byron/dua-cli (Rust)

Issue:
FR: Add "sort by name" in interactive mode
In interactive mode, we have
```
s => Toggle sort by size
m => Toggle sort by modified time
c => Toggle sort by entries
```

Sometimes, I'd like to sort by name.

Vaguely related: #83 and #126


Discussion:

Patch:
diff --git a/src/interactive/app/common.rs b/src/interactive/app/common.rs
index 433a793..54737c8 100644
--- a/src/interactive/app/common.rs
+++ b/src/interactive/app/common.rs
@@ -15,6 +15,8 @@ pub enum SortMode {
     MTimeAscending,
     CountDescending,
     CountAscending,
+    NameDescending,
+    NameAscending,
 }
 
 impl SortMode {
@@ -44,6 +46,15 @@ impl SortMode {
             _ => CountDescending,
         }
     }
+
+    pub fn toggle_name(&mut self) {
+        use SortMode::*;
+        *self = match self {
+            NameAscending => NameDescending,
+            NameDescending => NameAscending,
+            _ => NameAscending,
+        }
+    }
 }
 
 pub struct EntryDataBundle {
@@ -86,17 +97,26 @@ pub fn sorted_entries(
             .cmp(&r.entry_count)
             .then_with(|| l.name.cmp(&r.name))
     }
+    fn cmp_name(l: &EntryDataBundle, r: &EntryDataBundle) -> Ordering {
+        if l.is_dir && !r.is_dir {
+            Ordering::Less
+        } else if !l.is_dir && r.is_dir {
+            Ordering::Greater
+        } else {
+            l.name.cmp(&r.name)
+        }
+    }
     tree.neighbors_directed(node_idx, Direction::Outgoing)
         .filter_map(|idx| {
             tree.node_weight(idx).map(|entry| {
-                let use_glob_path = glob_root.map_or(false, |glob_root| glob_root == node_idx);
+                let use_glob_path = glob_root.is_some_and(|glob_root| glob_root == node_idx);
                 let (path, exists, is_dir) = {
                     let path = path_of(tree, idx, glob_root);
                     if matches!(check, EntryCheck::Disabled) || glob_root == Some(node_idx) {
                         (path, true, entry.is_dir)
                     } else {
                         let meta = path.symlink_metadata();
-                        (path, meta.is_ok(), meta.ok().map_or(false, |m| m.is_dir()))
+                        (path, meta.is_ok(), meta.ok().is_some_and(|m| m.is_dir()))
                     }
                 };
                 EntryDataBundle {
@@ -121,6 +141,8 @@ pub fn sorted_entries(
             MTimeDescending => r.mtime.cmp(&l.mtime),
             CountAscending => cmp_count(l, r),
             CountDescending => cmp_count(l, r).reverse(),
+            NameAscending => cmp_name(l, r),
+            NameDescending => cmp_name(l, r).reverse(),
         })
         .collect()
 }
diff --git a/src/interactive/app/eventloop.rs b/src/interactive/app/eventloop.rs
index c082414..fb14cff 100644
--- a/src/interactive/app/eventloop.rs
+++ b/src/interactive/app/eventloop.rs
@@ -332,6 +332,7 @@ impl AppState {
                     Char('M') => self.toggle_mtime_column(),
                     Char('c') => self.cycle_count_sorting(&tree_view),
                     Char('C') => self.toggle_count_column(),
+                    Char('n') => self.cycle_name_sorting(&tree_view),
                     Char('g') | Char('S') => display.byte_vis.cycle(),
                     Char('d') => self.mark_entry(
                         CursorMode::Advance,
diff --git a/src/interactive/app/handlers.rs b/src/interactive/app/handlers.rs
index ed4b611..cc77401 100644
--- a/src/interactive/app/handlers.rs
+++ b/src/interactive/app/handlers.rs
@@ -146,6 +146,15 @@ impl AppState {
         );
     }
 
+    pub fn cycle_name_sorting(&mut self, tree_view: &TreeView<'_>) {
+        self.sorting.toggle_name();
+        self.entries = tree_view.sorted_entries(
+            self.navigation().view_root,
+            self.sorting,
+            self.entry_check(),
+        );
+    }
+
     pub fn toggle_mtime_column(&mut self) {
         self.toggle_column(Column::MTime);
     }
diff --git a/src/interactive/widgets/footer.rs b/src/interactive/widgets/footer.rs
index 80bd352..d643008 100644
--- a/src/interactive/widgets/footer.rs
+++ b/src/interactive/widgets/footer.rs
@@ -45,6 +45,8 @@ impl Footer {
                     SortMode::MTimeDescending => "modified descending",
                     SortMode::CountAscending => "items ascending",
                     SortMode::CountDescending => "items descending",
+                    SortMode::NameAscending => "name ascending",
+                    SortMode::NameDescending => "name descending",
                 },
                 format.display(*total_bytes),
                 entries_traversed,
diff --git a/src/interactive/widgets/glob.rs b/src/interactive/widgets/glob.rs
index cbaa363..e5b8c38 100644
--- a/src/interactive/widgets/glob.rs
+++ b/src/interactive/widgets/glob.rs
@@ -74,7 +74,7 @@ impl GlobPane {
             self.input
                 .graphemes(true)
                 .take(self.cursor_grapheme_idx)
-                .map(|g| g.as_bytes().len())
+                .map(|g| g.len())
                 .sum::<usize>(),
             new_char,
         );
diff --git a/src/interactive/widgets/help.rs b/src/interactive/widgets/help.rs
index 2dafb0f..054a386 100644
--- a/src/interactive/widgets/help.rs
+++ b/src/interactive/widgets/help.rs
@@ -152,6 +152,7 @@ impl HelpPane {
                 hotkey("M", "Show/hide modified time.", None);
                 hotkey("c", "Toggle sort by entries descending/ascending.", None);
                 hotkey("C", "Show/hide entry count.", None);
+                hotkey("n", "Toggle sort by name ascending/descending.", None);
                 hotkey(
                     "g/S",
                     "Cycle through percentage display and bar options.",
diff --git a/src/traverse.rs b/src/traverse.rs
index 648eaa7..883dcc4 100644
--- a/src/traverse.rs
+++ b/src/traverse.rs
@@ -387,7 +387,7 @@ impl BackgroundTraversal {
                     }
                 }
 
-                if self.throttle.as_ref().map_or(false, |t| t.can_update()) {
+                if self.throttle.as_ref().is_some_and(|t| t.can_update()) {
                     return Some(false);
                 }
             }


Test patch:
diff --git a/src/interactive/app/tests/journeys_readonly.rs b/src/interactive/app/tests/journeys_readonly.rs
index 80098e0..1954184 100644
--- a/src/interactive/app/tests/journeys_readonly.rs
+++ b/src/interactive/app/tests/journeys_readonly.rs
@@ -68,6 +68,20 @@ fn simple_user_journey_read_only() -> Result<()> {
 
     // SORTING
     {
+        // when hitting the N key
+        app.process_events(&mut terminal, into_codes("n"))?;
+        assert_eq!(
+            app.state.sorting,
+            SortMode::NameAscending,
+            "it sets the sort mode to ascending by name"
+        );
+        // when hitting the N key again
+        app.process_events(&mut terminal, into_codes("n"))?;
+        assert_eq!(
+            app.state.sorting,
+            SortMode::NameDescending,
+            "it sets the sort mode to descending by name"
+        );
         // when hitting the M key
         app.process_events(&mut terminal, into_codes("m"))?;
         assert_eq!(
@@ -301,9 +315,10 @@ fn simple_user_journey_read_only() -> Result<()> {
                 "it marks only a single node",
             );
             assert!(
-                app.window.mark_pane.as_ref().map_or(false, |p| p
-                    .marked()
-                    .contains_key(&previously_selected_index)),
+                app.window
+                    .mark_pane
+                    .as_ref()
+                    .is_some_and(|p| p.marked().contains_key(&previously_selected_index)),
                 "it marks the selected node"
             );
             assert_eq!(
@@ -341,9 +356,10 @@ fn simple_user_journey_read_only() -> Result<()> {
             );
 
             assert!(
-                app.window.mark_pane.as_ref().map_or(false, |p| p
-                    .marked()
-                    .contains_key(&previously_selected_index)),
+                app.window
+                    .mark_pane
+                    .as_ref()
+                    .is_some_and(|p| p.marked().contains_key(&previously_selected_index)),
                 "it leaves the first selected item marked"
             );
         }