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                        // If following CWD but no disk matched (e.g. removable
108                        // since removed), fall back to the launch dir's disk.
109                        let fallback = use_cwd.then(|| launch_dir.lock().clone());
110                        if let Some(snapshot) =
111                            snapshot_for(disks.list(), &target, fallback.as_deref())
112                        {
113                            *data.lock() = snapshot;
114                        }
115
116                        let deadline = Instant::now() + interval;
117                        while Instant::now() < deadline && running.load(Ordering::Relaxed) {
118                            std::thread::sleep(Duration::from_millis(50));
119                        }
120                    }
121                });
122
123            match handle {
124                Ok(h) => *self.thread.lock() = Some(h),
125                Err(e) => {
126                    // Reset running so start() can be retried; degrade
127                    // gracefully rather than crashing the session.
128                    self.running.store(false, Ordering::SeqCst);
129                    crate::debug_error!(
130                        "SESSION_LOGGER",
131                        "failed to spawn disk monitor thread: {:?}",
132                        e
133                    );
134                }
135            }
136        }
137
138        /// Signal the polling thread to stop without waiting.
139        pub fn signal_stop(&self) {
140            self.running.store(false, Ordering::SeqCst);
141        }
142
143        /// Stop the polling thread and wait for it to finish.
144        pub fn stop(&self) {
145            self.signal_stop();
146            if let Some(handle) = self.thread.lock().take() {
147                let _ = handle.join();
148            }
149        }
150
151        /// Whether the polling thread is currently running.
152        pub fn is_running(&self) -> bool {
153            self.running.load(Ordering::SeqCst)
154        }
155
156        /// Get a clone of the current data snapshot.
157        pub fn data(&self) -> DiskMonitorData {
158            self.data.lock().clone()
159        }
160
161        /// Update the active tab's working directory (called each frame).
162        pub fn set_cwd(&self, new_cwd: Option<&str>) {
163            *self.cwd.lock() = new_cwd.map(String::from);
164        }
165
166        /// Toggle between launch-disk (false) and follow-active-CWD (true).
167        pub fn set_follow_cwd(&self, follow: bool) {
168            *self.follow_cwd.lock() = follow;
169        }
170    }
171
172    impl Default for DiskMonitor {
173        fn default() -> Self {
174            Self::new()
175        }
176    }
177
178    impl Drop for DiskMonitor {
179        fn drop(&mut self) {
180            self.stop();
181        }
182    }
183
184    /// One poll's result: the free-space snapshot for the disk holding
185    /// `target` (or `fallback` when nothing matches `target`), stamped now.
186    pub fn snapshot_for(
187        disks: &[Disk],
188        target: &Path,
189        fallback: Option<&Path>,
190    ) -> Option<DiskMonitorData> {
191        let disk = disk_for_path(disks, target).or_else(|| disk_for_path(disks, fallback?))?;
192        let total = disk.total_space();
193        let free = disk.available_space();
194        let free_percent = if total > 0 {
195            (free as f64 / total as f64 * 100.0) as f32
196        } else {
197            0.0
198        };
199        Some(DiskMonitorData {
200            free_bytes: free,
201            total_bytes: total,
202            free_percent,
203            last_update: Some(Instant::now()),
204        })
205    }
206
207    /// Return the disk whose mount point is the longest path-prefix of `target`.
208    ///
209    /// `Path::starts_with` compares by component, so `/` matches every absolute
210    /// path on the root volume (the universal fallback). Pure over the slice.
211    pub fn disk_for_path<'a>(disks: &'a [Disk], target: &Path) -> Option<&'a Disk> {
212        disks
213            .iter()
214            .filter(|d| target.starts_with(d.mount_point()))
215            .max_by_key(|d| d.mount_point().components().count())
216    }
217}
218
219#[cfg(feature = "system-monitor")]
220pub use inner::{DiskMonitor, disk_for_path, snapshot_for};
221
222// ============================================================================
223// Stub implementation (feature disabled)
224// ============================================================================
225
226#[cfg(not(feature = "system-monitor"))]
227mod inner {
228    use super::DiskMonitorData;
229
230    /// Stub disk monitor (sysinfo feature disabled). Same API, all no-ops.
231    pub struct DiskMonitor;
232
233    impl DiskMonitor {
234        pub fn new() -> Self {
235            Self
236        }
237        pub fn start(&self, _poll_interval_secs: f32) {}
238        pub fn signal_stop(&self) {}
239        pub fn stop(&self) {}
240        pub fn is_running(&self) -> bool {
241            false
242        }
243        pub fn data(&self) -> DiskMonitorData {
244            DiskMonitorData::default()
245        }
246        pub fn set_cwd(&self, _new_cwd: Option<&str>) {}
247        pub fn set_follow_cwd(&self, _follow: bool) {}
248    }
249
250    impl Default for DiskMonitor {
251        fn default() -> Self {
252            Self::new()
253        }
254    }
255}
256
257#[cfg(not(feature = "system-monitor"))]
258pub use inner::DiskMonitor;
259
260// ============================================================================
261// Tests
262// ============================================================================
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_disk_monitor_data_default() {
270        let d = DiskMonitorData::default();
271        assert_eq!(d.free_bytes, 0);
272        assert_eq!(d.total_bytes, 0);
273        assert_eq!(d.free_percent, 0.0);
274        assert!(d.last_update.is_none());
275    }
276
277    #[cfg(feature = "system-monitor")]
278    #[test]
279    fn test_disk_for_path_resolves() {
280        use sysinfo::Disks;
281        let disks = Disks::new_with_refreshed_list();
282        // The system temp dir must resolve to some disk.
283        let tmp = std::env::temp_dir();
284        let d = disk_for_path(disks.list(), &tmp);
285        assert!(d.is_some(), "no disk matched temp dir {:?}", tmp);
286        let d = d.unwrap();
287        assert!(d.total_space() > 0);
288        assert!(d.available_space() <= d.total_space());
289    }
290
291    #[cfg(feature = "system-monitor")]
292    #[test]
293    fn test_disk_for_path_returns_mount_prefix() {
294        use sysinfo::Disks;
295        let disks = Disks::new_with_refreshed_list();
296        if disks.list().is_empty() {
297            return; // no disks to assert against
298        }
299        // The temp dir lives on a real volume on every platform. The matched
300        // disk's mount point must be a path-component prefix of the target —
301        // the core invariant of disk_for_path. (Does not assume a Unix `/`
302        // root, which matches no drive-letter volume on Windows.)
303        let target = std::env::temp_dir();
304        let matched = disk_for_path(disks.list(), &target);
305        assert!(matched.is_some(), "no disk matched temp dir {target:?}");
306        assert!(
307            target.starts_with(matched.unwrap().mount_point()),
308            "matched mount point must be a prefix of the target path"
309        );
310    }
311
312    #[cfg(feature = "system-monitor")]
313    #[test]
314    fn test_snapshot_for_target_and_fallback() {
315        use sysinfo::Disks;
316        let disks = Disks::new_with_refreshed_list();
317        let tmp = std::env::temp_dir();
318
319        let s = snapshot_for(disks.list(), &tmp, None).expect("temp dir resolves to a disk");
320        assert!(s.total_bytes > 0 && s.free_bytes <= s.total_bytes);
321        assert!(s.free_percent >= 0.0 && s.free_percent <= 100.0);
322        assert!(s.last_update.is_some());
323
324        // Mount points are absolute, so a relative target matches no disk —
325        // the follow-cwd fallback path.
326        let unmatched = std::path::Path::new("relative/nowhere");
327        assert!(snapshot_for(disks.list(), unmatched, None).is_none());
328        assert!(snapshot_for(disks.list(), unmatched, Some(&tmp)).is_some());
329    }
330
331    /// Lifecycle only: the poll result is covered by
332    /// `test_snapshot_for_target_and_fallback`. Waiting here for the thread's
333    /// first poll put a wall-clock deadline on mount enumeration, which a
334    /// loaded host stalled past (card 01a0d5fb99df77b3be1f30ddd50f862c).
335    #[cfg(feature = "system-monitor")]
336    #[test]
337    fn test_disk_monitor_start_stop() {
338        let monitor = DiskMonitor::new();
339        assert!(!monitor.is_running());
340        monitor.start(5.0);
341        assert!(monitor.is_running());
342        monitor.stop();
343        assert!(!monitor.is_running());
344    }
345}