1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
//! Sort mode for the activity list panels — TODOS / NOTES / FINDINGS.
//!
//! User ask 2026-09-01: "not sure what controls order of notes, maybe
//! we need view modes like A-Z or newest ... findings, todo, and notes
//! might need same or similar."
//!
//! **Why a shared enum here, when mnml already has seven sort types.**
//! `file_browser::Sort`, `SessionsSortMode`, `InstalledSort`,
//! `MarketplaceSort`, `TestsSort`, `SpendSortKey` and
//! `claude_agents::SortBy` all exist, and they are NOT collapsible: a
//! file browser sorts dirs-first, sessions sort by run state, the
//! marketplace by install count. Their variants are genuinely
//! different, so a single universal `Sort` would be a lie that every
//! caller then works around.
//!
//! These three panels are the exception — all three are lists of files
//! with a name and an mtime, so they share both variants exactly. That
//! is the test for belonging here: same variants, not merely "also
//! sorted".
/// How a list panel orders its rows.
///
/// Every key is PAIRED with its reverse (user 2026-09-03: "shoudl todos
/// have an oldest first too? and what abotu z-a, i think the pared ones
/// shoudl show"). Four explicit variants rather than two keys plus a
/// `desc: bool`, because these are what the menu lists — a flag would
/// have to be flattened back into four rows at every call site, and the
/// click-to-cycle chip would need to know how to walk a 2-D space.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ListSort {
/// Most-recently-modified first. The default for all three panels
/// because it was already their hard-coded behaviour — changing
/// what a user sees on upgrade is a separate decision from letting
/// them choose.
#[default]
Newest,
/// Least-recently-modified first.
Oldest,
/// A–Z by the name the row displays. Case-insensitive, because a
/// user scanning for `README` does not think about capitalisation.
Name,
/// Z–A by the same key.
NameDesc,
}
impl ListSort {
/// The label shown in the panel's sort menu.
pub fn label(self) -> &'static str {
match self {
ListSort::Newest => "Newest first",
ListSort::Oldest => "Oldest first",
ListSort::Name => "Name (A–Z)",
ListSort::NameDesc => "Name (Z–A)",
}
}
/// Config token. Kept short and stable — this lands in the user's
/// `config.toml`.
pub fn as_str(self) -> &'static str {
match self {
ListSort::Newest => "newest",
ListSort::Oldest => "oldest",
ListSort::Name => "name",
ListSort::NameDesc => "name_desc",
}
}
/// Parse a config token. Unknown values fall back to the default
/// rather than erroring: a typo should not stop the panel drawing.
///
/// Named `from_token`, not `from_str`, so it is not mistaken for
/// `std::str::FromStr` — this never fails, and a `Result` here
/// would push a pointless unwrap onto every caller.
pub fn from_token(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
// `newest` and `name` predate the reversed pairs and are
// already in users' config.toml — they must keep parsing
// to the same modes they always did.
"oldest" => ListSort::Oldest,
"name" => ListSort::Name,
"name_desc" => ListSort::NameDesc,
_ => ListSort::Newest,
}
}
/// Every mode, in menu order — each key next to its reverse, so
/// the pairing is visible in the menu and one click of the chip
/// flips direction rather than jumping to an unrelated key.
pub fn all() -> [ListSort; 4] {
[
ListSort::Newest,
ListSort::Oldest,
ListSort::Name,
ListSort::NameDesc,
]
}
}
/// Sort `paths` in place.
///
/// `key` yields the string a row DISPLAYS, which is not always the file
/// name — FINDINGS shows a path relative to its root so nested
/// tester-round directories keep their context. Sorting by the file
/// name there would order rows differently from how they read.
pub fn sort_paths<F>(paths: &mut [std::path::PathBuf], mode: ListSort, key: F)
where
F: Fn(&std::path::PathBuf) -> String,
{
match mode {
ListSort::Name => {
paths.sort_by_key(|p| key(p).to_lowercase());
}
ListSort::NameDesc => {
paths.sort_by_key(|p| std::cmp::Reverse(key(p).to_lowercase()));
}
ListSort::Oldest => {
// Oldest is Newest's exact mirror INCLUDING the tiebreak:
// name stays ascending so same-second files read A→Z in
// both directions. Reversing the whole comparator would
// flip the tiebreak too, which is not what "oldest first"
// means to anyone.
paths.sort_by_key(|p| (mtime_secs(p), key(p).to_lowercase()));
}
ListSort::Newest => {
// Name is the TIEBREAK, not decoration. mtime has
// one-second resolution here, so files written in the same
// second — which is most of a freshly-cloned or
// agent-written directory — otherwise render in raw
// `read_dir` order: `note-24, note-10, note-34`. TODOS
// already tie-broke; the shared helper did not.
paths.sort_by_key(|p| (std::cmp::Reverse(mtime_secs(p)), key(p).to_lowercase()));
}
}
}
/// Seconds since the epoch, or 0 when the file is gone. A missing
/// mtime must not panic a redraw — the row simply sorts as oldest.
fn mtime_secs(p: &std::path::Path) -> u64 {
std::fs::metadata(p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn name_sort_is_case_insensitive() {
let mut v = vec![
PathBuf::from("/x/beta.md"),
PathBuf::from("/x/Alpha.md"),
PathBuf::from("/x/gamma.md"),
];
sort_paths(&mut v, ListSort::Name, |p| {
p.file_name().unwrap().to_string_lossy().into_owned()
});
let names: Vec<String> = v
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec!["Alpha.md", "beta.md", "gamma.md"],
"capitalised names sorted into their own block"
);
}
/// The key is what the row DISPLAYS, not the file name — FINDINGS
/// shows a relative path, and sorting by file name there would
/// order rows differently from how they read on screen.
#[test]
fn name_sort_uses_the_displayed_key_not_the_file_name() {
let mut v = vec![
PathBuf::from("/root/zzz/a.md"),
PathBuf::from("/root/aaa/z.md"),
];
sort_paths(&mut v, ListSort::Name, |p| {
p.strip_prefix("/root")
.unwrap_or(p)
.to_string_lossy()
.into_owned()
});
assert_eq!(
v[0],
PathBuf::from("/root/aaa/z.md"),
"sorted by file name instead of the displayed path"
);
}
/// mtime has one-second resolution, so files written together —
/// most of a cloned or agent-written directory — tie. Without a
/// tiebreak they render in raw `read_dir` order, which looks
/// random: `note-24, note-10, note-34`.
#[test]
fn newest_breaks_ties_by_name_not_read_dir_order() {
let d = tempfile::tempdir().unwrap();
let when = std::time::SystemTime::now();
let mut v = Vec::new();
// Deliberately created out of order, all with the SAME mtime.
for n in ["note-24", "note-10", "note-34"] {
let p = d.path().join(format!("{n}.md"));
std::fs::write(&p, "x").unwrap();
let f = std::fs::File::options().write(true).open(&p).unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(when))
.unwrap();
v.push(p);
}
sort_paths(&mut v, ListSort::Newest, |p| {
p.file_name().unwrap().to_string_lossy().into_owned()
});
let names: Vec<String> = v
.iter()
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec!["note-10", "note-24", "note-34"],
"same-second files did not fall back to name order"
);
}
/// USER 2026-09-03 — "shoudl todos have an oldest first too? and
/// what abotu z-a, i think the pared ones shoudl show".
///
/// Each key must be a real mirror of its pair, not merely a fourth
/// mode that happens to exist.
#[test]
fn each_key_is_the_exact_reverse_of_its_pair() {
let d = tempfile::tempdir().unwrap();
let mut v = Vec::new();
// Distinct mtimes AND distinct names, so a mode that silently
// fell back to the other key would still be caught.
for (i, n) in ["alpha", "bravo", "charlie"].iter().enumerate() {
let p = d.path().join(format!("{n}.md"));
std::fs::write(&p, "x").unwrap();
let when = std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(1_700_000_000 + i as u64 * 60);
std::fs::File::options()
.write(true)
.open(&p)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(when))
.unwrap();
v.push(p);
}
let name = |p: &PathBuf| p.file_stem().unwrap().to_string_lossy().into_owned();
let run = |mode: ListSort| {
let mut c = v.clone();
sort_paths(&mut c, mode, |p| name(p));
c.iter().map(name).collect::<Vec<_>>()
};
assert_eq!(run(ListSort::Name), ["alpha", "bravo", "charlie"]);
assert_eq!(run(ListSort::NameDesc), ["charlie", "bravo", "alpha"]);
// charlie is newest (largest mtime offset).
assert_eq!(run(ListSort::Newest), ["charlie", "bravo", "alpha"]);
assert_eq!(run(ListSort::Oldest), ["alpha", "bravo", "charlie"]);
let mut rev = run(ListSort::Newest);
rev.reverse();
assert_eq!(rev, run(ListSort::Oldest), "Oldest is not Newest reversed");
}
/// Oldest mirrors Newest's KEY, not its tiebreak: same-second files
/// stay A→Z in both directions. Flipping the whole comparator would
/// reverse the tiebreak too, which is not what "oldest first" means.
#[test]
fn oldest_keeps_the_name_tiebreak_ascending() {
let d = tempfile::tempdir().unwrap();
let when = std::time::SystemTime::now();
let mut v = Vec::new();
for n in ["note-24", "note-10", "note-34"] {
let p = d.path().join(format!("{n}.md"));
std::fs::write(&p, "x").unwrap();
std::fs::File::options()
.write(true)
.open(&p)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(when))
.unwrap();
v.push(p);
}
sort_paths(&mut v, ListSort::Oldest, |p| {
p.file_name().unwrap().to_string_lossy().into_owned()
});
let names: Vec<String> = v
.iter()
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["note-10", "note-24", "note-34"]);
}
/// `newest` and `name` are already in users' config.toml. A new
/// variant must not change what an existing token parses to.
#[test]
fn the_pre_existing_tokens_still_parse_to_the_same_modes() {
assert_eq!(ListSort::from_token("newest"), ListSort::Newest);
assert_eq!(ListSort::from_token("name"), ListSort::Name);
}
/// The menu must list each key next to its reverse, so one click of
/// the chip flips direction instead of jumping to another key.
#[test]
fn the_menu_order_keeps_each_pair_adjacent() {
assert_eq!(
ListSort::all(),
[
ListSort::Newest,
ListSort::Oldest,
ListSort::Name,
ListSort::NameDesc
]
);
}
#[test]
fn config_tokens_round_trip() {
for m in ListSort::all() {
assert_eq!(
ListSort::from_token(m.as_str()),
m,
"{m:?} did not round-trip"
);
}
}
/// An unknown token must not stop the panel drawing.
#[test]
fn an_unknown_token_falls_back_to_the_default() {
assert_eq!(ListSort::from_token("nonsense"), ListSort::default());
assert_eq!(ListSort::default(), ListSort::Newest, "default changed");
}
/// Every mode must be reachable from the menu, or a mode exists
/// that no user can select.
#[test]
fn every_mode_is_listed_and_labelled() {
let all = ListSort::all();
assert_eq!(all.len(), 4);
for m in all {
assert!(!m.label().is_empty(), "{m:?} has no menu label");
}
}
}