Skip to main content

par_term/status_bar/
disk_monitor.rs

1//! Disk free-space monitor for the status bar.
2//!
3//! `DiskMonitor` runs a background thread that periodically reports free space
4//! on a target disk via `sysinfo`. The target is either the disk containing
5//! par-term's launch directory (default) or the disk containing the active
6//! tab/pane's working directory (when follow-cwd is enabled).
7
8use std::time::Instant;
9
10/// Snapshot of free space on the monitored disk.
11#[derive(Debug, Clone, Default)]
12pub struct DiskMonitorData {
13    /// Free bytes available to non-privileged users.
14    pub free_bytes: u64,
15    /// Total capacity of the disk (bytes).
16    pub total_bytes: u64,
17    /// Free percentage (0.0–100.0).
18    pub free_percent: f32,
19    /// When this data was last updated.
20    pub last_update: Option<Instant>,
21}
22
23// ============================================================================
24// Full implementation (feature enabled)
25// ============================================================================
26
27#[cfg(feature = "system-monitor")]
28mod inner {
29    use std::path::{Path, PathBuf};
30    use std::sync::Arc;
31    use std::sync::atomic::{AtomicBool, Ordering};
32    use std::thread::JoinHandle;
33    use std::time::{Duration, Instant};
34
35    use parking_lot::Mutex;
36    use sysinfo::{Disk, DiskRefreshKind, Disks};
37
38    use super::DiskMonitorData;
39
40    /// Background disk free-space monitor.
41    ///
42    /// Spawns a polling thread that periodically refreshes disk free space via
43    /// `sysinfo`. The monitored disk defaults to the one containing par-term's
44    /// launch directory; with `set_follow_cwd(true)` it follows the active
45    /// tab/pane's working directory instead.
46    pub struct DiskMonitor {
47        data: Arc<Mutex<DiskMonitorData>>,
48        cwd: Arc<Mutex<Option<String>>>,
49        follow_cwd: Arc<Mutex<bool>>,
50        launch_dir: Arc<Mutex<PathBuf>>,
51        running: Arc<AtomicBool>,
52        thread: Mutex<Option<JoinHandle<()>>>,
53    }
54
55    impl DiskMonitor {
56        /// Create a new (stopped) disk monitor, capturing the launch directory.
57        ///
58        /// par-term never chdir's its own process (the PTY child does), so
59        /// `std::env::current_dir()` here is stable for the whole session.
60        pub fn new() -> Self {
61            let launch_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
62            Self {
63                data: Arc::new(Mutex::new(DiskMonitorData::default())),
64                cwd: Arc::new(Mutex::new(None)),
65                follow_cwd: Arc::new(Mutex::new(false)),
66                launch_dir: Arc::new(Mutex::new(launch_dir)),
67                running: Arc::new(AtomicBool::new(false)),
68                thread: Mutex::new(None),
69            }
70        }
71
72        /// Start the polling thread. No-op if already running.
73        pub fn start(&self, poll_interval_secs: f32) {
74            if self
75                .running
76                .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
77                .is_err()
78            {
79                return;
80            }
81
82            let data = Arc::clone(&self.data);
83            let cwd = Arc::clone(&self.cwd);
84            let follow_cwd = Arc::clone(&self.follow_cwd);
85            let launch_dir = Arc::clone(&self.launch_dir);
86            let running = Arc::clone(&self.running);
87            let interval = Duration::from_secs_f32(poll_interval_secs.max(5.0));
88
89            let handle = std::thread::Builder::new()
90                .name("status-bar-disk".to_string())
91                .spawn(move || {
92                    let mut disks = Disks::new_with_refreshed_list();
93                    while running.load(Ordering::SeqCst) {
94                        disks.refresh_specifics(true, DiskRefreshKind::nothing().with_storage());
95
96                        let use_cwd = *follow_cwd.lock();
97                        let target: PathBuf = if use_cwd {
98                            cwd.lock()
99                                .clone()
100                                .filter(|c| !c.is_empty())
101                                .map(PathBuf::from)
102                                .unwrap_or_else(|| launch_dir.lock().clone())
103                        } else {
104                            launch_dir.lock().clone()
105                        };
106
107                        // Resolve target → disk; if following CWD but no disk
108                        // matched (e.g. removable since removed), fall back to
109                        // the launch dir's disk.
110                        let mut disk = disk_for_path(disks.list(), &target);
111                        if disk.is_none() && use_cwd {
112                            disk = disk_for_path(disks.list(), &launch_dir.lock());
113                        }
114
115                        if let Some(d) = disk {
116                            let total = d.total_space();
117                            let free = d.available_space();
118                            let pct = if total > 0 {
119                                (free as f64 / total as f64 * 100.0) as f32
120                            } else {
121                                0.0
122                            };
123                            let mut dat = data.lock();
124                            dat.free_bytes = free;
125                            dat.total_bytes = total;
126                            dat.free_percent = pct;
127                            dat.last_update = Some(Instant::now());
128                        }
129
130                        let deadline = Instant::now() + interval;
131                        while Instant::now() < deadline && running.load(Ordering::Relaxed) {
132                            std::thread::sleep(Duration::from_millis(50));
133                        }
134                    }
135                });
136
137            match handle {
138                Ok(h) => *self.thread.lock() = Some(h),
139                Err(e) => {
140                    // Reset running so start() can be retried; degrade
141                    // gracefully rather than crashing the session.
142                    self.running.store(false, Ordering::SeqCst);
143                    crate::debug_error!(
144                        "SESSION_LOGGER",
145                        "failed to spawn disk monitor thread: {:?}",
146                        e
147                    );
148                }
149            }
150        }
151
152        /// Signal the polling thread to stop without waiting.
153        pub fn signal_stop(&self) {
154            self.running.store(false, Ordering::SeqCst);
155        }
156
157        /// Stop the polling thread and wait for it to finish.
158        pub fn stop(&self) {
159            self.signal_stop();
160            if let Some(handle) = self.thread.lock().take() {
161                let _ = handle.join();
162            }
163        }
164
165        /// Whether the polling thread is currently running.
166        pub fn is_running(&self) -> bool {
167            self.running.load(Ordering::SeqCst)
168        }
169
170        /// Get a clone of the current data snapshot.
171        pub fn data(&self) -> DiskMonitorData {
172            self.data.lock().clone()
173        }
174
175        /// Update the active tab's working directory (called each frame).
176        pub fn set_cwd(&self, new_cwd: Option<&str>) {
177            *self.cwd.lock() = new_cwd.map(String::from);
178        }
179
180        /// Toggle between launch-disk (false) and follow-active-CWD (true).
181        pub fn set_follow_cwd(&self, follow: bool) {
182            *self.follow_cwd.lock() = follow;
183        }
184    }
185
186    impl Default for DiskMonitor {
187        fn default() -> Self {
188            Self::new()
189        }
190    }
191
192    impl Drop for DiskMonitor {
193        fn drop(&mut self) {
194            self.stop();
195        }
196    }
197
198    /// Return the disk whose mount point is the longest path-prefix of `target`.
199    ///
200    /// `Path::starts_with` compares by component, so `/` matches every absolute
201    /// path on the root volume (the universal fallback). Pure over the slice.
202    pub fn disk_for_path<'a>(disks: &'a [Disk], target: &Path) -> Option<&'a Disk> {
203        disks
204            .iter()
205            .filter(|d| target.starts_with(d.mount_point()))
206            .max_by_key(|d| d.mount_point().components().count())
207    }
208}
209
210#[cfg(feature = "system-monitor")]
211pub use inner::{DiskMonitor, disk_for_path};
212
213// ============================================================================
214// Stub implementation (feature disabled)
215// ============================================================================
216
217#[cfg(not(feature = "system-monitor"))]
218mod inner {
219    use super::DiskMonitorData;
220
221    /// Stub disk monitor (sysinfo feature disabled). Same API, all no-ops.
222    pub struct DiskMonitor;
223
224    impl DiskMonitor {
225        pub fn new() -> Self {
226            Self
227        }
228        pub fn start(&self, _poll_interval_secs: f32) {}
229        pub fn signal_stop(&self) {}
230        pub fn stop(&self) {}
231        pub fn is_running(&self) -> bool {
232            false
233        }
234        pub fn data(&self) -> DiskMonitorData {
235            DiskMonitorData::default()
236        }
237        pub fn set_cwd(&self, _new_cwd: Option<&str>) {}
238        pub fn set_follow_cwd(&self, _follow: bool) {}
239    }
240
241    impl Default for DiskMonitor {
242        fn default() -> Self {
243            Self::new()
244        }
245    }
246}
247
248#[cfg(not(feature = "system-monitor"))]
249pub use inner::DiskMonitor;
250
251// ============================================================================
252// Tests
253// ============================================================================
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_disk_monitor_data_default() {
261        let d = DiskMonitorData::default();
262        assert_eq!(d.free_bytes, 0);
263        assert_eq!(d.total_bytes, 0);
264        assert_eq!(d.free_percent, 0.0);
265        assert!(d.last_update.is_none());
266    }
267
268    #[cfg(feature = "system-monitor")]
269    #[test]
270    fn test_disk_for_path_resolves() {
271        use sysinfo::Disks;
272        let disks = Disks::new_with_refreshed_list();
273        // The system temp dir must resolve to some disk.
274        let tmp = std::env::temp_dir();
275        let d = disk_for_path(disks.list(), &tmp);
276        assert!(d.is_some(), "no disk matched temp dir {:?}", tmp);
277        let d = d.unwrap();
278        assert!(d.total_space() > 0);
279        assert!(d.available_space() <= d.total_space());
280    }
281
282    #[cfg(feature = "system-monitor")]
283    #[test]
284    fn test_disk_for_path_returns_mount_prefix() {
285        use sysinfo::Disks;
286        let disks = Disks::new_with_refreshed_list();
287        if disks.list().is_empty() {
288            return; // no disks to assert against
289        }
290        // The temp dir lives on a real volume on every platform. The matched
291        // disk's mount point must be a path-component prefix of the target —
292        // the core invariant of disk_for_path. (Does not assume a Unix `/`
293        // root, which matches no drive-letter volume on Windows.)
294        let target = std::env::temp_dir();
295        let matched = disk_for_path(disks.list(), &target);
296        assert!(matched.is_some(), "no disk matched temp dir {target:?}");
297        assert!(
298            target.starts_with(matched.unwrap().mount_point()),
299            "matched mount point must be a prefix of the target path"
300        );
301    }
302
303    #[cfg(feature = "system-monitor")]
304    #[test]
305    fn test_disk_monitor_start_stop() {
306        use std::time::Duration;
307        let monitor = DiskMonitor::new();
308        assert!(!monitor.is_running());
309        monitor.start(5.0);
310        assert!(monitor.is_running());
311        // Wait for the first poll (mirrors system_monitor's flake-free approach).
312        let deadline = std::time::Instant::now() + Duration::from_secs(10);
313        let mut polled = false;
314        while std::time::Instant::now() < deadline {
315            if monitor.data().last_update.is_some() {
316                polled = true;
317                break;
318            }
319            std::thread::sleep(Duration::from_millis(50));
320        }
321        assert!(polled, "disk monitor recorded no initial poll within 10s");
322        // free percent is sane
323        let d = monitor.data();
324        assert!(d.free_percent >= 0.0 && d.free_percent <= 100.0);
325        monitor.stop();
326        assert!(!monitor.is_running());
327    }
328}