Make the Rust implementation the only one: crate at the root, Go tree removed

This commit is contained in:
Andy Dixon
2026-10-08 19:51:33 +01:00
parent 7e3be19a2b
commit 507f635b49
131 changed files with 3 additions and 12500 deletions
+438
View File
@@ -0,0 +1,438 @@
//! The concrete modals: item information, LOD group, help, confirmation and
//! save snapshot.
use super::canvas::{rgb, Style, BOLD};
use super::keys::default_snapshot_name;
use super::mapview::Group;
use super::modal::{kv, styled, txt, wrap, Line, Modal, Span};
use super::model::{Cmd, Model};
use super::search::LineEditor;
use super::{fmt_time, pad_left, pad_right, san, trunc, tw};
use crate::export;
use crate::filter;
use crate::inventory::{NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED, FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE};
use crate::platform;
use crate::textutil;
// ---- Item information -------------------------------------------------------
struct InfoModal {
id: NodeId,
btime: i64, // live birth time (0 if unknown)
}
pub(crate) fn new_info_modal(m: &Model, tr: &Tree, id: NodeId) -> Box<dyn Modal> {
let mut md = InfoModal { id, btime: 0 };
if !m.snapshot {
// Birth time is not kept in the inventory; read it live.
if let Ok(meta) = platform::lstat(&tr.path_bytes(id)) {
md.btime = meta.btime;
}
}
Box::new(md)
}
impl Modal for InfoModal {
fn width(&self) -> i32 {
72
}
fn title(&self) -> String {
"Item information".into()
}
fn footer(&self, m: &Model) -> String {
let mut s = format!("Esc close · c copy path · o {} · Space go to", reveal_verb());
if !m.opts.read_only && !m.snapshot {
s += &format!(" · d {}", delete_verb());
}
s
}
fn content(&self, m: &mut Model, tr: &Tree, w: i32) -> Vec<Line> {
let t = m.theme.clone();
let n = tr.node(self.id);
let ks = Style::new(t.muted, Default::default());
let vs = Style::new(t.fg, Default::default());
let bold = vs.with(BOLD);
let warn = Style::new(t.warn, Default::default()).with(BOLD);
let mut out: Vec<Line> = Vec::new();
let add = |out: &mut Vec<Line>, k: &str, v: String| out.push(kv(k, v, ks, vs));
let add_s = |out: &mut Vec<Line>, k: &str, v: String, st: Style| out.push(kv(k, v, ks, st));
let col = m.color_of(tr, self.id as i64);
let title = Style::new(col.mix(rgb(255, 255, 255), 0.3), Default::default()).with(BOLD);
for l in wrap(&san(&n.name), w) {
out.push(styled(l, title));
}
out.push(txt(""));
out.push(styled("Path", ks));
for l in wrap(&san(&tr.path_bytes(self.id)), w) {
out.push(styled(l, vs));
}
if san(&n.name).as_bytes() != &*n.name {
out.push(styled("Name contains control or invalid characters (shown escaped)", warn));
}
out.push(txt(""));
let mode = m.size_mode;
if n.is_dir() {
add_s(&mut out, "Total logical", format!("{} ({} bytes)", textutil::size(n.tot_size), textutil::count(n.tot_size)), bold);
add_s(&mut out, "Total allocated", format!("{} ({} bytes)", textutil::size(n.tot_alloc), textutil::count(n.tot_alloc)), bold);
add(&mut out, "Files", textutil::count(n.files as i64));
add(&mut out, "Directories", textutil::count(n.dirs as i64));
if n.parent != NO_NODE {
add(&mut out, "Of parent", textutil::percent(n.total(mode), tr.node(n.parent).total(mode)));
}
add(&mut out, "Of scan", textutil::percent(n.total(mode), tr.node(0).total(mode)));
let k = m.kids_of(tr, self.id);
if let Some(&first) = k.ids.first() {
add(&mut out, "Largest child", format!("{} ({})", san(&tr.node(first).name), textutil::size(k.sizes[0])));
}
if n.errors > 0 {
add_s(&mut out, "Errors beneath", textutil::count(n.errors as i64), Style::new(t.err, Default::default()).with(BOLD));
}
let state = if n.has(FLAG_INCOMPLETE) {
"incomplete (not fully read)"
} else if !n.has(FLAG_SCANNED) && m.scanning {
"scanning…"
} else if n.has(FLAG_SKIPPED_FS | FLAG_VIRTUAL_FS | FLAG_LOOP) {
"not descended"
} else {
"complete"
};
add(&mut out, "Scan state", state.into());
} else {
add_s(&mut out, "Logical size", format!("{} ({} bytes)", textutil::size(n.size), textutil::count(n.size)), bold);
let alloc = if n.has(FLAG_ALLOC_UNKNOWN) {
"unknown on this platform".to_string()
} else {
format!("{} ({} bytes)", textutil::size(n.alloc), textutil::count(n.alloc))
};
add_s(&mut out, "Allocated size", alloc, bold);
if n.parent != NO_NODE {
add(&mut out, "Of parent", textutil::percent(n.own(mode), tr.node(n.parent).total(mode)));
}
}
if let Some(r) = &m.search.result {
add(&mut out, "Matching filter", textutil::size(r.size(self.id)));
}
if let Some(d) = &m.opts.diff {
match d.old_size(self.id, mode) {
Some(old) => {
add(&mut out, "In old snapshot", textutil::size(old));
add_s(&mut out, "Change", textutil::signed_size(n.total(mode) - old), bold);
}
None => add_s(&mut out, "Change", "new since old snapshot".into(), Style::new(t.diff_new, Default::default()).with(BOLD)),
}
}
out.push(txt(""));
add(&mut out, "Type", n.kind.name().into());
if !n.is_dir() {
let e = tr.ext_name(n);
if !e.is_empty() {
add(&mut out, "Extension", textutil::sanitize(e));
}
add(&mut out, "Category", n.cat.name().into());
}
out.push(txt(""));
if n.mtime > 0 {
add(&mut out, "Modified", fmt_time(n.mtime));
}
if self.btime > 0 {
add(&mut out, "Created", fmt_time(self.btime));
}
out.push(txt(""));
add(&mut out, "Mode", platform::mode::string(n.mode));
add(&mut out, "Owner", textutil::sanitize(&platform::user_name(n.uid)));
add(&mut out, "Group", textutil::sanitize(&platform::group_name(n.gid)));
if !n.is_dir() {
add(&mut out, "Hard links", n.nlink.to_string());
let sparse = if n.has(FLAG_SPARSE) {
format!("Yes (or compressed) — {} not allocated", textutil::size(n.size - n.alloc))
} else {
"No".into()
};
add(&mut out, "Sparse", sparse);
}
let fl = export::flag_list(n.flags & !(FLAG_SCANNED | FLAG_SPARSE));
if !fl.is_empty() {
add_s(&mut out, "Flags", fl.join(", "), warn);
}
if n.has(FLAG_HARDLINK_DUP) {
out.push(styled("Another path to this inode was counted; this one adds nothing to totals.", ks));
}
if m.snapshot {
out.push(txt(""));
out.push(styled("From snapshot — metadata as recorded, not live.", ks));
}
out
}
fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) {
match k {
"c" => (false, m.copy_path(&tr.path_bytes(self.id))),
"o" => {
m.sel = self.id as i64;
// Headless, o navigates the map, which the modal would cover.
let c = m.reveal(tr);
(platform::headless(), c)
}
"enter" => (true, Cmd::None),
"g" | "right" => {
m.jump_to(tr, self.id);
(true, Cmd::None)
}
"d" | "delete" => {
m.sel = self.id as i64;
(true, m.request_trash(tr))
}
_ => (false, Cmd::None),
}
}
}
// ---- LOD group ------------------------------------------------------------------
struct GroupModal {
g: Group,
}
pub(crate) fn new_group_modal(g: Group) -> Box<dyn Modal> {
Box::new(GroupModal { g })
}
impl Modal for GroupModal {
fn width(&self) -> i32 {
70
}
fn title(&self) -> String {
"Smaller items (grouped)".into()
}
fn footer(&self, _: &Model) -> String {
"Esc close · Space shows them in the list view".into()
}
fn content(&self, m: &mut Model, tr: &Tree, w: i32) -> Vec<Line> {
let t = m.theme.clone();
let ids = self.g.ids();
let mut out = vec![
txt(format!("{} items too small to draw individually at this size,", textutil::count(ids.len() as i64))),
txt(format!("totalling {}. Zoom in or enlarge the terminal to see them.", textutil::size(self.g.size))),
txt(""),
];
for (i, &id) in ids.iter().enumerate() {
if i == 200 {
out.push(styled(format!("… and {} more", textutil::count(ids.len() as i64 - 200)), Style::new(t.muted, Default::default())));
break;
}
let n = tr.node(id);
let mut name = san(&n.name);
if n.is_dir() {
name.push('/');
}
out.push(vec![
Span { s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ", st: Some(Style::new(t.muted, Default::default())) },
Span { s: trunc(&name, w - 13), st: Some(Style::new(m.color_of(tr, id as i64).mix(t.fg, 0.4), Default::default())) },
]);
}
out
}
fn key(&mut self, m: &mut Model, _: &Tree, k: &str) -> (bool, Cmd) {
if k == "enter" {
m.sel = self.g.ids()[0] as i64;
m.set_view_by_name("List");
return (true, Cmd::None);
}
(false, Cmd::None)
}
}
// ---- Help ---------------------------------------------------------------------
struct HelpModal;
pub(crate) fn new_help_modal() -> Box<dyn Modal> {
Box::new(HelpModal)
}
impl Modal for HelpModal {
fn width(&self) -> i32 {
78
}
fn title(&self) -> String {
"Keyboard reference".into()
}
fn footer(&self, _: &Model) -> String {
"Esc close · ↑↓ scroll".into()
}
fn content(&self, m: &mut Model, _: &Tree, _: i32) -> Vec<Line> {
let t = m.theme.clone();
let h = Style::new(t.accent, Default::default()).with(BOLD);
let k = Style::new(t.sel, Default::default()).with(BOLD);
let d = Style::new(t.fg, Default::default());
let muted = Style::new(t.muted, Default::default());
let mut out: Vec<Line> = Vec::new();
let sec = |out: &mut Vec<Line>, s: &str| {
if !out.is_empty() {
out.push(txt(""));
}
out.push(styled(s, h));
};
let row = |out: &mut Vec<Line>, keys: &str, desc: &str| {
out.push(vec![Span { s: format!(" {}", pad_right(keys, 19)), st: Some(k) }, Span { s: desc.into(), st: Some(d) }]);
};
sec(&mut out, "NAVIGATION");
row(&mut out, "↑ ↓ ← → hjkl", "move spatially through the treemap");
row(&mut out, "Enter", "inspect the selected item");
row(&mut out, "Space →", "zoom into the selected directory");
row(&mut out, "Backspace ←", "zoom out one level");
row(&mut out, "Home", "back to the scan root");
row(&mut out, "click / dbl-click", "select / zoom (mouse)");
row(&mut out, "wheel", "zoom in/out (map), scroll (lists)");
sec(&mut out, "SEARCH & FILTER");
row(&mut out, "/", "search or filter; the map shows only matches");
row(&mut out, "Esc", "clear the filter");
out.push(styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", muted));
out.push(styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", muted));
let fields: Vec<&str> = filter::FIELDS.split_whitespace().collect();
let split = 7.min(fields.len());
out.push(styled(format!(" fields: {}", fields[..split].join(" ")), muted));
out.push(styled(format!(" {}", fields[split..].join(" ")), muted));
out.push(styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", muted));
sec(&mut out, "VIEWS");
row(&mut out, "Tab Shift+Tab", "next / previous view");
row(&mut out, "1 – 7", "jump to a view");
row(&mut out, "x", "file types (extension statistics)");
row(&mut out, "g", "top lists: largest, oldest, sparse, hard links …");
row(&mut out, "e", "scan information and errors");
row(&mut out, "D", "find duplicate files (reads file contents)");
sec(&mut out, "APPLICATION");
row(&mut out, "a", "toggle allocated / logical (apparent) size");
row(&mut out, "c", "copy path to clipboard");
row(&mut out, "o", reveal_verb());
if !m.opts.read_only {
row(&mut out, "d", &format!("{} (asks first)", delete_verb()));
}
row(&mut out, "s", "save snapshot");
row(&mut out, "r", "rescan");
row(&mut out, "T", "cycle colour theme");
row(&mut out, "Ctrl+C", "cancel scan; press again to quit");
row(&mut out, "q", "quit");
out.push(txt(""));
let units = if crate::textutil::si() { "SI (kB = 1000 bytes)" } else { "IEC (KiB = 1024 bytes)" };
out.push(styled(format!("Sizes: {} · units: {units}", m.size_mode.name()), muted));
out
}
fn key(&mut self, _: &mut Model, _: &Tree, _: &str) -> (bool, Cmd) {
(false, Cmd::None)
}
}
// ---- Confirmation -------------------------------------------------------------
pub(crate) type OnYes = Box<dyn FnOnce(&mut Model) -> Cmd>;
pub(crate) struct ConfirmModal {
pub ttl: String,
pub body: Vec<String>,
pub yes: String,
pub on_yes: Option<OnYes>,
}
impl Modal for ConfirmModal {
fn width(&self) -> i32 {
self.body.iter().map(|b| tw(b)).fold(40, i32::max).min(90)
}
fn title(&self) -> String {
self.ttl.clone()
}
fn footer(&self, _: &Model) -> String {
format!("y {} · n / Esc cancel", self.yes)
}
fn content(&self, _: &mut Model, _: &Tree, w: i32) -> Vec<Line> {
self.body.iter().flat_map(|b| wrap(b, w)).map(txt).collect()
}
fn key(&mut self, m: &mut Model, _: &Tree, k: &str) -> (bool, Cmd) {
match k {
"y" | "Y" => (true, self.on_yes.take().map_or(Cmd::None, |f| f(m))),
"n" | "N" | "enter" => (true, Cmd::None),
_ => (false, Cmd::None),
}
}
}
// ---- Save snapshot ---------------------------------------------------------------
struct SaveModal {
ed: LineEditor,
}
pub(crate) fn new_save_modal(tr: &Tree) -> Box<dyn Modal> {
let mut md = SaveModal { ed: LineEditor::default() };
md.ed.set(&default_snapshot_name(&tr.stats.root));
Box::new(md)
}
impl Modal for SaveModal {
fn editor(&mut self) -> Option<&mut LineEditor> {
Some(&mut self.ed)
}
fn width(&self) -> i32 {
70
}
fn title(&self) -> String {
"Save snapshot".into()
}
fn footer(&self, _: &Model) -> String {
"Enter save · Esc cancel".into()
}
fn content(&self, m: &mut Model, _: &Tree, w: i32) -> Vec<Line> {
let t = &m.theme;
let mut out = vec![txt("File:")];
let s = textutil::sanitize(&self.ed.text());
for l in wrap(&(s + "█"), w) {
out.push(styled(l, Style::new(t.fg, Default::default()).with(BOLD)));
}
if m.scanning {
out.push(txt(""));
out.push(styled("The scan is still running: the snapshot will be marked incomplete.", Style::new(t.warn, Default::default())));
}
out
}
fn key(&mut self, m: &mut Model, _: &Tree, k: &str) -> (bool, Cmd) {
match k {
"esc" => (true, Cmd::None),
"enter" => {
let p = self.ed.text().trim().to_string();
if p.is_empty() {
return (false, Cmd::None);
}
let s = m.save_snapshot(p);
let i = m.info("Saving snapshot…");
(true, Cmd::batch(vec![s, i]))
}
_ => (false, Cmd::None),
}
}
}
/// Describe o and d, which change meaning on a headless system.
pub(crate) fn reveal_verb() -> &'static str {
if platform::headless() {
"show folder in map"
} else {
"reveal in file manager"
}
}
pub(crate) fn delete_verb() -> String {
if platform::headless() {
return "delete permanently".into();
}
format!("move to {}", platform::TRASH_NAME)
}