//! 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 { 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 { 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 = Vec::new(); let add = |out: &mut Vec, k: &str, v: String| out.push(kv(k, v, ks, vs)); let add_s = |out: &mut Vec, 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 { 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 { 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 { 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 { 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 = Vec::new(); let sec = |out: &mut Vec, s: &str| { if !out.is_empty() { out.push(txt("")); } out.push(styled(s, h)); }; let row = |out: &mut Vec, 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 Cmd>; pub(crate) struct ConfirmModal { pub ttl: String, pub body: Vec, pub yes: String, pub on_yes: Option, } 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 { 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 { 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 { 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) }