use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use clap::Parser;
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use disktree::app::App;
use disktree::index::{self, ScanProgress, Tree};
use disktree::ui;
#[derive(Parser, Debug)]
#[command(
name = "disktree",
version,
about = "Disk usage TUI: scan, browse, and delete what's taking space"
)]
struct Args {
#[arg(short, long)]
path: Option<String>,
#[arg(short = 'j', long)]
threads: Option<usize>,
#[arg(long, conflicts_with = "no_mft")]
mft: bool,
#[arg(long)]
no_mft: bool,
#[arg(long)]
mft_readers: Option<usize>,
#[arg(long)]
no_elevate: bool,
#[arg(long, hide = true)]
elevated_child: bool,
}
fn default_root() -> &'static str {
if cfg!(windows) { "C:\\" } else { "/" }
}
struct Plan {
#[cfg(windows)]
volume: Option<std::sync::Arc<disktree::mft::Volume>>,
notice: Option<String>,
}
fn main() -> io::Result<()> {
let args = Args::parse();
let path = PathBuf::from(
args.path
.clone()
.unwrap_or_else(|| default_root().to_owned()),
);
if !path.is_dir() {
eprintln!("'{}' is not a directory", path.display());
std::process::exit(1);
}
let plan = choose_scanner(&path, &args);
let mut terminal = ratatui::init();
let result = run(&mut terminal, path, &args, plan);
ratatui::restore();
result
}
#[cfg(not(windows))]
fn choose_scanner(_path: &Path, _args: &Args) -> Plan {
Plan { notice: None }
}
#[cfg(windows)]
fn choose_scanner(path: &Path, args: &Args) -> Plan {
use disktree::mft::{self, Capability};
if args.no_mft {
return Plan {
volume: None,
notice: None,
};
}
match mft::probe(path) {
Capability::Ready(vol) => {
let is_root = path == vol.mount();
if is_root || args.mft {
let secs_hint = vol.mft_bytes() as f64 / 800e6;
Plan {
notice: Some(format!(
"MFT scan: {:.2} GB of $MFT across {} fragments, up to {} records (~{:.0}s)",
vol.mft_bytes() as f64 / 1e9,
vol.fragments(),
vol.record_slots(),
secs_hint,
)),
volume: Some(std::sync::Arc::from(vol)),
}
} else {
Plan {
volume: None,
notice: Some(
"walked directories; pass --mft to read the whole volume instead"
.to_owned(),
),
}
}
}
Capability::NeedsElevation { letter, mft_bytes } => {
let gb = mft_bytes as f64 / 1e9;
let wants_fast = args.mft || is_volume_root(path);
if !args.no_elevate && !args.elevated_child && wants_fast {
match relaunch_elevated() {
Ok(()) => {
println!(
"Relaunching as administrator to read {letter}:'s MFT ({gb:.2} GB) \
in a new window."
);
std::process::exit(0);
}
Err(why) => Plan {
volume: None,
notice: Some(format!(
"not elevated ({why}); walked directories instead. \
An MFT scan of {letter}: would read {gb:.2} GB"
)),
},
}
} else {
Plan {
volume: None,
notice: Some(format!(
"not elevated; walked directories. Run as administrator for an MFT \
scan of {letter}: ({gb:.2} GB)"
)),
}
}
}
Capability::NotNtfs { filesystem } => Plan {
volume: None,
notice: Some(format!(
"{filesystem} is not NTFS, so there is no MFT; walked directories"
)),
},
Capability::Unsupported(why) => Plan {
volume: None,
notice: Some(format!("MFT scan unavailable ({why}); walked directories")),
},
}
}
#[cfg(windows)]
fn is_volume_root(path: &Path) -> bool {
path.parent().is_none()
}
#[cfg(windows)]
fn relaunch_elevated() -> Result<(), String> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let mut params: Vec<String> = std::env::args().skip(1).collect();
params.push("--elevated-child".to_owned());
let joined = params
.iter()
.map(|a| {
if a.contains(' ') {
format!("\"{a}\"")
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join(" ");
let wide = |s: OsString| -> Vec<u16> {
s.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>()
};
let verb = wide(OsString::from("runas"));
let file = wide(exe.into_os_string());
let args_w = wide(OsString::from(joined));
let rc = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
verb.as_ptr(),
file.as_ptr(),
args_w.as_ptr(),
std::ptr::null(),
1, )
};
if rc as isize > 32 {
Ok(())
} else if rc as isize == 5 {
Err("elevation declined".to_owned())
} else {
Err(format!("ShellExecuteW returned {}", rc as isize))
}
}
fn run(terminal: &mut DefaultTerminal, path: PathBuf, args: &Args, plan: Plan) -> io::Result<()> {
loop {
let Some(tree) = run_scan(terminal, &path, args, &plan)? else {
return Ok(());
};
let mut app = App::new(tree);
if let Some(n) = plan.notice.clone() {
app.status = Some(n);
}
run_browse(terminal, &mut app)?;
if !app.rescan {
return Ok(());
}
}
}
fn run_scan(
terminal: &mut DefaultTerminal,
path: &Path,
args: &Args,
plan: &Plan,
) -> io::Result<Option<Tree>> {
let progress = Arc::new(ScanProgress::default());
let (tx, rx) = mpsc::channel();
#[cfg(windows)]
let mode = if plan.volume.is_some() {
ui::ScanMode::Mft
} else {
ui::ScanMode::Walk
};
#[cfg(not(windows))]
let mode = {
let _ = plan;
ui::ScanMode::Walk
};
{
let progress = Arc::clone(&progress);
let root = path.to_path_buf();
let threads = args.threads;
#[cfg(windows)]
let readers = args.mft_readers;
#[cfg(windows)]
let volume = plan.volume.clone();
thread::spawn(move || {
#[cfg(windows)]
let tree = match volume {
Some(vol) => disktree::mft::scan(&vol, &progress, readers)
.unwrap_or_else(|_| index::scan_with_threads(root, &progress, threads)),
None => index::scan_with_threads(root, &progress, threads),
};
#[cfg(not(windows))]
let tree = index::scan_with_threads(root, &progress, threads);
let _ = tx.send(tree);
});
}
let start = Instant::now();
loop {
if let Ok(tree) = rx.try_recv() {
return Ok(Some(tree));
}
terminal.draw(|f| ui::draw_scanning(f, path, &progress, start.elapsed(), mode))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
let ctrl_c =
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL);
if key.kind == KeyEventKind::Press
&& (key.code == KeyCode::Char('q') || key.code == KeyCode::Esc || ctrl_c)
{
return Ok(None);
}
}
}
}
fn run_browse(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
loop {
terminal.draw(|f| ui::draw(f, app))?;
if let Some((targets, permanent)) = app.pending_delete.take() {
app.perform_delete(&targets, permanent);
continue;
}
if event::poll(Duration::from_millis(200))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
app.on_key(key);
}
if app.quit || app.rescan {
return Ok(());
}
}
}