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;
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,
pub center_selection: bool,
pub is_loading: bool,
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,
}
}
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);
}
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;
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;
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(),
})
.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));
}
#[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,
},
];
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);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
!accessible,
"permission-denied directory must not be accessible"
);
}
}