par-term 0.48.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
//! Disk free-space monitor for the status bar.
//!
//! `DiskMonitor` runs a background thread that periodically reports free space
//! on a target disk via `sysinfo`. The target is either the disk containing
//! par-term's launch directory (default) or the disk containing the active
//! tab/pane's working directory (when follow-cwd is enabled).

use std::time::Instant;

/// Snapshot of free space on the monitored disk.
#[derive(Debug, Clone, Default)]
pub struct DiskMonitorData {
    /// Free bytes available to non-privileged users.
    pub free_bytes: u64,
    /// Total capacity of the disk (bytes).
    pub total_bytes: u64,
    /// Free percentage (0.0–100.0).
    pub free_percent: f32,
    /// When this data was last updated.
    pub last_update: Option<Instant>,
}

// ============================================================================
// Full implementation (feature enabled)
// ============================================================================

#[cfg(feature = "system-monitor")]
mod inner {
    use std::path::{Path, PathBuf};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::thread::JoinHandle;
    use std::time::{Duration, Instant};

    use parking_lot::Mutex;
    use sysinfo::{Disk, DiskRefreshKind, Disks};

    use super::DiskMonitorData;

    /// Background disk free-space monitor.
    ///
    /// Spawns a polling thread that periodically refreshes disk free space via
    /// `sysinfo`. The monitored disk defaults to the one containing par-term's
    /// launch directory; with `set_follow_cwd(true)` it follows the active
    /// tab/pane's working directory instead.
    pub struct DiskMonitor {
        data: Arc<Mutex<DiskMonitorData>>,
        cwd: Arc<Mutex<Option<String>>>,
        follow_cwd: Arc<Mutex<bool>>,
        launch_dir: Arc<Mutex<PathBuf>>,
        running: Arc<AtomicBool>,
        thread: Mutex<Option<JoinHandle<()>>>,
    }

    impl DiskMonitor {
        /// Create a new (stopped) disk monitor, capturing the launch directory.
        ///
        /// par-term never chdir's its own process (the PTY child does), so
        /// `std::env::current_dir()` here is stable for the whole session.
        pub fn new() -> Self {
            let launch_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
            Self {
                data: Arc::new(Mutex::new(DiskMonitorData::default())),
                cwd: Arc::new(Mutex::new(None)),
                follow_cwd: Arc::new(Mutex::new(false)),
                launch_dir: Arc::new(Mutex::new(launch_dir)),
                running: Arc::new(AtomicBool::new(false)),
                thread: Mutex::new(None),
            }
        }

        /// Start the polling thread. No-op if already running.
        pub fn start(&self, poll_interval_secs: f32) {
            if self
                .running
                .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
                .is_err()
            {
                return;
            }

            let data = Arc::clone(&self.data);
            let cwd = Arc::clone(&self.cwd);
            let follow_cwd = Arc::clone(&self.follow_cwd);
            let launch_dir = Arc::clone(&self.launch_dir);
            let running = Arc::clone(&self.running);
            let interval = Duration::from_secs_f32(poll_interval_secs.max(5.0));

            let handle = std::thread::Builder::new()
                .name("status-bar-disk".to_string())
                .spawn(move || {
                    let mut disks = Disks::new_with_refreshed_list();
                    while running.load(Ordering::SeqCst) {
                        disks.refresh_specifics(true, DiskRefreshKind::nothing().with_storage());

                        let use_cwd = *follow_cwd.lock();
                        let target: PathBuf = if use_cwd {
                            cwd.lock()
                                .clone()
                                .filter(|c| !c.is_empty())
                                .map(PathBuf::from)
                                .unwrap_or_else(|| launch_dir.lock().clone())
                        } else {
                            launch_dir.lock().clone()
                        };

                        // If following CWD but no disk matched (e.g. removable
                        // since removed), fall back to the launch dir's disk.
                        let fallback = use_cwd.then(|| launch_dir.lock().clone());
                        if let Some(snapshot) =
                            snapshot_for(disks.list(), &target, fallback.as_deref())
                        {
                            *data.lock() = snapshot;
                        }

                        let deadline = Instant::now() + interval;
                        while Instant::now() < deadline && running.load(Ordering::Relaxed) {
                            std::thread::sleep(Duration::from_millis(50));
                        }
                    }
                });

            match handle {
                Ok(h) => *self.thread.lock() = Some(h),
                Err(e) => {
                    // Reset running so start() can be retried; degrade
                    // gracefully rather than crashing the session.
                    self.running.store(false, Ordering::SeqCst);
                    crate::debug_error!(
                        "SESSION_LOGGER",
                        "failed to spawn disk monitor thread: {:?}",
                        e
                    );
                }
            }
        }

        /// Signal the polling thread to stop without waiting.
        pub fn signal_stop(&self) {
            self.running.store(false, Ordering::SeqCst);
        }

        /// Stop the polling thread and wait for it to finish.
        pub fn stop(&self) {
            self.signal_stop();
            if let Some(handle) = self.thread.lock().take() {
                let _ = handle.join();
            }
        }

        /// Whether the polling thread is currently running.
        pub fn is_running(&self) -> bool {
            self.running.load(Ordering::SeqCst)
        }

        /// Get a clone of the current data snapshot.
        pub fn data(&self) -> DiskMonitorData {
            self.data.lock().clone()
        }

        /// Update the active tab's working directory (called each frame).
        pub fn set_cwd(&self, new_cwd: Option<&str>) {
            *self.cwd.lock() = new_cwd.map(String::from);
        }

        /// Toggle between launch-disk (false) and follow-active-CWD (true).
        pub fn set_follow_cwd(&self, follow: bool) {
            *self.follow_cwd.lock() = follow;
        }
    }

    impl Default for DiskMonitor {
        fn default() -> Self {
            Self::new()
        }
    }

    impl Drop for DiskMonitor {
        fn drop(&mut self) {
            self.stop();
        }
    }

    /// One poll's result: the free-space snapshot for the disk holding
    /// `target` (or `fallback` when nothing matches `target`), stamped now.
    pub fn snapshot_for(
        disks: &[Disk],
        target: &Path,
        fallback: Option<&Path>,
    ) -> Option<DiskMonitorData> {
        let disk = disk_for_path(disks, target).or_else(|| disk_for_path(disks, fallback?))?;
        let total = disk.total_space();
        let free = disk.available_space();
        let free_percent = if total > 0 {
            (free as f64 / total as f64 * 100.0) as f32
        } else {
            0.0
        };
        Some(DiskMonitorData {
            free_bytes: free,
            total_bytes: total,
            free_percent,
            last_update: Some(Instant::now()),
        })
    }

    /// Return the disk whose mount point is the longest path-prefix of `target`.
    ///
    /// `Path::starts_with` compares by component, so `/` matches every absolute
    /// path on the root volume (the universal fallback). Pure over the slice.
    pub fn disk_for_path<'a>(disks: &'a [Disk], target: &Path) -> Option<&'a Disk> {
        disks
            .iter()
            .filter(|d| target.starts_with(d.mount_point()))
            .max_by_key(|d| d.mount_point().components().count())
    }
}

#[cfg(feature = "system-monitor")]
pub use inner::{DiskMonitor, disk_for_path, snapshot_for};

// ============================================================================
// Stub implementation (feature disabled)
// ============================================================================

#[cfg(not(feature = "system-monitor"))]
mod inner {
    use super::DiskMonitorData;

    /// Stub disk monitor (sysinfo feature disabled). Same API, all no-ops.
    pub struct DiskMonitor;

    impl DiskMonitor {
        pub fn new() -> Self {
            Self
        }
        pub fn start(&self, _poll_interval_secs: f32) {}
        pub fn signal_stop(&self) {}
        pub fn stop(&self) {}
        pub fn is_running(&self) -> bool {
            false
        }
        pub fn data(&self) -> DiskMonitorData {
            DiskMonitorData::default()
        }
        pub fn set_cwd(&self, _new_cwd: Option<&str>) {}
        pub fn set_follow_cwd(&self, _follow: bool) {}
    }

    impl Default for DiskMonitor {
        fn default() -> Self {
            Self::new()
        }
    }
}

#[cfg(not(feature = "system-monitor"))]
pub use inner::DiskMonitor;

// ============================================================================
// Tests
// ============================================================================

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

    #[test]
    fn test_disk_monitor_data_default() {
        let d = DiskMonitorData::default();
        assert_eq!(d.free_bytes, 0);
        assert_eq!(d.total_bytes, 0);
        assert_eq!(d.free_percent, 0.0);
        assert!(d.last_update.is_none());
    }

    #[cfg(feature = "system-monitor")]
    #[test]
    fn test_disk_for_path_resolves() {
        use sysinfo::Disks;
        let disks = Disks::new_with_refreshed_list();
        // The system temp dir must resolve to some disk.
        let tmp = std::env::temp_dir();
        let d = disk_for_path(disks.list(), &tmp);
        assert!(d.is_some(), "no disk matched temp dir {:?}", tmp);
        let d = d.unwrap();
        assert!(d.total_space() > 0);
        assert!(d.available_space() <= d.total_space());
    }

    #[cfg(feature = "system-monitor")]
    #[test]
    fn test_disk_for_path_returns_mount_prefix() {
        use sysinfo::Disks;
        let disks = Disks::new_with_refreshed_list();
        if disks.list().is_empty() {
            return; // no disks to assert against
        }
        // The temp dir lives on a real volume on every platform. The matched
        // disk's mount point must be a path-component prefix of the target —
        // the core invariant of disk_for_path. (Does not assume a Unix `/`
        // root, which matches no drive-letter volume on Windows.)
        let target = std::env::temp_dir();
        let matched = disk_for_path(disks.list(), &target);
        assert!(matched.is_some(), "no disk matched temp dir {target:?}");
        assert!(
            target.starts_with(matched.unwrap().mount_point()),
            "matched mount point must be a prefix of the target path"
        );
    }

    #[cfg(feature = "system-monitor")]
    #[test]
    fn test_snapshot_for_target_and_fallback() {
        use sysinfo::Disks;
        let disks = Disks::new_with_refreshed_list();
        let tmp = std::env::temp_dir();

        let s = snapshot_for(disks.list(), &tmp, None).expect("temp dir resolves to a disk");
        assert!(s.total_bytes > 0 && s.free_bytes <= s.total_bytes);
        assert!(s.free_percent >= 0.0 && s.free_percent <= 100.0);
        assert!(s.last_update.is_some());

        // Mount points are absolute, so a relative target matches no disk —
        // the follow-cwd fallback path.
        let unmatched = std::path::Path::new("relative/nowhere");
        assert!(snapshot_for(disks.list(), unmatched, None).is_none());
        assert!(snapshot_for(disks.list(), unmatched, Some(&tmp)).is_some());
    }

    /// Lifecycle only: the poll result is covered by
    /// `test_snapshot_for_target_and_fallback`. Waiting here for the thread's
    /// first poll put a wall-clock deadline on mount enumeration, which a
    /// loaded host stalled past (card 01a0d5fb99df77b3be1f30ddd50f862c).
    #[cfg(feature = "system-monitor")]
    #[test]
    fn test_disk_monitor_start_stop() {
        let monitor = DiskMonitor::new();
        assert!(!monitor.is_running());
        monitor.start(5.0);
        assert!(monitor.is_running());
        monitor.stop();
        assert!(!monitor.is_running());
    }
}