Skip to main content

trail/app/
sort.rs

1//! How the navigation listing is ordered.
2//!
3//! Owns the sort key ([`SortBy`]), the per-tab settings that select one
4//! ([`SortSettings`]), and the comparison itself ([`sort_entries`]). The
5//! architecture doc gives the navigation panel the job of ordering the listing;
6//! this is that ordering, kept out of `state.rs` so the rules can be read and
7//! tested on their own.
8//!
9//! Every key is derived from data the directory listing already holds:
10//! `Entry::file_name` and the `Entry::metadata` that `fs::DirEntry::metadata`
11//! produced while the directory was being walked. Nothing here performs I/O, so
12//! re-sorting is free and the UI thread never blocks on it.
13
14use std::cmp::Ordering;
15
16use crate::app::state::{Entry, EntryKind};
17
18/// Which property the listing is ordered by.
19///
20/// Parsed from `[navigation] sort_by`, `:sort <key>` and `:set sort_by <key>`;
21/// spelled back with [`SortBy::as_str`] so the config, the command and the
22/// status-bar notice all use one vocabulary.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum SortBy {
25    /// File name, case-insensitive. The default, and Trail's only order before
26    /// v1.8.0.
27    #[default]
28    Name,
29    /// Size in bytes, largest first.
30    Size,
31    /// Modification time, most recent first.
32    Modified,
33    /// File extension, case-insensitive, then name.
34    Extension,
35}
36
37impl SortBy {
38    /// Parses the spelling used in config and commands, or `None` if it names
39    /// no known key.
40    ///
41    /// Case-insensitive, and `time` is accepted as an alias for `modified`
42    /// because that is what the `st` binding is named after.
43    pub fn parse(value: &str) -> Option<Self> {
44        match value.trim().to_ascii_lowercase().as_str() {
45            "name" => Some(Self::Name),
46            "size" => Some(Self::Size),
47            "modified" | "time" => Some(Self::Modified),
48            "extension" | "ext" => Some(Self::Extension),
49            _ => None,
50        }
51    }
52
53    /// The canonical spelling of this key, as `[navigation] sort_by` takes it.
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::Name => "name",
57            Self::Size => "size",
58            Self::Modified => "modified",
59            Self::Extension => "extension",
60        }
61    }
62
63    /// A short spelling for the navigation panel's sort badge.
64    ///
65    /// Four characters at most, because the badge shares the panel's top border
66    /// with the directory name.
67    pub fn short_label(self) -> &'static str {
68        match self {
69            Self::Name => "name",
70            Self::Size => "size",
71            Self::Modified => "time",
72            Self::Extension => "ext",
73        }
74    }
75
76    /// Whether this key reads best from its high end down.
77    ///
78    /// Size and time do: you went looking for the biggest or the newest, so
79    /// that end belongs at the top, which is what `ls -S` and `ls -t` do. Name
80    /// and extension run A–Z.
81    ///
82    /// This is the single definition of the natural direction —
83    /// [`sort_entries`] orders by it and the badge reports it, so the two
84    /// cannot come to disagree about which way a listing runs.
85    pub fn descends_by_default(self) -> bool {
86        matches!(self, Self::Size | Self::Modified)
87    }
88}
89
90/// Explanation attached to a rejected `sort_by` value.
91pub const SORT_BY_REASON: &str = "must be name, size, modified or extension";
92
93/// One tab's ordering of its listing.
94///
95/// Held per tab rather than per application: a tab is a place you are working,
96/// and the order that suits a source tree ("name") is not the one that suits a
97/// downloads folder ("modified"). [`crate::app::tabs::TabState`] carries a copy
98/// and `AppState` mirrors the active tab's, the same way `cwd` and `selected`
99/// are mirrored.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct SortSettings {
102    /// Which property the listing is ordered by.
103    pub by: SortBy,
104    /// Whether the order within each group is flipped.
105    pub reverse: bool,
106    /// Whether directories are grouped ahead of files regardless of `by`.
107    pub dirs_first: bool,
108}
109
110impl Default for SortSettings {
111    /// Trail's ordering before v1.8.0: directories first, then name,
112    /// case-insensitive.
113    fn default() -> Self {
114        Self {
115            by: SortBy::Name,
116            reverse: false,
117            dirs_first: true,
118        }
119    }
120}
121
122impl SortSettings {
123    /// Whether values descend as the listing is read downwards.
124    ///
125    /// The direction the badge's arrow reports: `true` means the largest, newest
126    /// or last-alphabetically entry is at the top. `reverse` flips whatever
127    /// [`SortBy::descends_by_default`] says.
128    pub fn is_descending(self) -> bool {
129        self.by.descends_by_default() != self.reverse
130    }
131
132    /// A one-line description for the status bar, e.g. `sort: size, reversed`.
133    pub fn describe(self) -> String {
134        let mut out = format!("sort: {}", self.by.as_str());
135        if self.reverse {
136            out.push_str(", reversed");
137        }
138        if !self.dirs_first {
139            out.push_str(", mixed with directories");
140        }
141        out
142    }
143}
144
145/// Orders `entries` in place according to `settings`.
146///
147/// The rules, in the order they are applied:
148///
149/// 1. **Directories first**, when `settings.dirs_first`. The grouping is an
150///    axis of its own and `reverse` does not flip it — reversing a listing is
151///    asking for the files in the other order, not for the directories to move
152///    to the bottom.
153/// 2. **The key**, from `settings.by`. `Size` and `Modified` read largest-first
154///    and newest-first, the way `ls -S` and `ls -t` do, because that is the end
155///    of the range you went looking for. `reverse` flips whichever order is
156///    active.
157/// 3. **Name, case-insensitive**, as the tie-break. Without it the order would
158///    be arbitrary exactly where ties are commonest — every empty file shares a
159///    size, and half a working tree shares an mtime after a checkout — and an
160///    arbitrary order that changes between refreshes reads as a bug.
161///
162/// Two cases are deliberately not ranked by their key:
163///
164/// - **Directories in a size sort** keep name order. `Metadata::len` on a
165///   directory is the size of its directory record, not of what is in it, so
166///   ranking by it scatters directories through the list on a number that means
167///   nothing.
168/// - **An entry that could not be stat'd** sorts to the end of its group in a
169///   `Size` or `Modified` sort, and stays there under `reverse`. It has no key
170///   to rank; the point is to keep it out of the way rather than to give it a
171///   position.
172///
173/// Symlink size and time are the link's own, not its target's, because the
174/// listing's metadata comes from an `lstat` — following them would mean I/O per
175/// entry on the UI thread and a cycle to guard against.
176pub fn sort_entries(entries: &mut [Entry], settings: SortSettings) {
177    entries.sort_by(|a, b| compare(a, b, settings));
178}
179
180/// Orders one pair of entries. Split out from [`sort_entries`] so the rules can
181/// be exercised directly.
182fn compare(a: &Entry, b: &Entry, settings: SortSettings) -> Ordering {
183    if settings.dirs_first {
184        let a_dir = a.kind == EntryKind::Dir;
185        let b_dir = b.kind == EntryKind::Dir;
186        match (a_dir, b_dir) {
187            (true, false) => return Ordering::Less,
188            (false, true) => return Ordering::Greater,
189            _ => {}
190        }
191    }
192
193    // A directory's byte length says nothing about its contents, so two of them
194    // fall back to the name rather than being ranked on it.
195    let by = if settings.by == SortBy::Size && a.kind == EntryKind::Dir && b.kind == EntryKind::Dir
196    {
197        SortBy::Name
198    } else {
199        settings.by
200    };
201
202    if matches!(by, SortBy::Size | SortBy::Modified) {
203        match (a.metadata.is_some(), b.metadata.is_some()) {
204            (true, false) => return Ordering::Less,
205            (false, true) => return Ordering::Greater,
206            _ => {}
207        }
208    }
209
210    let key = match by {
211        // The name is the tie-break below, so a name sort has no key of its own.
212        SortBy::Name => Ordering::Equal,
213        SortBy::Size => size_of(a).cmp(&size_of(b)),
214        SortBy::Modified => modified_of(a).cmp(&modified_of(b)),
215        SortBy::Extension => extension_of(a).cmp(&extension_of(b)),
216    };
217
218    // Size and time read from their high end down; see
219    // `SortBy::descends_by_default`, which the panel's badge reads too.
220    let key = if by.descends_by_default() {
221        key.reverse()
222    } else {
223        key
224    };
225
226    let ord = key.then_with(|| a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()));
227
228    if settings.reverse {
229        ord.reverse()
230    } else {
231        ord
232    }
233}
234
235/// The entry's size in bytes, or `0` when it could not be stat'd.
236///
237/// A missing size never reaches a comparison against a present one — the
238/// caller has already separated them — so the fallback only ever orders two
239/// unknowns against each other, where the name tie-break decides.
240fn size_of(entry: &Entry) -> u64 {
241    entry.metadata.as_ref().map(|m| m.len()).unwrap_or(0)
242}
243
244/// The entry's modification time, or `None` when the platform will not say.
245///
246/// `Metadata::modified` is `Err` on the rare filesystem that does not record
247/// one; such an entry sorts with the other unknowns rather than at an arbitrary
248/// time.
249fn modified_of(entry: &Entry) -> Option<std::time::SystemTime> {
250    entry.metadata.as_ref().and_then(|m| m.modified().ok())
251}
252
253/// The entry's extension, lowercased, or the empty string when it has none.
254///
255/// `Path::extension` reports `None` for a leading-dot name such as
256/// `.gitignore`, so dotfiles group with the extensionless rather than forming a
257/// `gitignore` group of one.
258fn extension_of(entry: &Entry) -> String {
259    entry
260        .path
261        .extension()
262        .and_then(|e| e.to_str())
263        .map(|e| e.to_lowercase())
264        .unwrap_or_default()
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn parse_accepts_every_canonical_spelling() {
273        for key in ["name", "size", "modified", "extension"] {
274            let parsed = SortBy::parse(key).expect("canonical spelling parses");
275            assert_eq!(parsed.as_str(), key, "round trip for {key}");
276        }
277    }
278
279    #[test]
280    fn parse_accepts_aliases_and_case() {
281        assert_eq!(SortBy::parse("TIME"), Some(SortBy::Modified));
282        assert_eq!(SortBy::parse(" ext "), Some(SortBy::Extension));
283        assert_eq!(SortBy::parse("Name"), Some(SortBy::Name));
284    }
285
286    #[test]
287    fn parse_rejects_an_unknown_key() {
288        assert_eq!(SortBy::parse("created"), None);
289        assert_eq!(SortBy::parse(""), None);
290    }
291
292    #[test]
293    fn default_is_the_pre_1_8_order() {
294        let settings = SortSettings::default();
295        assert_eq!(settings.by, SortBy::Name);
296        assert!(!settings.reverse);
297        assert!(settings.dirs_first);
298    }
299
300    #[test]
301    fn size_and_time_descend_by_default_and_the_others_do_not() {
302        assert!(SortBy::Size.descends_by_default());
303        assert!(SortBy::Modified.descends_by_default());
304        assert!(!SortBy::Name.descends_by_default());
305        assert!(!SortBy::Extension.descends_by_default());
306    }
307
308    #[test]
309    fn reverse_flips_the_reported_direction_for_every_key() {
310        for by in [
311            SortBy::Name,
312            SortBy::Size,
313            SortBy::Modified,
314            SortBy::Extension,
315        ] {
316            let forward = SortSettings {
317                by,
318                reverse: false,
319                dirs_first: true,
320            };
321            let flipped = SortSettings {
322                reverse: true,
323                ..forward
324            };
325            assert_eq!(
326                forward.is_descending(),
327                by.descends_by_default(),
328                "{by:?} unreversed should report its natural direction"
329            );
330            assert_ne!(
331                forward.is_descending(),
332                flipped.is_descending(),
333                "{by:?} reversed should report the other direction"
334            );
335        }
336    }
337
338    #[test]
339    fn short_labels_stay_inside_the_badge_budget() {
340        for by in [
341            SortBy::Name,
342            SortBy::Size,
343            SortBy::Modified,
344            SortBy::Extension,
345        ] {
346            let label = by.short_label();
347            assert!(
348                (1..=4).contains(&label.chars().count()),
349                "{by:?} label {label:?} does not fit the badge"
350            );
351        }
352    }
353
354    #[test]
355    fn describe_names_only_what_is_not_default() {
356        assert_eq!(SortSettings::default().describe(), "sort: name");
357        let reversed = SortSettings {
358            reverse: true,
359            ..SortSettings::default()
360        };
361        assert_eq!(reversed.describe(), "sort: name, reversed");
362    }
363}