trail-fm 1.12.2

A terminal-first workspace for navigating, inspecting and acting on the filesystem
Documentation
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
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
//! Core application state: `AppState`, `Entry`, `EntryKind`.
//!
//! The central data model that every other module reads or mutates.
//! `state.dirty` is the invariant that drives the render loop: every
//! mutation sets it, every render clears it.

use std::fs;
use std::path::{Path, PathBuf};

use nucleo::pattern::{CaseMatching, Normalization, Pattern};
use nucleo::{Config, Matcher, Utf32Str};
use thiserror::Error;

use crate::app::history::NavigationHistory;
use crate::app::mode::Mode;
use crate::app::tabs::TabManager;
use crate::config::TrailConfig;
use crate::input::command_parser::{CommandHistory, TabState};
use crate::plugin::bookmarks::{BookmarkError, BookmarkStore};
use crate::preview::provider::PreviewContent;

// ── Error type ────────────────────────────────────────────────────────────────

/// Errors that can arise when loading or mutating `AppState`.
#[derive(Debug, Error)]
pub enum StateError {
    /// A directory listing failed.
    #[error("failed to read directory {path}: {source}")]
    ReadDir {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    /// A filesystem mutation (rename/move/delete/etc.) failed.
    #[error("filesystem error: {0}")]
    FsOp(#[from] crate::actions::fs_ops::FsError),
    /// Built-in configuration failed to load.
    #[error("config error: {0}")]
    Config(#[from] crate::config::ConfigError),
}

// ── Entry ─────────────────────────────────────────────────────────────────────

/// The filesystem kind of a directory entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryKind {
    /// A directory (or a junction on Windows).
    Dir,
    /// A regular file.
    File,
    /// A symbolic link (the target kind is not resolved for display purposes).
    Symlink,
}

/// The git status of a single file within a repository.
///
/// Populated asynchronously by the git worker (Phase 4); `None` until then.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitFileStatus {
    /// File is untracked.
    #[allow(dead_code)]
    Untracked,
    /// File has been modified.
    #[allow(dead_code)]
    Modified,
    /// File has been added to the index.
    #[allow(dead_code)]
    Added,
    /// File has been deleted.
    #[allow(dead_code)]
    Deleted,
    /// File has been renamed.
    #[allow(dead_code)]
    Renamed,
    /// File is unmodified / clean.
    Clean,
}

/// A single entry shown in the navigation panel.
#[derive(Debug, Clone)]
pub struct Entry {
    /// Full absolute path to this entry.
    pub path: PathBuf,
    /// The file-name component (no parent path), pre-extracted for display.
    pub file_name: String,
    /// Whether this entry is a directory, file, or symlink.
    pub kind: EntryKind,
    /// Whether the entry name starts with `.` (Unix hidden-file convention).
    pub is_hidden: bool,
    /// Filesystem metadata, if available.
    ///
    /// Collected by the directory iterator as it walks — one `lstat` that has
    /// already been paid for — which is why sorting by size or time and showing
    /// either in the listing cost no I/O at all. `None` when the entry could
    /// not be stat'd; see [`crate::app::sort::sort_entries`] for where such an
    /// entry lands.
    pub metadata: Option<fs::Metadata>,
    /// Git status, populated asynchronously by the git worker (Phase 4).
    /// `None` before the worker reports back, or outside a git repo.
    pub git_status: Option<GitFileStatus>,
    /// Whether this entry is a text file, once anything has had reason to find
    /// out. `None` means "not yet classified", which is the state every entry
    /// starts in.
    ///
    /// This used to be decided for every file during the directory listing,
    /// which meant opening and reading 8 KB of each one on the UI thread — a
    /// directory of 500 files cost 500 reads per navigation. The answer is now
    /// produced by the highlight worker, which has to read the file anyway, and
    /// cached here by `workers::merge` so the next preview of the same entry is
    /// immediate.
    pub is_text: Option<bool>,
}

impl Entry {
    /// Constructs an `Entry` from a `DirEntry`.
    ///
    /// Returns `None` if the file name cannot be represented as UTF-8.
    ///
    /// Performs no I/O beyond the `stat` the directory iterator already carries:
    /// whether a file is text is left to the worker that reads it, because
    /// answering it here cost one open and one 8 KB read per entry, on the UI
    /// thread, every time a directory was listed.
    fn from_dir_entry(de: &fs::DirEntry) -> Option<Self> {
        let path = de.path();
        let file_name = path.file_name()?.to_str()?.to_owned();
        let is_hidden = file_name.starts_with('.');
        let metadata = de.metadata().ok();

        let kind = if let Some(ref m) = metadata {
            if m.is_symlink() {
                EntryKind::Symlink
            } else if m.is_dir() {
                EntryKind::Dir
            } else {
                EntryKind::File
            }
        } else {
            // If we can't stat, treat as file.
            EntryKind::File
        };

        Some(Entry {
            path,
            file_name,
            kind,
            is_hidden,
            metadata,
            git_status: None,
            // Unknown until something reads the file. See the field's docs.
            is_text: None,
        })
    }
}

// ── Git directory state ───────────────────────────────────────────────────────

/// Git repository information for the current directory.
///
/// Populated asynchronously by the git worker (Phase 4). `AppState::git`
/// holds `None` until the worker reports back.
#[derive(Debug, Clone)]
pub struct GitDirState {
    /// The active branch name, or `"HEAD"` if in detached-HEAD state.
    pub branch: String,
    /// Whether the working tree has any uncommitted changes.
    pub is_dirty: bool,
}

// ── Preview slot ──────────────────────────────────────────────────────────────

/// The preview pane's current content and the generation it belongs to.
///
/// `generation` is incremented on every selection change so that
/// late-arriving worker results for a since-abandoned selection can be
/// discarded in `workers::merge` (Phase 4). The field is defined here in
/// Phase 1 so the state shape is stable — the guard is exercised in Phase 4/5.
#[derive(Debug, Clone, Default)]
pub struct PreviewSlot {
    /// The path whose preview is currently being displayed (or loading).
    pub for_path: PathBuf,
    /// Monotonically increasing counter. Every selection change bumps this.
    pub generation: u64,
    /// The actual content to render, or a loading placeholder.
    pub content: PreviewContent,
    /// Index of the first content line drawn in the pane.
    ///
    /// Counted in *logical* lines (one entry of the content's line vector), not
    /// wrapped terminal rows, because the render path slices the line vector
    /// rather than offsetting a `Paragraph`. The limits it is clamped to do
    /// count wrapped rows — see [`Self::viewport_width`].
    pub scroll: usize,
    /// Height in rows of the pane's interior, recorded by the render pass.
    ///
    /// Only the renderer knows the pane size, so it writes this on every frame
    /// and the scroll actions clamp against it. It is zero until the first
    /// render, which makes every scroll request a no-op — harmless, because no
    /// key can be pressed before the first frame is drawn.
    pub viewport_height: usize,
    /// Width in columns of the pane's interior, recorded by the render pass.
    ///
    /// A line wider than this wraps onto further rows, so the scroll limit and
    /// the page step measure rows with it rather than assuming one row per
    /// line. Zero until the first render, which counts every line as one row.
    pub viewport_width: u16,
    /// Whether the loaded content stops short of the end of the file.
    ///
    /// Set from `WorkerMsg::Preview` when the highlight worker hit
    /// `[preview] max_lines`. The renderer marks such a preview so the pane
    /// never silently claims to show a whole file.
    pub truncated: bool,
    /// Whether the last frame drew an inline image.
    ///
    /// Written by the renderer, for the renderer. An inline image is placed by
    /// the terminal itself rather than by ratatui, so no cell diff can erase it;
    /// the frame that stops showing one has to clear the screen, and this is how
    /// it knows. Everything else relies on the ordinary diff.
    pub drew_image: bool,
}

/// Number of lines a page scroll leaves on screen for continuity.
///
/// Matches the overlap Vim's `Ctrl-F`/`Ctrl-B` keep, so the eye has an anchor
/// in the new screenful.
const PAGE_OVERLAP_LINES: usize = 2;

impl PreviewSlot {
    /// Prepares the slot for a preview of `path` and returns the new generation.
    ///
    /// Bumps `generation` so that in-flight worker results for the previous
    /// request are dropped by [`crate::workers::merge`], and resets the scroll
    /// position **only when `path` differs from the current `for_path`**. A
    /// re-preview of the same path (a filesystem-watch tick, `R`, or toggling
    /// hidden files) therefore keeps the reader where they were.
    pub fn begin(&mut self, path: &Path) -> u64 {
        self.generation = self.generation.wrapping_add(1);
        if self.for_path != path {
            self.for_path = path.to_path_buf();
            self.scroll = 0;
            self.truncated = false;
        }
        self.generation
    }

    /// Returns the largest useful scroll offset: the offset at which the last
    /// content line ends on the pane's last row.
    ///
    /// Wrapped lines count for every row they take, so a file with long lines
    /// can still be scrolled until its end is on screen.
    ///
    /// Returns `0` for content that does not scroll (an image, a placeholder)
    /// and for content that fits the pane.
    pub fn max_scroll(&self) -> usize {
        let len = self.content.scrollable_len().unwrap_or(0);
        len - self.lines_fitting_before(len)
    }

    /// Number of logical lines drawn whole when the pane starts at line `start`.
    ///
    /// At least `1` while `start` is inside the content and the pane has a row:
    /// a single line taller than the pane still counts as the one being shown,
    /// so a page step always advances. `0` past the end or for content that
    /// does not scroll.
    pub fn lines_visible_from(&self, start: usize) -> usize {
        let len = self.content.scrollable_len().unwrap_or(0);
        let mut rows = 0;
        let mut count = 0;
        for index in start..len {
            rows += self.content.display_rows(index, self.viewport_width);
            if rows > self.viewport_height {
                break;
            }
            count += 1;
        }
        self.at_least_one(count, start < len)
    }

    /// Number of logical lines ending just above line `end` that the pane can
    /// show whole — the lines a screen ending there would hold.
    ///
    /// The mirror of [`Self::lines_visible_from`], with the same floor of `1`.
    fn lines_fitting_before(&self, end: usize) -> usize {
        let mut rows = 0;
        let mut count = 0;
        for index in (0..end).rev() {
            rows += self.content.display_rows(index, self.viewport_width);
            if rows > self.viewport_height {
                break;
            }
            count += 1;
        }
        self.at_least_one(count, end > 0)
    }

    /// Raises a line count of zero to one when there is a line to show and a
    /// row to show it in, so an over-tall line never stalls the scroll.
    fn at_least_one(&self, count: usize, has_line: bool) -> usize {
        if has_line && self.viewport_height > 0 {
            count.max(1)
        } else {
            count
        }
    }

    /// Clamps `scroll` into range, leaving it alone for content that does not
    /// scroll.
    ///
    /// Called by the render pass once it has recorded `viewport_height`: it is
    /// the only place that sees a terminal resize, and it must not zero a
    /// position that a `Loading` frame is about to replace with real content.
    pub fn clamp_scroll(&mut self) {
        if self.content.scrollable_len().is_none() {
            return;
        }
        self.scroll = self.scroll.min(self.max_scroll());
    }

    /// The scroll offset one page down from `from`: the lines on screen less
    /// [`PAGE_OVERLAP_LINES`], and never less than one line on.
    fn page_down_from(&self, from: usize) -> usize {
        let step = self
            .lines_visible_from(from)
            .saturating_sub(PAGE_OVERLAP_LINES)
            .max(1);
        from.saturating_add(step)
    }

    /// The scroll offset one page up from `from`: the screen that ends
    /// [`PAGE_OVERLAP_LINES`] into the current one, and never less than one
    /// line back.
    fn page_up_from(&self, from: usize) -> usize {
        let len = self.content.scrollable_len().unwrap_or(0);
        let end = from.saturating_add(PAGE_OVERLAP_LINES).min(len);
        let target = end.saturating_sub(self.lines_fitting_before(end));
        target.min(from.saturating_sub(1))
    }
}

/// Offsets `base` by `delta`, saturating at zero instead of wrapping.
///
/// `usize` cannot hold a negative intermediate, so an upward scroll past the
/// first line must clamp rather than underflow.
fn offset_by(base: usize, delta: isize) -> usize {
    if delta >= 0 {
        base.saturating_add(delta as usize)
    } else {
        base.saturating_sub(delta.unsigned_abs())
    }
}

// ── Filter state ──────────────────────────────────────────────────────────────

/// Active fuzzy-filter state while in Search Mode.
///
/// `matches` holds indices into `AppState::entries` ordered by descending
/// fuzzy-match score. `scores` is a parallel `Vec` keeping the score for
/// each corresponding match (used for sorting; not rendered directly).
#[derive(Debug, Clone, Default)]
pub struct FilterState {
    /// The current query string.
    pub query: String,
    /// Indices into `AppState::entries`, ordered by descending match score.
    pub matches: Vec<usize>,
    /// Match scores parallel to `matches`. `scores[i]` is the score for
    /// `matches[i]`.
    pub scores: Vec<u32>,
}

// ── Status bar state ──────────────────────────────────────────────────────────

/// Derived values cached for the status bar so the render path is pure.
///
/// Updated whenever the underlying state changes rather than recomputed
/// on every frame.
#[derive(Debug, Clone, Default)]
pub struct StatusBarState {
    /// Human-readable string for the current working directory.
    pub cwd_display: String,
    /// Total number of entries (after hidden-file filtering).
    pub entry_count: usize,
}

// ── Notices ───────────────────────────────────────────────────────────────────

/// Whether a [`Notice`] reports a failure or just says what happened.
///
/// The distinction is the whole point of the type: before it existed, a
/// successful `:bookmark` reported itself through the error field and the status
/// bar dutifully rendered `Error: bookmark added: name` in red.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoticeLevel {
    /// Something worked, and saying so is useful — a yank, a bookmark.
    Info,
    /// Something failed. Rendered in the theme's error colour.
    Error,
}

/// One line of feedback for the status bar.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
    /// The text to show. Kept short by the caller; the status bar elides what
    /// does not fit.
    pub text: String,
    /// Whether this is a failure or an ordinary report.
    pub level: NoticeLevel,
}

// ── AppState ──────────────────────────────────────────────────────────────────

/// Central application state owned by the UI thread.
///
/// Every mutation must set `dirty = true` so the render loop knows to
/// redraw. Every render clears `dirty`.
#[derive(Debug)]
pub struct AppState {
    /// Runtime configuration loaded from defaults plus any user TOML file.
    pub config: TrailConfig,
    /// Current working directory being displayed.
    ///
    /// Always absolute, and always in the form [`crate::pathfmt::simplified`]
    /// produces — on Windows that means no `\\?\` prefix unless the path
    /// genuinely needs one. Entry paths, the panel title, yanks and the
    /// `--cwd-file` handoff are all derived from here, so normalizing this
    /// field is what keeps the prefix out of all of them.
    pub cwd: PathBuf,
    /// The directory Trail was launched from — the shell's working directory
    /// at startup, which is unaffected by navigation.
    ///
    /// This is the base for `yr` (copy relative path): the point of a relative
    /// path is that it can be pasted back into the shell that started Trail,
    /// and that shell has not moved. Relative to `cwd` instead, every yank
    /// would be the selection's own file name, which is what `yn` yanks.
    ///
    /// Canonicalized through [`crate::pathfmt::canonicalize`], the same as
    /// `cwd` and therefore as entry paths, so the two can be related
    /// component-by-component. Both spellings have to match: relating a
    /// `\\?\C:\…` base to a `C:\…` target would fail on the prefix component
    /// and silently fall back to yanking the absolute path.
    pub launch_dir: PathBuf,
    /// Directory-first sorted listing of the current directory.
    /// Hidden entries are included but may be filtered from display.
    pub entries: Vec<Entry>,
    /// Index into `entries` of the currently highlighted item.
    pub selected: usize,
    /// Where the navigation panel was scrolled to on the last frame.
    ///
    /// Written by the renderer, which is the only thing that knows the pane
    /// height, through [`crate::app::scroll::NavScroll::update`]. `None` until
    /// the first frame.
    pub nav_scroll: Option<crate::app::scroll::NavScroll>,
    /// Current interaction mode.
    pub mode: Mode,
    /// Navigation history for `u`/`Ctrl-r` back/forward.
    pub history: NavigationHistory,
    /// Active fuzzy-filter while in Search Mode; `None` otherwise.
    pub filter: Option<FilterState>,
    /// Current preview pane content + generation counter.
    pub preview: PreviewSlot,
    /// Git repository state for the current directory.
    /// `None` before the git worker reports back or outside a git repo.
    pub git: Option<GitDirState>,
    /// Cached status bar strings.
    pub status: StatusBarState,
    /// Whether hidden files are shown. Toggled by a keybinding (Phase 1).
    pub show_hidden: bool,
    /// How the active tab orders its listing.
    ///
    /// A mirror of the active [`crate::app::tabs::TabState`]'s `sort`, kept
    /// flat here for the same reason `cwd` and `selected` are: the listing code
    /// reads it on every `load_dir` and should not have to go through the tab
    /// manager to do so. `save_to_active_tab` and `restore_from_active_tab`
    /// keep the two in step.
    pub sort: crate::app::sort::SortSettings,
    /// Set `true` on any state mutation; cleared after each render.
    pub dirty: bool,
    /// The entry to re-select when returning to a directory visited before.
    ///
    /// Key: the directory's absolute path; value: the absolute path of the entry
    /// that was selected in it. A **path**, not an index: an index names
    /// whichever file happens to occupy that row now, so a directory whose
    /// contents changed while the user was away would come back with the
    /// selection on something else.
    pub selection_memory: std::collections::HashMap<PathBuf, PathBuf>,

    // ── Phase 3 fields ────────────────────────────────────────────────────────
    /// `true` while awaiting the user's confirmation of a `dd` delete.
    ///
    /// The status bar renders a confirmation prompt while this is set.
    /// Confirmed via `Enter`; cancelled via `Esc`.
    pub pending_delete: bool,
    /// The one message the status bar is currently showing, if any.
    ///
    /// Set through [`AppState::notify`] and [`AppState::set_error`] rather than
    /// directly, so that every message reaches the log as well as the screen.
    /// Cleared by [`AppState::clear_notice`] on the next keystroke, which is
    /// what makes it a flash rather than something that lingers over
    /// directories it has nothing to do with.
    pub notice: Option<Notice>,
    /// The most-recently yanked string (set by clipboard operations).
    ///
    /// The full text, not the summary the status bar flashes — observable in
    /// tests and the record of what the OS clipboard was handed.
    pub last_yank: Option<String>,
    /// Persisted command history for Command Mode.
    pub command_history: CommandHistory,
    /// Tab-completion cycling state for Command Mode. Stored here so it
    /// survives across individual key dispatches within the same mode session.
    pub tab_state: TabState,

    // ── Phase 3 multi-key sequence state ───────────────────────────────────────
    /// Tracks the first key of multi-key Navigation Mode sequences:
    /// `y` (for `ya`/`yr`/`yn`/`yc`) and `d` (for `dd`).
    pub pending_nav_key: Option<char>,

    // ── Phase 6 shell integration ──────────────────────────────────────────────
    /// A pending `RunExternal` action to be executed by the event loop.
    ///
    /// Set by `apply()` when an editor-open or `!<shell command>` action is
    /// requested. The event loop in `main.rs` drains this field after each
    /// key dispatch, calling `shell_exec::run_external` and forcing a full
    /// redraw. Using a state field rather than a channel keeps `apply()`
    /// synchronous and testable without a running terminal.
    pub pending_external: Option<crate::actions::Action>,

    // ── Phase 8 tab management ─────────────────────────────────────────────────
    /// Multi-tab state manager.
    ///
    /// The active tab's `cwd`/`entries`/`selected`/`history` are kept in sync
    /// with the flat `AppState` fields so all existing code continues to work
    /// unchanged. On a tab switch the active state is written to the old tab
    /// slot and read from the new one.
    pub tab_manager: TabManager,

    // -- Phase 8 bookmark store --------------------------------------------------
    /// Persisted named-path bookmark registry.
    ///
    /// None before startup initialization. Bookmark operations silently
    /// degrade when None (e.g. data directory unavailable).
    pub bookmark_store: Option<BookmarkStore>,
    /// Phase 8 plugin engine for firing hooks on navigation.
    pub plugin_engine: Option<crate::plugin::PluginEngine>,
    /// Recent directories tracker.
    pub recent_dirs: crate::session::RecentDirs,
    /// The terminal panel and its shells.
    pub terminal: crate::terminal::TerminalPanel,
    /// Copies and moves running in the background.
    pub transfers: crate::actions::transfer::Transfers,
    /// Entries marked for a paste, move, copy or delete. One set for every
    /// tab, so marks made in one can be pasted from another.
    pub marks: crate::app::marks::MarkSet,
    /// The `:set` changes made this session, for `:config write`.
    pub settings: crate::config::persist::SessionSettings,
    /// The last file operation, for `@` to do again.
    pub last_repeatable: Option<crate::actions::Repeatable>,
}

impl AppState {
    /// Creates a new `AppState` rooted at `start_path`.
    ///
    /// Loads the initial directory listing synchronously. Returns an error
    /// if `start_path` cannot be read.
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the initial directory listing fails.
    // clippy: dead_code — used heavily by integration tests in `tests/`
    #[allow(dead_code)]
    pub fn new(start_path: PathBuf) -> Result<Self, StateError> {
        let config = crate::config::load(None)?;
        Self::with_config(start_path, config)
    }

    /// Creates a new `AppState` rooted at `start_path` using `config`.
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the initial directory listing fails.
    pub fn with_config(start_path: PathBuf, config: TrailConfig) -> Result<Self, StateError> {
        let cwd = crate::pathfmt::canonicalize(&start_path).unwrap_or(start_path);

        // The process working directory at startup is the shell's, since
        // nothing has navigated yet — Trail changes `cwd` here, never the
        // process's own directory. If it cannot be read (the shell was started
        // in a directory that has since been deleted), fall back to the start
        // path, which at worst makes `yr` behave as it did before.
        let launch_dir = std::env::current_dir()
            .map(|dir| crate::pathfmt::canonicalize(&dir).unwrap_or(dir))
            .unwrap_or_else(|e| {
                tracing::debug!("could not read the launch directory: {e}");
                cwd.clone()
            });

        // The configured order seeds the first tab; every tab opened later
        // inherits the order of the tab it was opened from.
        let sort = config.navigation.sort_settings();

        let mut state = AppState {
            config,
            cwd: cwd.clone(),
            launch_dir,
            entries: Vec::new(),
            selected: 0,
            nav_scroll: None,
            mode: Mode::default(),
            history: NavigationHistory::new(),
            filter: None,
            preview: PreviewSlot::default(),
            git: None,
            status: StatusBarState::default(),
            show_hidden: false,
            sort,
            dirty: true,
            selection_memory: std::collections::HashMap::new(),
            pending_delete: false,
            notice: None,
            last_yank: None,
            command_history: CommandHistory::new(),
            tab_state: TabState::new(),
            pending_nav_key: None,
            pending_external: None,
            tab_manager: TabManager::new(cwd.clone(), sort),
            bookmark_store: None,
            plugin_engine: None,
            recent_dirs: crate::session::RecentDirs::default(),
            terminal: crate::terminal::TerminalPanel::default(),
            transfers: crate::actions::transfer::Transfers::default(),
            marks: crate::app::marks::MarkSet::default(),
            settings: crate::config::persist::SessionSettings::default(),
            last_repeatable: None,
        };

        state.load_dir(&cwd)?;
        Ok(state)
    }

    /// Recomputes the fuzzy-filter against the current `entries` using
    /// `query`, storing results sorted by descending score in
    /// `self.filter`. Also auto-selects the top match (index 0).
    ///
    /// Call this every time the query changes in Search Mode. The filter
    /// runs synchronously on the UI thread — the architecture doc
    /// designates fuzzy filtering as "fast enough not to need offloading."
    ///
    /// An empty query matches all entries (no score order; original listing
    /// order is preserved).
    pub fn apply_filter(&mut self, query: String) {
        let mut filter = FilterState {
            query: query.clone(),
            matches: Vec::new(),
            scores: Vec::new(),
        };

        if query.is_empty() {
            // Empty query: show all visible entries in their original order.
            filter.matches = (0..self.entries.len())
                .filter(|&i| self.show_hidden || !self.entries[i].is_hidden)
                .collect();
            filter.scores = vec![0u32; filter.matches.len()];
        } else {
            let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
            let mut matcher = Matcher::new(Config::DEFAULT);

            // Score each visible entry and collect those that match (score > 0).
            let mut scored: Vec<(usize, u32)> = self
                .entries
                .iter()
                .enumerate()
                .filter(|(_, e)| self.show_hidden || !e.is_hidden)
                .filter_map(|(idx, entry)| {
                    let mut buf = Vec::new();
                    let haystack = Utf32Str::new(&entry.file_name, &mut buf);
                    pattern.score(haystack, &mut matcher).map(|s| (idx, s))
                })
                .collect();

            // Sort by score descending so the best match is first.
            scored.sort_by_key(|b| std::cmp::Reverse(b.1));

            filter.matches = scored.iter().map(|(idx, _)| *idx).collect();
            filter.scores = scored.iter().map(|(_, score)| *score).collect();
        }

        // Auto-select the top match.
        self.selected = 0;
        self.filter = Some(filter);

        // Keep Mode::Search.query in sync with the FilterState.query.
        if let Mode::Search {
            query: q,
            matches: m,
        } = &mut self.mode
        {
            *q = query;
            *m = self
                .filter
                .as_ref()
                .map(|f| f.matches.clone())
                .unwrap_or_default();
        }

        self.dirty = true;
    }

    /// Drops the Search Mode filter, keeping the selection on the entry it was on.
    ///
    /// While filtering, `selected` counts rows of the match list; without the
    /// filter it counts rows of the full listing. Clearing the filter alone
    /// would leave the cursor on whatever entry sits at that row, losing the
    /// one the search found. Does not change `mode`. A no-op without a filter.
    pub fn clear_filter(&mut self) {
        if self.filter.is_none() {
            return;
        }
        let anchor = self.selected_entry().map(|e| e.path.clone());
        self.filter = None;
        self.restore_selection(anchor.as_deref(), 0);
        self.dirty = true;
    }

    /// Returns the number of filtered-visible entries, or `visible_count()`
    /// when no filter is active.
    pub fn filtered_count(&self) -> usize {
        match &self.filter {
            Some(f) => f.matches.len(),
            None => self.visible_count(),
        }
    }

    /// Returns an iterator over the entries visible given the current filter
    /// and `show_hidden` flag.
    ///
    /// When a filter is active, entries are yielded in match-score order.
    /// When no filter is active, entries are yielded in listing order
    /// (excluding hidden entries unless `show_hidden` is `true`).
    pub fn filtered_entries(&self) -> impl Iterator<Item = (usize, &Entry)> {
        // Return a concrete `Vec` iterator so both branches have the same type.
        let pairs: Vec<(usize, &Entry)> = match &self.filter {
            Some(f) => f
                .matches
                .iter()
                .filter_map(|&idx| self.entries.get(idx).map(|e| (idx, e)))
                .collect(),
            None => self
                .entries
                .iter()
                .enumerate()
                .filter(|(_, e)| self.show_hidden || !e.is_hidden)
                .collect(),
        };
        pairs.into_iter()
    }

    /// Returns the currently selected `Entry` under the active filter,
    /// or `None` if the visible (filtered) list is empty.
    pub fn selected_filtered_entry(&self) -> Option<&Entry> {
        self.filtered_entries().nth(self.selected).map(|(_, e)| e)
    }

    /// Reads and sorts the directory at `path`, replacing `self.entries`.
    ///
    /// Sort order comes from `self.sort`, which is the active tab's — see
    /// [`crate::app::sort::sort_entries`] for the rules. Hidden entries are
    /// always included in `entries`; visibility is controlled by `show_hidden`
    /// at render time.
    ///
    /// Also updates `status` and sets `dirty`.
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the directory cannot be listed.
    pub fn load_dir(&mut self, path: &Path) -> Result<(), StateError> {
        let read = fs::read_dir(path).map_err(|e| StateError::ReadDir {
            path: path.to_owned(),
            source: e,
        })?;

        let mut entries: Vec<Entry> = read
            .filter_map(|res| res.ok())
            .filter_map(|de| Entry::from_dir_entry(&de))
            .collect();

        crate::app::sort::sort_entries(&mut entries, self.sort);

        self.entries = entries;

        // Re-select the entry this directory was left on, by path, so a listing
        // that changed in the meantime does not hand the selection to a
        // different file. Falls back to the top when that entry is gone.
        let remembered = self.selection_memory.get(path).cloned();
        self.restore_selection(remembered.as_deref(), 0);

        self.update_status();
        self.dirty = true;
        Ok(())
    }

    /// Returns the row `path` occupies in the list the user is looking at, or
    /// `None` when it is not in it.
    ///
    /// Counted the same way `selected` is: against the filtered list in Search
    /// Mode and against the visible listing otherwise, so the result can be
    /// assigned to `selected` directly.
    fn index_of_path(&self, path: &Path) -> Option<usize> {
        if self.filter.is_some() {
            self.filtered_entries().position(|(_, e)| e.path == path)
        } else {
            self.visible_entries().position(|e| e.path == path)
        }
    }

    /// Puts the selection back on `anchor`, or on `fallback` when that entry is
    /// no longer in the list.
    ///
    /// The selection is an index, so anything that changes the length or the
    /// order of the list — revealing hidden files, a re-read after a filesystem
    /// change, returning to a directory — moves it onto a different entry unless
    /// the entry it was on is looked up again by path. Both arguments are
    /// clamped, and an empty list selects row zero.
    fn restore_selection(&mut self, anchor: Option<&Path>, fallback: usize) {
        let count = self.filtered_count();
        if count == 0 {
            self.selected = 0;
            return;
        }
        let found = anchor.and_then(|path| self.index_of_path(path));
        self.selected = found.unwrap_or_else(|| fallback.min(count - 1));
    }

    /// Records what a worker found out about whether `path` is text.
    ///
    /// The classification is a fact about the file rather than about the current
    /// selection, so it is worth keeping even when the preview it arrived with is
    /// stale: it is what lets the *next* preview of the same entry go straight to
    /// the right provider instead of deferring to a worker again. Silently
    /// ignores a path that is not in the current listing.
    pub fn classify_entry(&mut self, path: &Path, is_text: bool) {
        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
            entry.is_text = Some(is_text);
        }
    }

    /// Records which entry is selected in `cwd`, for [`AppState::load_dir`] to
    /// restore the next time this directory is listed.
    ///
    /// A directory with nothing in it records nothing, leaving any earlier
    /// memory of it alone.
    fn remember_selection(&mut self) {
        if let Some(path) = self.selected_entry().map(|e| e.path.clone()) {
            self.selection_memory.insert(self.cwd.clone(), path);
        }
    }

    /// Returns the number of entries that are visible given `show_hidden`.
    pub fn visible_count(&self) -> usize {
        if self.show_hidden {
            self.entries.len()
        } else {
            self.entries.iter().filter(|e| !e.is_hidden).count()
        }
    }

    /// Returns a slice of entries visible given the current `show_hidden` flag.
    ///
    /// Returns an iterator rather than allocating a new `Vec`.
    pub fn visible_entries(&self) -> impl Iterator<Item = &Entry> {
        self.entries
            .iter()
            .filter(move |e| self.show_hidden || !e.is_hidden)
    }

    /// Returns the currently selected `Entry`, or `None` if the list is empty.
    ///
    /// In Search Mode, delegates to `selected_filtered_entry` so the
    /// selection is resolved against the match list rather than the raw
    /// listing. Outside Search Mode the raw visible listing is used.
    pub fn selected_entry(&self) -> Option<&Entry> {
        if self.filter.is_some() {
            self.selected_filtered_entry()
        } else {
            self.visible_entries().nth(self.selected)
        }
    }

    /// Navigates into the directory at `path`, pushing the current `cwd` onto
    /// the history stack and loading the new listing.
    ///
    /// Saves the current selection in `selection_memory` before navigating.
    ///
    /// # Errors
    ///
    /// Propagates any `StateError` from `load_dir`.
    pub fn enter_dir(&mut self, path: PathBuf) -> Result<(), StateError> {
        // Not every caller hands over a path descended from `cwd`: `:jump`
        // reads one out of the bookmark store, which may have been written by
        // a build that saved the Windows verbatim form. Normalize here, the
        // one place an outside path becomes `cwd`, so the prefix cannot come
        // back in and spread to the entries, the yanks and the history.
        let path = crate::pathfmt::simplified(&path);

        // Remember where we were in the old directory.
        self.remember_selection();
        self.history.push(self.cwd.clone());
        self.cwd = path.clone();

        let res = self.load_dir(&self.cwd.clone());

        self.recent_dirs.visit(path.clone());
        if let Some(engine) = &self.plugin_engine {
            engine.fire_on_enter_dir(&path);
        }

        res
    }

    /// Navigates to the parent directory, if one exists.
    ///
    /// Saves the current selection before navigating.
    ///
    /// # Errors
    ///
    /// Propagates any `StateError` from `load_dir`.
    pub fn go_parent(&mut self) -> Result<(), StateError> {
        if let Some(parent) = self.cwd.parent().map(|p| p.to_owned()) {
            self.remember_selection();
            self.history.push(self.cwd.clone());
            self.cwd = parent;
            self.load_dir(&self.cwd.clone())?;
        }
        Ok(())
    }

    /// Navigates backward in history (bound to `u`).
    ///
    /// # Errors
    ///
    /// Propagates any `StateError` from `load_dir`.
    pub fn history_back(&mut self) -> Result<(), StateError> {
        if let Some(prev) = self.history.back(self.cwd.clone()) {
            self.remember_selection();
            self.cwd = prev;
            self.load_dir(&self.cwd.clone())?;
        }
        Ok(())
    }

    /// Navigates forward in history (bound to `Ctrl-r`).
    ///
    /// # Errors
    ///
    /// Propagates any `StateError` from `load_dir`.
    pub fn history_forward(&mut self) -> Result<(), StateError> {
        if let Some(next) = self.history.forward(self.cwd.clone()) {
            self.remember_selection();
            self.cwd = next;
            self.load_dir(&self.cwd.clone())?;
        }
        Ok(())
    }

    /// Moves the selection down by one within the visible (or filtered) list,
    /// clamping at the last entry.
    ///
    /// Sets `dirty = true` if the selection changed.
    pub fn move_down(&mut self) {
        let count = self.filtered_count();
        if count == 0 {
            return;
        }
        let new = (self.selected + 1).min(count - 1);
        if new != self.selected {
            self.selected = new;
            self.dirty = true;
        }
    }

    /// Moves the selection up by one, clamping at zero.
    ///
    /// Sets `dirty = true` if the selection changed.
    pub fn move_up(&mut self) {
        if self.selected > 0 {
            self.selected -= 1;
            self.dirty = true;
        }
    }

    /// Jumps the selection to the first visible entry.
    ///
    /// Sets `dirty = true` if the selection changed.
    pub fn jump_top(&mut self) {
        if self.selected != 0 {
            self.selected = 0;
            self.dirty = true;
        }
    }

    /// Jumps the selection to the last visible (or filtered) entry.
    ///
    /// Sets `dirty = true` if the selection changed.
    pub fn jump_bottom(&mut self) {
        let count = self.filtered_count();
        if count == 0 {
            return;
        }
        let last = count - 1;
        if self.selected != last {
            self.selected = last;
            self.dirty = true;
        }
    }

    /// Scrolls the preview pane by `delta` logical lines, positive for down.
    ///
    /// Clamped to the loaded content: the first line never scrolls off the top
    /// and the last line never scrolls off the bottom. A no-op — including for
    /// `dirty` — when the content does not scroll (an image or a placeholder)
    /// or is already at the requested edge.
    pub fn scroll_preview_lines(&mut self, delta: isize) {
        self.set_preview_scroll(offset_by(self.preview.scroll, delta));
    }

    /// Scrolls the preview pane by `pages` screenfuls, positive for down.
    ///
    /// A page is the lines on screen less a two-line overlap, so consecutive
    /// pages share context. Lines are counted whole: a wrapped line takes all
    /// its rows, so a page of long lines moves fewer of them.
    pub fn scroll_preview_pages(&mut self, pages: isize) {
        let mut target = self.preview.scroll;
        for _ in 0..pages.unsigned_abs() {
            target = if pages > 0 {
                self.preview.page_down_from(target)
            } else {
                self.preview.page_up_from(target)
            };
        }
        self.set_preview_scroll(target);
    }

    /// Jumps the preview pane to the end of the loaded content (`to_end`) or
    /// back to its first line.
    ///
    /// "End of the loaded content" is not necessarily the end of the file — see
    /// [`PreviewSlot::truncated`].
    pub fn scroll_preview_to_edge(&mut self, to_end: bool) {
        let target = if to_end { self.preview.max_scroll() } else { 0 };
        self.set_preview_scroll(target);
    }

    /// Applies a clamped preview scroll offset, marking the state dirty only if
    /// the offset actually moved.
    ///
    /// Every public scroll entry point funnels through here so the clamp and
    /// the `dirty` discipline live in exactly one place.
    fn set_preview_scroll(&mut self, target: usize) {
        if self.preview.content.scrollable_len().is_none() {
            return;
        }
        let new = target.min(self.preview.max_scroll());
        if new != self.preview.scroll {
            self.preview.scroll = new;
            self.dirty = true;
        }
    }

    /// Toggles display of hidden files, keeping the selection on the same entry.
    ///
    /// When a filter is active, re-applies it so hidden-file visibility is
    /// reflected correctly in the match list.
    ///
    /// # Errors
    ///
    /// Propagates any `StateError` from `load_dir`.
    pub fn toggle_hidden(&mut self) -> Result<(), StateError> {
        // Hidden entries sort in among the visible ones, so revealing them
        // shifts every row below the first one — holding the index still would
        // hand the selection to a different file, which is what it used to do.
        let anchor = self.selected_entry().map(|e| e.path.clone());

        self.show_hidden = !self.show_hidden;
        // Re-apply the filter if one is active so the match set is correct.
        if let Some(f) = self.filter.take() {
            let q = f.query.clone();
            self.apply_filter(q);
        }
        self.restore_selection(anchor.as_deref(), self.selected);
        self.dirty = true;
        Ok(())
    }

    /// Re-orders the listing under `settings`, keeping the selection on the
    /// same entry, and records the new order on the active tab.
    ///
    /// Re-sorting is done in place rather than by re-listing the directory:
    /// every sort key comes from data the entries already carry, so there is
    /// nothing to re-read and no reason to pay for a `read_dir`.
    ///
    /// Two things have to move with the order, and both are silent corruption
    /// if they do not:
    ///
    /// - **The filter's match list** holds *indices* into `entries`
    ///   ([`FilterState::matches`]), so after a re-sort every index names a
    ///   different file. The filter is re-applied rather than re-indexed.
    /// - **The selection** is an index too, so it is resolved to a path first
    ///   and looked up again afterwards — the same rule as `toggle_hidden`.
    pub fn set_sort(&mut self, settings: crate::app::sort::SortSettings) {
        let anchor = self.selected_entry().map(|e| e.path.clone());

        self.sort = settings;
        self.tab_manager.active_tab_mut().sort = settings;
        crate::app::sort::sort_entries(&mut self.entries, settings);

        if let Some(f) = self.filter.take() {
            let q = f.query.clone();
            self.apply_filter(q);
        }
        self.restore_selection(anchor.as_deref(), self.selected);
        self.dirty = true;
    }

    /// Reloads the current directory listing in place (e.g. after an external
    /// change or a self-initiated filesystem mutation).
    ///
    /// Unlike `enter_dir`, this keeps the selection on the entry the user was on
    /// so that `R` — or a filesystem-watch refresh — does not move the cursor.
    /// Falls back to the same row when that entry is gone, which is what a
    /// delete of the selected file wants.
    ///
    /// # Errors
    ///
    /// Propagates any `StateError` from `load_dir`.
    pub fn refresh(&mut self) -> Result<(), StateError> {
        // By path, not by index: a file created or deleted above the selection
        // shifts every row below it, and a refresh is usually something the
        // filesystem asked for rather than something the user did.
        let anchor = self.selected_entry().map(|e| e.path.clone());
        let saved = self.selected;
        self.load_dir(&self.cwd.clone())?;
        self.restore_selection(anchor.as_deref(), saved);
        self.dirty = true;
        Ok(())
    }

    // -- Notices --------------------------------------------------------------

    /// Reports that something happened, in the status bar and in the log.
    ///
    /// For outcomes worth seeing but not worth alarm: a yank, a bookmark, a
    /// count of files moved. Logged at `info`, which the default subscriber
    /// filter keeps, so the log file says what the user was told.
    ///
    /// Replaces any notice already showing — the status bar holds one line, and
    /// the newest outcome is the relevant one.
    pub fn notify(&mut self, text: impl Into<String>) {
        let text = text.into();
        tracing::info!(%text, "notice");
        self.notice = Some(Notice {
            text,
            level: NoticeLevel::Info,
        });
        self.dirty = true;
    }

    /// Reports a failure, in the status bar and in the log.
    ///
    /// Logged at `warn` rather than `debug`: anything the user is shown as an
    /// error is by definition worth having in the log afterwards, and `debug`
    /// is below the default filter, so those messages used to reach the screen
    /// and nothing else.
    pub fn set_error(&mut self, text: impl Into<String>) {
        let text = text.into();
        tracing::warn!(%text, "error notice");
        self.notice = Some(Notice {
            text,
            level: NoticeLevel::Error,
        });
        self.dirty = true;
    }

    /// Dismisses the current notice, if there is one.
    ///
    /// Called on each keystroke from the event loop, before the keystroke's own
    /// action runs — so a notice survives exactly until the user does the next
    /// thing, and an action is free to post a new one. Returns whether anything
    /// was actually dismissed, so the caller can avoid a needless redraw.
    pub fn clear_notice(&mut self) -> bool {
        if self.notice.is_none() {
            return false;
        }
        self.notice = None;
        self.dirty = true;
        true
    }

    /// The text of the current notice when it is an error, else `None`.
    ///
    /// For callers that care specifically about failure — chiefly tests
    /// asserting that an operation did or did not fail.
    // clippy: dead_code — reached from tests/, which drive the library rather
    // than the binary; the binary matches on the level directly.
    #[allow(dead_code)]
    pub fn error_text(&self) -> Option<&str> {
        match &self.notice {
            Some(Notice {
                text,
                level: NoticeLevel::Error,
            }) => Some(text.as_str()),
            _ => None,
        }
    }

    /// Updates the cached `StatusBarState` from current state.
    ///
    /// Called automatically by `load_dir`.
    fn update_status(&mut self) {
        self.status.cwd_display = crate::pathfmt::display(&self.cwd);
        self.status.entry_count = self.visible_count();
    }

    // -- Tab management -------------------------------------------------------

    /// Saves the current flat state into the active tab slot before switching.
    fn save_to_active_tab(&mut self) {
        let tab = self.tab_manager.active_tab_mut();
        tab.cwd = self.cwd.clone();
        tab.entries = self.entries.clone();
        tab.selected = self.selected;
        tab.sort = self.sort;
        std::mem::swap(&mut tab.history, &mut self.history);
    }

    /// Restores the flat state from the active tab slot after switching.
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the restored `cwd` can no longer be listed.
    pub fn restore_from_active_tab(&mut self) -> Result<(), StateError> {
        let tab = self.tab_manager.active_tab_mut();
        let new_cwd = tab.cwd.clone();
        let new_selected = tab.selected;
        // Restored before `load_dir` below, which sorts by it — a tab must come
        // back in the order it was left in, not the order the tab you came from
        // happened to be using.
        let new_sort = tab.sort;
        std::mem::swap(&mut tab.history, &mut self.history);
        self.cwd = new_cwd.clone();
        self.selected = new_selected;
        self.sort = new_sort;
        self.git = None;
        self.filter = None;
        self.preview = PreviewSlot::default();
        self.mode = Mode::Navigation;
        self.load_dir(&new_cwd)?;
        Ok(())
    }

    /// Opens a new tab rooted at `cwd` (or the current directory when `None`).
    ///
    /// Saves the current tab's state before switching.
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the new tab's directory cannot be listed.
    pub fn open_tab(&mut self, cwd: Option<PathBuf>) -> Result<(), StateError> {
        // Same reasoning as `enter_dir`: an explicit `cwd` comes from outside
        // and may carry a Windows verbatim prefix.
        let new_cwd = cwd
            .map(|p| crate::pathfmt::simplified(&p))
            .unwrap_or_else(|| self.cwd.clone());
        self.save_to_active_tab();
        // The new tab inherits this one's order: opening a second view of the
        // same work is not a request to go back to the configured default.
        self.tab_manager.open_tab(new_cwd.clone(), self.sort);
        self.cwd = new_cwd.clone();
        self.selected = 0;
        self.git = None;
        self.filter = None;
        self.preview = PreviewSlot::default();
        self.mode = Mode::Navigation;
        self.history = NavigationHistory::new();
        self.load_dir(&new_cwd)?;
        Ok(())
    }

    /// Closes the active tab and restores the adjacent tab's state.
    ///
    /// Returns `false` when only one tab is open (close refused).
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the revealed tab's directory cannot be listed.
    pub fn close_tab(&mut self) -> Result<bool, StateError> {
        if !self.tab_manager.close_active_tab() {
            return Ok(false);
        }
        let tab = self.tab_manager.active_tab_mut();
        let new_cwd = tab.cwd.clone();
        let new_selected = tab.selected;
        // As in `restore_from_active_tab`: the revealed tab keeps its own order.
        let new_sort = tab.sort;
        std::mem::swap(&mut tab.history, &mut self.history);
        self.cwd = new_cwd.clone();
        self.selected = new_selected;
        self.sort = new_sort;
        self.git = None;
        self.filter = None;
        self.preview = PreviewSlot::default();
        self.mode = Mode::Navigation;
        self.load_dir(&new_cwd)?;
        Ok(true)
    }

    /// Switches to the next tab (wraps), restoring its state.
    ///
    /// No-op when only one tab is open.
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the target tab's directory cannot be listed.
    pub fn switch_tab_next(&mut self) -> Result<(), StateError> {
        if self.tab_manager.is_single() {
            return Ok(());
        }
        self.save_to_active_tab();
        self.tab_manager.switch_next();
        self.restore_from_active_tab()
    }

    /// Switches to the previous tab (wraps), restoring its state.
    ///
    /// No-op when only one tab is open.
    ///
    /// # Errors
    ///
    /// Returns [`StateError::ReadDir`] if the target tab's directory cannot be listed.
    pub fn switch_tab_prev(&mut self) -> Result<(), StateError> {
        if self.tab_manager.is_single() {
            return Ok(());
        }
        self.save_to_active_tab();
        self.tab_manager.switch_prev();
        self.restore_from_active_tab()
    }

    // -- Bookmark management --------------------------------------------------

    /// Adds a bookmark named `name` pointing to the current directory.
    ///
    /// No-op (logs at debug) when the bookmark store is not initialized.
    ///
    /// # Errors
    ///
    /// Returns [`BookmarkError`] if the store cannot be persisted.
    pub fn bookmark_add(&mut self, name: String) -> Result<(), BookmarkError> {
        let cwd = self.cwd.clone();
        if let Some(store) = &mut self.bookmark_store {
            store.add(name, cwd)?;
        } else {
            tracing::debug!("bookmark_add: store not initialized");
        }
        Ok(())
    }

    /// Navigates to the bookmark named `name`.
    ///
    /// Returns `Ok(true)` if the bookmark existed and navigation succeeded,
    /// `Ok(false)` if no bookmark with that name exists.
    ///
    /// # Errors
    ///
    /// Returns [`StateError`] if the bookmarked directory cannot be listed.
    pub fn bookmark_jump(&mut self, name: &str) -> Result<bool, StateError> {
        let target = match &self.bookmark_store {
            Some(store) => store.get(name).map(|p| p.to_owned()),
            None => {
                tracing::debug!("bookmark_jump: store not initialized");
                return Ok(false);
            }
        };
        match target {
            Some(path) => {
                // Recorded for `U`, unless the jump goes nowhere: returning
                // from a jump to the directory you were already in would be a
                // `U` that does nothing.
                if crate::pathfmt::simplified(&path) != self.cwd {
                    self.history.push_jump(self.cwd.clone());
                }
                self.enter_dir(path)?;
                Ok(true)
            }
            None => Ok(false),
        }
    }

    /// Returns to the directory the newest jump left (`U`).
    ///
    /// Walks past jumps whose directory has since disappeared, and returns
    /// how many it skipped, so the caller can say so. `Ok(None)` means there
    /// was no jump to return from. The return itself is an ordinary visit — `u`
    /// comes back from it — but not a jump, so `U` again goes further back
    /// rather than bouncing between two places.
    ///
    /// # Errors
    ///
    /// Returns [`StateError`] if the directory exists but cannot be listed.
    pub fn return_from_jump(&mut self) -> Result<Option<usize>, StateError> {
        let mut skipped = 0;
        while let Some(dir) = self.history.pop_jump() {
            if dir.is_dir() {
                self.enter_dir(dir)?;
                return Ok(Some(skipped));
            }
            skipped += 1;
        }
        Ok(if skipped > 0 { Some(skipped) } else { None })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    /// Creates a temp directory with a known set of files for testing.
    fn make_test_dir() -> TempDir {
        let dir = tempfile::tempdir().expect("tempdir");
        let p = dir.path();
        fs::create_dir(p.join("alpha_dir")).unwrap();
        fs::create_dir(p.join("zeta_dir")).unwrap();
        fs::write(p.join("a_file.txt"), b"").unwrap();
        fs::write(p.join(".hidden_file"), b"").unwrap();
        dir
    }

    #[test]
    fn dir_first_sort() {
        let dir = make_test_dir();
        let state = AppState::new(dir.path().to_owned()).unwrap();

        // Directories should come first.
        let entries: Vec<_> = state.visible_entries().collect();
        assert_eq!(entries[0].kind, EntryKind::Dir);
        assert_eq!(entries[1].kind, EntryKind::Dir);
        // File comes after directories.
        assert_eq!(entries[2].kind, EntryKind::File);
    }

    #[test]
    fn dir_sort_is_alphabetical_case_insensitive() {
        let dir = make_test_dir();
        let state = AppState::new(dir.path().to_owned()).unwrap();
        let dirs: Vec<_> = state
            .visible_entries()
            .filter(|e| e.kind == EntryKind::Dir)
            .map(|e| e.file_name.as_str())
            .collect();
        assert_eq!(dirs, vec!["alpha_dir", "zeta_dir"]);
    }

    #[test]
    fn hidden_files_hidden_by_default() {
        let dir = make_test_dir();
        let state = AppState::new(dir.path().to_owned()).unwrap();
        assert!(
            state.visible_entries().all(|e| !e.is_hidden),
            "hidden files should not be visible by default"
        );
    }

    #[test]
    fn toggle_hidden_reveals_hidden_files() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        state.toggle_hidden().unwrap();
        assert!(
            state.visible_entries().any(|e| e.is_hidden),
            "hidden files should be visible after toggle"
        );
    }

    #[test]
    fn move_down_clamps_at_bottom() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        let count = state.visible_count();
        for _ in 0..count + 5 {
            state.move_down();
        }
        assert_eq!(state.selected, count - 1);
    }

    #[test]
    fn move_up_clamps_at_top() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        state.move_up(); // Already at 0.
        assert_eq!(state.selected, 0);
    }

    #[test]
    fn jump_top_bottom() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        state.jump_bottom();
        let count = state.visible_count();
        assert_eq!(state.selected, count - 1);
        state.jump_top();
        assert_eq!(state.selected, 0);
    }

    #[test]
    fn dirty_set_on_mutation() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        state.dirty = false; // simulate post-render clear
        state.move_down();
        assert!(state.dirty);
    }

    /// The bug this guards: the selection was an index that was merely clamped,
    /// so revealing hidden files moved it onto whichever entry had taken that
    /// row. In this fixture `.hidden_file` sorts directly before `a_file.txt`,
    /// so the selected row is exactly the one that shifts.
    #[test]
    fn toggling_hidden_keeps_the_selection_on_the_same_entry() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        state.jump_bottom(); // a_file.txt — the last visible entry
        let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();
        assert!(
            anchor.ends_with("a_file.txt"),
            "fixture changed: {anchor:?}"
        );

        state.toggle_hidden().unwrap();
        assert_eq!(
            state.selected_entry().map(|e| e.path.clone()),
            Some(anchor.clone()),
            "revealing hidden files must not move the selection"
        );
        assert_eq!(
            state.selected, 3,
            "the row it sits on has to have moved, or the test proves nothing"
        );

        state.toggle_hidden().unwrap();
        assert_eq!(
            state.selected_entry().map(|e| e.path.clone()),
            Some(anchor),
            "hiding them again must put it back"
        );
    }

    /// A refresh is usually something the filesystem asked for rather than
    /// something the user did, so it must not move the cursor under them.
    #[test]
    fn a_refresh_keeps_the_selection_when_an_entry_appears_above_it() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        state.jump_bottom();
        let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();

        // `0` sorts before `a`, so this lands above the selection.
        fs::write(dir.path().join("0_first.txt"), b"").unwrap();
        state.refresh().unwrap();

        assert_eq!(state.selected_entry().map(|e| e.path.clone()), Some(anchor));
    }

    /// Returning to a directory restores the entry, not the row number — the
    /// listing may have changed while the user was away.
    #[test]
    fn returning_to_a_directory_reselects_the_same_entry() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();
        state.jump_bottom();
        let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();

        // Descend from `cwd`, which is canonicalized, so the parent Trail
        // returns to is the same path it recorded the selection under.
        let child = state.cwd.join("alpha_dir");
        state.enter_dir(child).unwrap();
        fs::write(dir.path().join("0_first.txt"), b"").unwrap();
        state.go_parent().unwrap();

        assert_eq!(state.selected_entry().map(|e| e.path.clone()), Some(anchor));
    }

    /// The bug this guards: an outcome and a failure shared one field, so a
    /// successful `:bookmark` was rendered as `Error: bookmark added: name`.
    #[test]
    fn an_outcome_is_not_an_error() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();

        state.notify("bookmark added: work");
        assert_eq!(
            state.notice.as_ref().map(|n| n.level),
            Some(NoticeLevel::Info)
        );
        assert!(
            state.error_text().is_none(),
            "an info notice must not read as a failure"
        );

        state.set_error("mkdir: destination already exists");
        assert_eq!(
            state.notice.as_ref().map(|n| n.level),
            Some(NoticeLevel::Error)
        );
        assert_eq!(
            state.error_text(),
            Some("mkdir: destination already exists")
        );
    }

    /// The bug this guards: `last_yank` was only ever assigned, so one `ya`
    /// kept announcing itself in every directory visited afterwards.
    #[test]
    fn clearing_a_notice_reports_whether_there_was_one() {
        let dir = make_test_dir();
        let mut state = AppState::new(dir.path().to_owned()).unwrap();

        assert!(!state.clear_notice(), "nothing to clear on a fresh state");

        state.notify("yanked: src/main.rs");
        state.dirty = false; // simulate post-render clear
        assert!(state.clear_notice(), "the notice was there to clear");
        assert!(state.notice.is_none());
        assert!(state.dirty, "dismissing a notice has to be drawn");
        assert!(!state.clear_notice(), "and it is gone for good");
    }
}