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}