bmrk 0.4.0

A fast TUI for directory navigation and bookmark management
use crossbeam_channel::{unbounded, Receiver};
use std::path::{Path, PathBuf};
use std::thread::{self, JoinHandle};

#[derive(Debug, Clone)]
pub struct DiskInfo {
    pub name: String,
    pub mount_point: PathBuf,
    pub fs_type: String,
    pub total_bytes: u64,
    pub available_bytes: u64,
}

impl DiskInfo {
    fn format_size(bytes: u64) -> String {
        if bytes >= 1_000_000_000_000 {
            format!("{:.1}T", bytes as f64 / 1_000_000_000_000.0)
        } else if bytes >= 1_000_000_000 {
            format!("{:.1}G", bytes as f64 / 1_000_000_000.0)
        } else if bytes >= 1_000_000 {
            format!("{:.1}M", bytes as f64 / 1_000_000.0)
        } else {
            format!("{:.1}K", bytes as f64 / 1_000.0)
        }
    }

    pub fn display_line(&self) -> String {
        let path = self.mount_point.display().to_string();
        let fs = &self.fs_type;
        // Show name only when it differs from the mount point label
        let mount_trimmed = path.trim_end_matches(['/', '\\']);
        let name_suffix = if !self.name.is_empty() && self.name != mount_trimmed {
            format!("  {}", self.name)
        } else {
            String::new()
        };
        if self.total_bytes == 0 {
            format!("{:<12}[{:<6}]{}", path, fs, name_suffix)
        } else {
            let free = Self::format_size(self.available_bytes);
            let total = Self::format_size(self.total_bytes);
            format!(
                "{:<12}[{:<6}]  {:>8} free / {:>8} total{}",
                path, fs, free, total, name_suffix
            )
        }
    }
}

pub struct Disks {
    pub disks: Vec<DiskInfo>,
    pub is_selecting: bool,
    pub selected_index: usize,
    /// `true` after keyboard navigation (center in viewport); `false` after mouse (minimal scroll).
    pub center_selection: bool,
    /// `true` while the background enumeration thread hasn't reported back yet.
    pub is_loading: bool,
    /// The path to pre-select against once the background enumeration result arrives — stashed
    /// by `enter_selection_mode` since preselection can no longer run synchronously at call time.
    pending_preselect_path: Option<PathBuf>,
    load_thread: Option<JoinHandle<()>>,
    load_receiver: Option<Receiver<Vec<DiskInfo>>>,
}

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

impl Disks {
    pub fn new() -> Self {
        Self {
            disks: Vec::new(),
            is_selecting: false,
            selected_index: 0,
            center_selection: false,
            is_loading: false,
            pending_preselect_path: None,
            load_thread: None,
            load_receiver: None,
        }
    }

    /// Enters disk-selection mode without blocking on enumeration — `enumerate_disks()` walks
    /// every mounted volume with a `read_dir` per mount, which can hang the UI thread for as long
    /// as a stale/disconnected network mount takes to time out at the OS level (`.debug/BDP.md`
    /// Part 5, Finding #5). The disk list starts empty and `is_loading` true; the real list and
    /// current-path preselection are applied once the background thread reports back, via
    /// [`poll_load`](Self::poll_load).
    pub fn enter_selection_mode(&mut self, current_path: Option<&Path>) {
        self.disks = Vec::new();
        self.is_selecting = true;
        self.is_loading = true;
        self.selected_index = 0;
        self.center_selection = false;
        self.pending_preselect_path = current_path.map(|p| p.to_path_buf());

        let (tx, rx) = unbounded();
        let handle = thread::spawn(move || {
            let _ = tx.send(enumerate_disks());
        });
        self.load_thread = Some(handle);
        self.load_receiver = Some(rx);
    }

    /// Drains the background enumeration result, if any. Returns `true` if the disk list was
    /// just populated (the UI should redraw).
    pub fn poll_load(&mut self) -> bool {
        let Some(rx) = self.load_receiver.as_ref() else {
            return false;
        };
        let Ok(disks) = rx.try_recv() else {
            return false;
        };

        self.disks = disks;
        self.load_thread = None;
        self.load_receiver = None;
        self.is_loading = false;

        // Pre-select the disk whose mount point is the longest prefix of the path the caller
        // was on when `enter_selection_mode` was called — this used to run inline at call time;
        // now it runs here, once the list this depends on has actually arrived.
        if let Some(path) = self.pending_preselect_path.take() {
            let mut best_len = 0usize;
            let mut best_idx = 0usize;
            for (i, disk) in self.disks.iter().enumerate() {
                if path.starts_with(&disk.mount_point) {
                    let len = disk.mount_point.as_os_str().len();
                    if len > best_len {
                        best_len = len;
                        best_idx = i;
                    }
                }
            }
            if best_len > 0 {
                self.selected_index = best_idx;
            }
        }

        true
    }

    pub fn exit_selection_mode(&mut self) {
        self.is_selecting = false;
        self.is_loading = false;
        self.pending_preselect_path = None;
        // Not joined — enumeration has no natural cancellation point mid-walk (see Finding #5's
        // note), so a slow scan is simply detached. Dropping the receiver here means a late send
        // from that stale thread is silently ignored instead of repopulating a closed panel.
        self.load_thread = None;
        self.load_receiver = None;
    }

    pub fn move_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
        self.center_selection = true;
    }

    pub fn move_down(&mut self) {
        if !self.disks.is_empty() && self.selected_index + 1 < self.disks.len() {
            self.selected_index += 1;
        }
        self.center_selection = true;
    }

    pub fn get_selected(&self) -> Option<&DiskInfo> {
        self.disks.get(self.selected_index)
    }
}

fn enumerate_disks() -> Vec<DiskInfo> {
    use sysinfo::Disks as SysDisks;
    let sys_disks = SysDisks::new_with_refreshed_list();
    let mut result: Vec<DiskInfo> = sys_disks
        .list()
        .iter()
        .map(|d| DiskInfo {
            name: d.name().to_string_lossy().to_string(),
            mount_point: d.mount_point().to_path_buf(),
            fs_type: d.file_system().to_string_lossy().to_string(),
            total_bytes: d.total_space(),
            available_bytes: d.available_space(),
        })
        // Skip mount points we can't actually read (e.g. permission denied) — listing them
        // only to have every action on them fail is not useful.
        .filter(|d| is_accessible(&d.mount_point))
        .collect();
    result.sort_by(|a, b| a.mount_point.cmp(&b.mount_point));
    result
}

fn is_accessible(path: &std::path::Path) -> bool {
    std::fs::read_dir(path).is_ok()
}

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

    #[test]
    fn is_accessible_true_for_readable_dir() {
        assert!(is_accessible(&std::env::temp_dir()));
    }

    #[test]
    fn is_accessible_false_for_nonexistent_path() {
        let path = std::env::temp_dir().join("bmrk_test_disk_nonexistent_xyz");
        assert!(!is_accessible(&path));
    }

    // Finding #5 (.debug/BDP.md Part 5): `enumerate_disks()` used to run synchronously on the
    // UI thread inside `enter_selection_mode`, so a stale/disconnected mount could freeze the
    // whole TUI. These two tests cover the fix: entering selection mode must return immediately,
    // and the current-path preselection (moved from call time to `poll_load`) must still work.

    #[test]
    fn enter_selection_mode_does_not_block() {
        let mut disks = Disks::new();

        let start = std::time::Instant::now();
        disks.enter_selection_mode(Some(&std::env::temp_dir()));
        let elapsed = start.elapsed();

        assert!(
            elapsed < std::time::Duration::from_millis(50),
            "enter_selection_mode() took too long: {:?}",
            elapsed
        );
        assert!(disks.is_selecting);
        assert!(disks.is_loading);
        assert!(disks.disks.is_empty());
    }

    #[test]
    fn poll_load_applies_preselection() {
        let mut disks = Disks::new();
        disks.is_selecting = true;
        disks.is_loading = true;
        disks.pending_preselect_path = Some(PathBuf::from("/mnt/data/projects/bmrk"));

        let fixture = vec![
            DiskInfo {
                name: "root".to_string(),
                mount_point: PathBuf::from("/"),
                fs_type: "ext4".to_string(),
                total_bytes: 0,
                available_bytes: 0,
            },
            DiskInfo {
                name: "data".to_string(),
                mount_point: PathBuf::from("/mnt/data"),
                fs_type: "ext4".to_string(),
                total_bytes: 0,
                available_bytes: 0,
            },
        ];

        // Bypass the real background thread: feed the channel directly, as the thread would.
        let (tx, rx) = unbounded();
        tx.send(fixture).unwrap();
        disks.load_receiver = Some(rx);

        let updated = disks.poll_load();

        assert!(updated);
        assert!(!disks.is_loading);
        assert_eq!(disks.disks.len(), 2);
        assert_eq!(
            disks.selected_index, 1,
            "must preselect '/mnt/data' (the longest matching mount point prefix), not '/'"
        );
        assert!(disks.pending_preselect_path.is_none());
    }

    #[cfg(unix)]
    #[test]
    fn is_accessible_false_for_permission_denied_dir() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::TempDir::new().unwrap();
        let locked = dir.path().join("locked");
        std::fs::create_dir(&locked).unwrap();
        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();

        let accessible = is_accessible(&locked);

        // Restore permissions so TempDir cleanup doesn't fail
        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();

        assert!(
            !accessible,
            "permission-denied directory must not be accessible"
        );
    }
}