diskr-cli 1.0.0

Save your disk space, without fear of deleting the wrong thing.
Documentation
use crate::scanner::{self, FsNode};
use crate::ui::widgets;
use anyhow::Result;
use crossterm::event::{
    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton, MouseEvent,
    MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
use std::io;
use std::path::PathBuf;
use std::time::Duration;

pub struct App {
    pub root: FsNode,
    pub cursor_path: Vec<usize>,
    pub selected: usize,
    pub scroll: usize,
    pub visible_rows: usize,
    pub should_quit: bool,
    pub marked: Vec<PathBuf>,
    pub list_area: Rect,
}

impl App {
    pub fn new(root: FsNode) -> Self {
        Self {
            root,
            cursor_path: Vec::new(),
            selected: 0,
            scroll: 0,
            visible_rows: 10,
            should_quit: false,
            marked: Vec::new(),
            list_area: Rect::default(),
        }
    }

    pub fn current(&self) -> &FsNode {
        let mut node = &self.root;
        for &i in &self.cursor_path {
            node = &node.children[i];
        }
        node
    }

    pub fn breadcrumb(&self) -> String {
        self.current().path.display().to_string()
    }

    pub fn marked_total(&self) -> u64 {
        fn find_size(node: &FsNode, target: &PathBuf) -> Option<u64> {
            if &node.path == target {
                return Some(node.size);
            }
            for c in &node.children {
                if let Some(s) = find_size(c, target) {
                    return Some(s);
                }
            }
            None
        }
        self.marked.iter().filter_map(|p| find_size(&self.root, p)).sum()
    }

    pub fn set_visible_rows(&mut self, rows: usize) {
        self.visible_rows = rows.max(1);
        self.clamp_scroll();
    }

    fn clamp_scroll(&mut self) {
        let len = self.current().children.len();
        if self.selected < self.scroll {
            self.scroll = self.selected;
        } else if self.selected >= self.scroll + self.visible_rows {
            self.scroll = self.selected + 1 - self.visible_rows;
        }
        let max_scroll = len.saturating_sub(self.visible_rows);
        if self.scroll > max_scroll {
            self.scroll = max_scroll;
        }
    }

    pub fn descend(&mut self) {
        let node = self.current();
        if node.is_dir && !node.children.is_empty() && self.selected < node.children.len() {
            let child_is_dir = node.children[self.selected].is_dir;
            if child_is_dir {
                self.cursor_path.push(self.selected);
                self.selected = 0;
                self.scroll = 0;
            }
        }
    }

    pub fn ascend(&mut self) {
        if let Some(prev) = self.cursor_path.pop() {
            self.selected = prev;
            self.scroll = 0;
            self.clamp_scroll();
        }
    }

    pub fn move_down(&mut self) {
        let len = self.current().children.len();
        if len > 0 && self.selected + 1 < len {
            self.selected += 1;
            self.clamp_scroll();
        }
    }

    pub fn move_up(&mut self) {
        if self.selected > 0 {
            self.selected -= 1;
            self.clamp_scroll();
        }
    }

    pub fn page_down(&mut self) {
        let len = self.current().children.len();
        if len == 0 {
            return;
        }
        self.selected = (self.selected + self.visible_rows).min(len - 1);
        self.clamp_scroll();
    }

    pub fn page_up(&mut self) {
        self.selected = self.selected.saturating_sub(self.visible_rows);
        self.clamp_scroll();
    }

    pub fn go_top(&mut self) {
        self.selected = 0;
        self.clamp_scroll();
    }

    pub fn go_bottom(&mut self) {
        let len = self.current().children.len();
        self.selected = len.saturating_sub(1);
        self.clamp_scroll();
    }

    fn row_to_index(&self, row: u16) -> Option<usize> {
        let first_row = self.list_area.y.saturating_add(1);
        let last_row = self.list_area.y.saturating_add(self.list_area.height.saturating_sub(2));
        if row < first_row || row > last_row {
            return None;
        }
        let offset = (row - first_row) as usize;
        let idx = self.scroll + offset;
        if idx < self.current().children.len() {
            Some(idx)
        } else {
            None
        }
    }

    pub fn handle_mouse(&mut self, ev: MouseEvent) {
        match ev.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(idx) = self.row_to_index(ev.row) {
                    if idx == self.selected {
                        self.descend();
                    } else {
                        self.selected = idx;
                        self.clamp_scroll();
                    }
                }
            }
            MouseEventKind::Down(MouseButton::Right) => self.ascend(),
            MouseEventKind::ScrollDown => {
                for _ in 0..3 {
                    self.move_down();
                }
            }
            MouseEventKind::ScrollUp => {
                for _ in 0..3 {
                    self.move_up();
                }
            }
            _ => {}
        }
    }

    pub fn toggle_mark(&mut self) {
        let node = self.current();
        if let Some(child) = node.children.get(self.selected) {
            let path = child.path.clone();
            if let Some(pos) = self.marked.iter().position(|p| p == &path) {
                self.marked.remove(pos);
            } else {
                self.marked.push(path);
            }
        }
    }
}

pub fn launch() -> Result<()> {
    let target = PathBuf::from(".");
    let opts = scanner::walker::WalkOptions::default();
    let result = scanner::walker::scan_path(&target, &opts)?;
    let mut root = result.root;
    root.sort_by_size_desc();

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let mut app = App::new(root);
    let res = run_loop(&mut terminal, &mut app);

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
    terminal.show_cursor()?;

    res
}

fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
    while !app.should_quit {
        terminal.draw(|f| widgets::draw(f, app))?;
        if event::poll(Duration::from_millis(200))? {
            match event::read()? {
                Event::Key(key) => {
                    if key.kind != KeyEventKind::Press {
                        continue;
                    }
                    match key.code {
                        KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
                        KeyCode::Down | KeyCode::Char('j') => app.move_down(),
                        KeyCode::Up | KeyCode::Char('k') => app.move_up(),
                        KeyCode::Right | KeyCode::Enter | KeyCode::Char('l') => app.descend(),
                        KeyCode::Left | KeyCode::Backspace | KeyCode::Char('h') => app.ascend(),
                        KeyCode::PageDown => app.page_down(),
                        KeyCode::PageUp => app.page_up(),
                        KeyCode::Home | KeyCode::Char('g') => app.go_top(),
                        KeyCode::End | KeyCode::Char('G') => app.go_bottom(),
                        KeyCode::Char(' ') => app.toggle_mark(),
                        _ => {}
                    }
                }
                Event::Mouse(mouse_ev) => app.handle_mouse(mouse_ev),
                _ => {}
            }
        }
    }
    Ok(())
}