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
//! TUI application state.
use super::filter::Filter;
use crate::caches::model::{Cache, FloorPolicy, MarkSet, Sort};
use std::path::PathBuf;
use std::time::SystemTime;
/// Frames of the loading spinner glyph, advanced once per event-loop idle tick.
pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Transient overlay shown after a delete pass completes. Dismissed
/// automatically after 2 s via `Effect::EmitAfter`.
pub struct Overlay {
pub outcome: RunOutcome,
}
/// Outcome of a real or dry-run cache deletion pass.
pub struct RunOutcome {
pub freed: u64,
pub deleted: usize,
/// Items the cleaner returned an `Err` for. `0` on dry runs.
pub failed: usize,
pub dry_run: bool,
}
/// Visual state of a background scan in progress.
pub struct Loading {
/// Human label of the cache being scanned — shown in the spinner modal.
pub label: String,
/// Spinner animation frame index into `SPINNER_FRAMES`.
pub frame: usize,
/// When the scan started; used to render elapsed time when no per-task
/// progress signal is available.
pub started: std::time::Instant,
/// `Some(n)` when the worker streams a folder-count via `ScanProgress`
/// (the LoadSeeds startup scan). `None` for spinners that don't carry a
/// progress signal, in which case the view falls back to elapsed time.
pub folders: Option<usize>,
}
impl Loading {
/// Advance the spinner one frame, wrapping at the end of the glyph cycle.
pub fn update_frame(&mut self) {
self.frame = (self.frame + 1) % SPINNER_FRAMES.len();
}
/// Current spinner glyph.
pub fn glyph(&self) -> &'static str {
SPINNER_FRAMES[self.frame]
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Modal {
#[default]
None,
DeleteConfirm,
ActiveMark(Vec<usize>),
FilterEdit,
}
pub struct State {
pub now: SystemTime,
pub all: Vec<Cache>,
pub sort: Sort,
pub marks: MarkSet,
pub cursor: usize,
pub files_cursor: usize,
pub floor: FloorPolicy,
pub focus_right: bool,
pub stack: Vec<Vec<Cache>>, // drill-in: parent levels saved here
pub stack_labels: Vec<String>,
pub quit: bool,
pub modal: Modal,
pub dry_run: bool,
pub yes_mode: bool,
/// Bytes freed across all deletion passes in this session.
pub total_freed: u64,
/// When `Some`, a less/vim-style `/` filter is active (possibly being
/// edited). When `None`, no filter is applied.
pub filter: Option<Filter>,
/// `Some` while a background drill-in scan is running; drives the
/// spinner modal.
pub loading: Option<Loading>,
/// `Some` for ~2 s after a delete pass completes; draws the result
/// overlay until `Msg::OverlayDismiss` is received.
pub overlay: Option<Overlay>,
/// Set to true whenever something was successfully deleted at the
/// current drill level. Reset on drill in/out. When we drill out and
/// this was true, the parent's row for the cache we're leaving is
/// re-scanned to reflect the smaller size.
pub level_dirty: bool,
/// Path stack parallel to `stack` so we know which entry in the
/// restored parent corresponds to the cache we just drilled out of.
/// Pushed on `drill_into`, popped on `drill_out`.
pub drill_paths: Vec<PathBuf>,
/// Cursor positions parallel to `stack`. On `drill_into` we save the
/// current cursor; on `drill_out` we restore it (then clamp), so the
/// user lands back on the row they were on instead of at the top.
pub cursor_stack: Vec<usize>,
}
impl State {
pub fn sorted_indices(&self) -> Vec<usize> {
let mut idx: Vec<usize> = (0..self.all.len()).collect();
if let Some(f) = &self.filter {
idx.retain(|&i| f.is_visible(&self.all[i].path));
}
match self.sort {
Sort::Score => idx.sort_by(|&a, &b| {
self.all[b]
.score(self.now)
.partial_cmp(&self.all[a].score(self.now))
.unwrap()
}),
Sort::Size => idx.sort_by(|&a, &b| self.all[b].size_bytes.cmp(&self.all[a].size_bytes)),
Sort::Age => idx.sort_by(|&a, &b| {
let aa = self.all[a].age(self.now).map(|d| d.as_secs());
let bb = self.all[b].age(self.now).map(|d| d.as_secs());
match (aa, bb) {
(Some(x), Some(y)) => y.cmp(&x), // descending: older first
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}),
}
idx
}
pub(crate) fn clamp_cursor_to_visible(&mut self) {
let n = self.sorted_indices().len();
if n == 0 {
self.cursor = 0;
} else if self.cursor >= n {
self.cursor = n - 1;
}
}
pub fn drill_into(&mut self, children: Vec<Cache>) {
let parent = std::mem::replace(&mut self.all, children);
self.cursor_stack.push(self.cursor);
self.stack.push(parent);
self.cursor = 0;
self.marks.clear(); // marks are index-keyed; reset on level change
self.level_dirty = false;
}
pub fn drill_out(&mut self) {
let _ = self.drill_out_with_path();
}
/// Same as `drill_out` but also returns the path of the cache we just
/// left (for the event loop to trigger a refresh). Returns `None`
/// when already at the top level.
pub fn drill_out_with_path(&mut self) -> Option<PathBuf> {
// Only pop drill_paths / cursor_stack when we actually pop the
// stack — otherwise calling drill_out twice at the top level would
// silently desync them from stack.len().
if let Some(parent) = self.stack.pop() {
self.all = parent;
// Restore the cursor the user had when they drilled in. Clamp
// against the new visible set in case a refresh shifted things.
self.cursor = self.cursor_stack.pop().unwrap_or(0);
self.marks.clear();
self.stack_labels.pop();
self.level_dirty = false;
let popped = self.drill_paths.pop();
self.clamp_cursor_to_visible();
popped
} else {
None
}
}
}