diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 5c17928..105dadf 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,4 +13,4 @@ pub mod treemap; pub use category::{CATEGORY_COUNT, Category, categorize}; pub use entry::{EntryBatch, EntryFlags, FileEntry}; pub use tree::{Node, NodeId, Removed, Tree, TreeBuilder}; -pub use treemap::{TreemapOptions, TreemapRect, Viewport}; +pub use treemap::{MAX_LAYOUT_DEPTH, TreemapOptions, TreemapRect, Viewport}; diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index b27707f..2a97275 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -14,6 +14,9 @@ pub struct Viewport { pub h: f32, } +/// The deepest level any layout draws: tree depth is unbounded and `emit` recurses. +pub const MAX_LAYOUT_DEPTH: u8 = 24; + #[derive(Clone, Copy, Debug)] pub struct TreemapOptions { pub min_area_px: f32, @@ -21,6 +24,8 @@ pub struct TreemapOptions { pub max_depth: u8, /// Omit SYSTEM entries and proportion tiles by visible bytes. pub hide_system: bool, + /// Kept visible past `max_depth`: its ancestors draw open, and so does it if a directory. + pub reveal: Option, } impl Default for TreemapOptions { @@ -28,8 +33,9 @@ impl Default for TreemapOptions { TreemapOptions { min_area_px: 1.0, padding_px: 1.0, - max_depth: 32, + max_depth: MAX_LAYOUT_DEPTH, hide_system: false, + reveal: None, } } } @@ -44,6 +50,8 @@ pub struct TreemapRect { pub depth: u8, pub is_dir: bool, pub category: u8, + /// A directory whose children were laid out; false for files. + pub expanded: bool, } /// Lays out any directory subtree into `viewport`. @@ -97,10 +105,38 @@ fn layout_impl( } None => None, }; - emit(tree, root, frame, 0, opts, visible, &mut out); + let pass = Pass { + tree, + opts, + visible, + max_depth: opts.max_depth.min(MAX_LAYOUT_DEPTH), + reveal_path: reveal_path(tree, root, opts.reveal), + }; + pass.emit(root, frame, 0, &mut out); out } +/// Root first, so index `d` is the node revealed at depth `d`. Empty unless +/// `reveal` is live and under `root`. +fn reveal_path(tree: &Tree, root: NodeId, reveal: Option) -> Vec { + let Some(reveal) = reveal.filter(|&id| tree.is_live(id)) else { + return Vec::new(); + }; + let mut path = vec![reveal]; + let mut cur = reveal; + while cur != root { + match tree.node(cur).parent() { + Some(parent) => { + path.push(parent); + cur = parent; + } + None => return Vec::new(), + } + } + path.reverse(); + path +} + /// Fills `visible` with subtree sizes that omit SYSTEM entries, for the /// subtree under `root` only. SYSTEM subtrees are never visited, so `visible` /// must arrive zeroed. Explicit stack: tree depth is unbounded. @@ -163,97 +199,138 @@ impl Frame { } } -fn emit( - tree: &Tree, - id: NodeId, - frame: Frame, - depth: u8, - opts: &TreemapOptions, - visible: Option<&[u64]>, - out: &mut Vec, -) { - let node = tree.node(id); - out.push(TreemapRect { - id, - x: frame.x as f32, - y: frame.y as f32, - w: frame.w as f32, - h: frame.h as f32, - depth, - is_dir: node.is_dir(), - category: categorize(tree.name(id), node.is_dir()) as u8, - }); - if !node.is_dir() || depth >= opts.max_depth { - return; - } - let inner = frame.inset(opts.padding_px as f64); - if inner.w <= 0.0 || inner.h <= 0.0 { - return; - } - lay_children(tree, id, inner, depth + 1, opts, visible, out); +struct Pass<'a> { + tree: &'a Tree, + opts: &'a TreemapOptions, + visible: Option<&'a [u64]>, + max_depth: u8, + reveal_path: Vec, } -fn lay_children( - tree: &Tree, - dir: NodeId, - frame: Frame, - depth: u8, - opts: &TreemapOptions, - visible: Option<&[u64]>, - out: &mut Vec, -) { - let mut items: Vec<(NodeId, u64)> = tree - .children(dir) - .map(|c| (c, effective_size(tree, c, visible))) - .filter(|&(_, size)| size > 0) - .collect(); - if items.is_empty() { - return; - } - items.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - - let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); - let scale = frame.area() / total; - let min_area = opts.min_area_px as f64; - - let mut remaining = frame; - let mut i = 0; - while i < items.len() { - if remaining.w <= 0.0 || remaining.h <= 0.0 { +impl Pass<'_> { + fn emit(&self, id: NodeId, frame: Frame, depth: u8, out: &mut Vec) { + let node = self.tree.node(id); + let index = out.len(); + out.push(TreemapRect { + id, + x: frame.x as f32, + y: frame.y as f32, + w: frame.w as f32, + h: frame.h as f32, + depth, + is_dir: node.is_dir(), + category: categorize(self.tree.name(id), node.is_dir()) as u8, + expanded: false, + }); + let limit = if self.reveal_path.get(depth as usize) == Some(&id) { + MAX_LAYOUT_DEPTH + } else { + self.max_depth + }; + if !node.is_dir() || depth >= limit { return; } - let side = remaining.w.min(remaining.h); - - let first = items[i].1 as f64 * scale; - let (mut sum, mut max, mut min) = (first, first, first); - let mut worst = worst_aspect(sum, max, min, side); - let mut j = i + 1; - while j < items.len() { - let a = items[j].1 as f64 * scale; - let candidate = worst_aspect(sum + a, max.max(a), min.min(a), side); - if candidate > worst { - break; + let inner = frame.inset(self.opts.padding_px as f64); + if inner.w <= 0.0 || inner.h <= 0.0 { + return; + } + self.lay_children(id, inner, depth + 1, out); + out[index].expanded = out.len() > index + 1; + } + + fn lay_children(&self, dir: NodeId, frame: Frame, depth: u8, out: &mut Vec) { + let mut items: Vec<(NodeId, u64)> = self + .tree + .children(dir) + .map(|c| (c, effective_size(self.tree, c, self.visible))) + .filter(|&(_, size)| size > 0) + .collect(); + if items.is_empty() { + return; + } + items.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + + let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); + let scale = frame.area() / total; + + let mut remaining = frame; + let mut i = 0; + while i < items.len() { + if remaining.w <= 0.0 || remaining.h <= 0.0 { + return; + } + let side = remaining.w.min(remaining.h); + + let first = items[i].1 as f64 * scale; + let (mut sum, mut max, mut min) = (first, first, first); + let mut worst = worst_aspect(sum, max, min, side); + let mut j = i + 1; + while j < items.len() { + let a = items[j].1 as f64 * scale; + let candidate = worst_aspect(sum + a, max.max(a), min.min(a), side); + if candidate > worst { + break; + } + worst = candidate; + sum += a; + max = max.max(a); + min = min.min(a); + j += 1; } - worst = candidate; - sum += a; - max = max.max(a); - min = min.min(a); - j += 1; + + self.lay_row(&items[i..j], scale, sum, &mut remaining, depth, out); + i = j; } + } - lay_row( - tree, - &items[i..j], - scale, - sum, - &mut remaining, - depth, - min_area, - opts, - visible, - out, - ); - i = j; + fn lay_row( + &self, + row: &[(NodeId, u64)], + scale: f64, + row_area: f64, + remaining: &mut Frame, + depth: u8, + out: &mut Vec, + ) { + let horizontal = remaining.w < remaining.h; // row spans the full width + let side = if horizontal { remaining.w } else { remaining.h }; + let thickness = (row_area / side).min(if horizontal { remaining.h } else { remaining.w }); + + let mut offset = 0.0; + for (k, &(id, size)) in row.iter().enumerate() { + let len = if k == row.len() - 1 { + side - offset + } else { + (size as f64 * scale) / thickness + }; + let frame = if horizontal { + Frame { + x: remaining.x + offset, + y: remaining.y, + w: len, + h: thickness, + } + } else { + Frame { + x: remaining.x, + y: remaining.y + offset, + w: thickness, + h: len, + } + }; + offset += len; + if frame.area() >= self.opts.min_area_px as f64 { + self.emit(id, frame, depth, out); + } + } + + if horizontal { + remaining.y += thickness; + remaining.h -= thickness; + } else { + remaining.x += thickness; + remaining.w -= thickness; + } } } @@ -263,60 +340,6 @@ fn worst_aspect(sum: f64, max: f64, min: f64, side: f64) -> f64 { (w2 * max / s2).max(s2 / (w2 * min)) } -#[allow(clippy::too_many_arguments)] -fn lay_row( - tree: &Tree, - row: &[(NodeId, u64)], - scale: f64, - row_area: f64, - remaining: &mut Frame, - depth: u8, - min_area: f64, - opts: &TreemapOptions, - visible: Option<&[u64]>, - out: &mut Vec, -) { - let horizontal = remaining.w < remaining.h; // row spans the full width - let side = if horizontal { remaining.w } else { remaining.h }; - let thickness = (row_area / side).min(if horizontal { remaining.h } else { remaining.w }); - - let mut offset = 0.0; - for (k, &(id, size)) in row.iter().enumerate() { - let len = if k == row.len() - 1 { - side - offset - } else { - (size as f64 * scale) / thickness - }; - let frame = if horizontal { - Frame { - x: remaining.x + offset, - y: remaining.y, - w: len, - h: thickness, - } - } else { - Frame { - x: remaining.x, - y: remaining.y + offset, - w: thickness, - h: len, - } - }; - offset += len; - if frame.area() >= min_area { - emit(tree, id, frame, depth, opts, visible, out); - } - } - - if horizontal { - remaining.y += thickness; - remaining.h -= thickness; - } else { - remaining.x += thickness; - remaining.w -= thickness; - } -} - #[cfg(test)] mod tests { use super::*; @@ -354,8 +377,7 @@ mod tests { TreemapOptions { min_area_px: 0.0, padding_px: 0.0, - max_depth: 32, - hide_system: false, + ..Default::default() } } @@ -485,10 +507,8 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_area_px: 0.0, padding_px: 2.0, - max_depth: 32, - hide_system: false, + ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); @@ -506,9 +526,7 @@ mod tests { let tree = flat_tree(&[("big", 1_000_000), ("tiny", 1)]); let opts = TreemapOptions { min_area_px: 1.0, - padding_px: 0.0, - max_depth: 32, - hide_system: false, + ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); @@ -542,15 +560,18 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_area_px: 0.0, - padding_px: 0.0, max_depth: 1, - hide_system: false, + ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); assert!(rects.iter().any(|r| r.id == 1), "depth-1 dir emitted"); assert!(rects.iter().all(|r| r.id != 2), "depth-2 file not emitted"); + assert_eq!(area(&rect_of(&rects, 1)), 10_000.0); + + let drilled = layout(&tree, 1, Viewport { w: 100.0, h: 100.0 }, &opts); + assert_eq!(rect_of(&drilled, 2).depth, 1); + assert_eq!(area(&rect_of(&drilled, 2)), 10_000.0); } #[test] @@ -704,4 +725,160 @@ mod tests { assert!((area(&rect_of(&hidden, 1)) - 10_000.0).abs() < 1.0); assert!((area(&rect_of(&hidden, 2)) - 10_000.0).abs() < 1.0); } + + /// root { x { xa 300, y { ya 100 } }, z { za 400 }, w 200 } + fn open_tree() -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("x", entry(1, 0, DIR, 0)); + b.push("xa", entry(2, 1, FILE, 300)); + b.push("y", entry(3, 1, DIR, 0)); + b.push("ya", entry(4, 3, FILE, 100)); + b.push("z", entry(5, 0, DIR, 0)); + b.push("za", entry(6, 5, FILE, 400)); + b.push("w", entry(7, 0, FILE, 200)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + + fn capped(max_depth: u8, reveal: Option) -> TreemapOptions { + TreemapOptions { + max_depth, + reveal, + ..no_padding() + } + } + + const VIEW: Viewport = Viewport { w: 100.0, h: 100.0 }; + + fn geometry(rects: &[TreemapRect]) -> Vec<(NodeId, [u32; 4], u8)> { + rects + .iter() + .map(|r| (r.id, [r.x, r.y, r.w, r.h].map(f32::to_bits), r.depth)) + .collect() + } + + #[test] + fn revealing_a_folder_adds_its_children_and_moves_nothing() { + let tree = open_tree(); + let closed = layout(&tree, 0, VIEW, &capped(1, None)); + let opened = layout(&tree, 0, VIEW, &capped(1, Some(1))); + + let before = geometry(&closed); + let after = geometry(&opened); + for g in &before { + assert!(after.contains(g), "rect {} moved or vanished", g.0); + } + let mut added: Vec<_> = after.iter().filter(|g| !before.contains(g)).collect(); + added.sort_by_key(|g| g.0); + assert_eq!(added.len(), 2); + assert_eq!((added[0].0, added[0].2), (2, 2)); + assert_eq!((added[1].0, added[1].2), (3, 2)); + assert!(!rect_of(&closed, 1).expanded); + assert!(rect_of(&opened, 1).expanded); + } + + #[test] + fn revealing_a_nested_folder_keeps_its_parent_open() { + let tree = open_tree(); + let rects = layout(&tree, 0, VIEW, &capped(1, Some(3))); + + assert!(rects.iter().any(|r| r.id == 2), "x stays open"); + assert!(rects.iter().any(|r| r.id == 4), "y opens"); + assert!(rects.iter().all(|r| r.id != 6), "z stays closed"); + } + + #[test] + fn revealing_a_file_opens_the_folders_above_it() { + let tree = open_tree(); + let rects = layout(&tree, 0, VIEW, &capped(1, Some(4))); + + assert!(rects.iter().any(|r| r.id == 4), "ya drawn"); + assert!(rect_of(&rects, 1).expanded); + assert!(rect_of(&rects, 3).expanded); + assert!(!rect_of(&rects, 5).expanded); + } + + #[test] + fn a_reveal_that_is_not_hidden_under_the_view_changes_nothing() { + let tree = open_tree(); + let closed = layout(&tree, 1, VIEW, &capped(1, None)); + + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(5))), + closed, + "other branch" + ); + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(999))), + closed, + "dead id" + ); + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(2))), + closed, + "visible file" + ); + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(1))), + closed, + "the root" + ); + let wide = layout(&tree, 0, VIEW, &capped(2, None)); + assert_eq!( + layout(&tree, 0, VIEW, &capped(2, Some(1))), + wide, + "already open" + ); + } + + #[test] + fn only_folders_with_children_drawn_are_expanded() { + let tree = open_tree(); + let rects = layout(&tree, 0, VIEW, &capped(1, None)); + + assert!(rect_of(&rects, 0).expanded, "root"); + assert!(!rect_of(&rects, 1).expanded, "x at the cap"); + assert!(!rect_of(&rects, 7).expanded, "file"); + } + + #[test] + fn nothing_is_drawn_past_the_hard_depth_limit() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + for id in 1..40 { + b.push("d", entry(id, id - 1, DIR, 0)); + } + b.push("f", entry(40, 39, FILE, 1)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let deepest_closed = layout(&tree, 0, VIEW, &capped(32, None)) + .iter() + .map(|r| r.depth) + .max(); + let deepest_open = layout(&tree, 0, VIEW, &capped(1, Some(39))) + .iter() + .map(|r| r.depth) + .max(); + + assert_eq!(deepest_closed, Some(MAX_LAYOUT_DEPTH)); + assert_eq!(deepest_open, Some(MAX_LAYOUT_DEPTH)); + } + + #[test] + fn an_open_folder_shows_only_what_the_filter_keeps() { + let tree = open_tree(); + // root, x, xa, y(out), ya(out), z, za, w + let bytes = vec![900u64, 300, 300, 0, 0, 400, 400, 200]; + let rects = layout_with_filter(&tree, 0, VIEW, &capped(1, Some(1)), &bytes); + + assert!( + rects.iter().all(|r| r.id != 3), + "filtered-out folder omitted" + ); + assert_eq!(area(&rect_of(&rects, 2)), area(&rect_of(&rects, 1))); + } } diff --git a/crates/scanner-ntfs/src/record.rs b/crates/scanner-ntfs/src/record.rs index aefd6a3..814ba4b 100644 --- a/crates/scanner-ntfs/src/record.rs +++ b/crates/scanner-ntfs/src/record.rs @@ -273,8 +273,10 @@ fn file_name(attr: &[u8], arena: &mut String, facts: &mut RecordFacts) -> Result if facts.name.is_none_or(|n| rank < n.rank) { let off = arena.len() as u32; let units = v[0x42..0x42 + 2 * chars] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])); + .as_chunks::<2>() + .0 + .iter() + .map(|&c| u16::from_le_bytes(c)); for r in char::decode_utf16(units) { arena.push(r.unwrap_or(char::REPLACEMENT_CHARACTER)); } diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 1f4f8a2..be1518b 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -16,7 +16,7 @@ use std::time::{Duration, Instant}; use mathom_core::export::ExportOptions; use mathom_core::search::{FilterOverlay, SearchQuery}; use mathom_core::tree::{NodeId, Tree}; -use mathom_core::{EntryFlags, TreeBuilder, TreemapOptions, Viewport, treemap}; +use mathom_core::{EntryFlags, MAX_LAYOUT_DEPTH, TreeBuilder, TreemapOptions, Viewport, treemap}; use mathom_scanner::{GenericScanner, ScanEvent, ScanHandle, ScanOptions, Scanner}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; @@ -168,6 +168,22 @@ pub struct TreemapRectDto { depth: u8, is_dir: bool, category: u8, + expanded: bool, + /// Only on folded directories under a depth cap, the ones the UI labels. + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TreemapArgs { + width: f32, + height: f32, + hide_system: bool, + filter: Option, + /// `None` draws every level. + max_depth: Option, + reveal: Option, } #[derive(Clone, Serialize)] @@ -343,10 +359,7 @@ pub fn get_treemap( state: State<'_, AppState>, generation: u64, root_id: NodeId, - width: f32, - height: f32, - hide_system: bool, - filter: Option, + args: TreemapArgs, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -360,14 +373,16 @@ pub fn get_treemap( let opts = TreemapOptions { min_area_px: 3.0, padding_px: 1.0, - max_depth: 24, - hide_system, + max_depth: args.max_depth.map_or(MAX_LAYOUT_DEPTH, |d| d.max(1)), + hide_system: args.hide_system, + reveal: args.reveal, }; + let capped = args.max_depth.is_some(); let viewport = Viewport { - w: width, - h: height, + w: args.width, + h: args.height, }; - let rects = match overlay_for(&session, tree, filter.as_deref(), hide_system) { + let rects = match overlay_for(&session, tree, args.filter.as_deref(), args.hide_system) { Some(o) => treemap::layout_with_filter(tree, root_id, viewport, &opts, &o.bytes), None => treemap::layout(tree, root_id, viewport, &opts), }; @@ -382,6 +397,8 @@ pub fn get_treemap( depth: r.depth, is_dir: r.is_dir, category: r.category, + expanded: r.expanded, + name: (capped && r.is_dir && !r.expanded).then(|| tree.name(r.id).to_owned()), }) .collect()) } @@ -399,8 +416,6 @@ pub struct TypeStatDto { #[serde(rename_all = "camelCase")] pub struct TypePanelData { types: Vec, - total_bytes: u64, - total_files: u64, top_files: Vec, } @@ -424,7 +439,7 @@ pub fn get_type_stats( let overlay = overlay_for(&session, tree, filter.as_deref(), hide_system); let visible = overlay.as_deref().map(|o| o.visible.as_slice()); let filtered = mathom_core::stats::type_breakdown(tree, root_id, hide_system, visible); - let (total_bytes, total_files) = (filtered.total_bytes, filtered.total_files); + let total_bytes = filtered.total_bytes; let top_files = mathom_core::stats::largest_files(tree, root_id, TOP_FILES, hide_system, visible) .into_iter() @@ -445,8 +460,6 @@ pub fn get_type_stats( files: t.files, }) .collect(), - total_bytes, - total_files, top_files, }) } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2039b87..e3cc8cd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -14,9 +14,14 @@ import { type Row, type SearchHit, type Snapshot, - type TreemapRect, } from "./lib/api"; import { copyText } from "./lib/clipboard"; +import { + loadTreemapPrefs, + saveTreemapPrefs, + treemapDepth, + type TreemapPrefs, +} from "./lib/prefs"; import { onUiError, reportUiError, reportUnlessStale } from "./lib/errors"; const TREE_PANE_MIN = 320; @@ -52,6 +57,7 @@ export default function App() { const [selected, setSelected] = useState(null); const [selectedPath, setSelectedPath] = useState(null); const [viewRootId, setViewRootId] = useState(0); + const [treemapPrefs, setTreemapPrefs] = useState(loadTreemapPrefs); const [hoveredId, setHoveredId] = useState(null); const [revealId, setRevealId] = useState(null); const [treeWidth, setTreeWidth] = useState(560); @@ -122,15 +128,14 @@ export default function App() { ); const handleTreemapSelect = useCallback( - (rect: TreemapRect) => { - select(rect.id); - if (rect.isDir) setViewRootId(rect.id); + (id: number) => { + select(id); if (generation === 0) return; api - .getAncestors(generation, rect.id) + .getAncestors(generation, id) .then((chain) => { expandMany(chain.slice(0, -1).map((c) => c.id)); - setRevealId(rect.id); + setRevealId(id); }) .catch((e) => reportUnlessStale("revealing selection", e)); }, @@ -177,6 +182,11 @@ export default function App() { setViewRootId(id); }, []); + const handleTreemapPrefs = useCallback((prefs: TreemapPrefs) => { + setTreemapPrefs(prefs); + saveTreemapPrefs(prefs); + }, []); + const handleRevealed = useCallback(() => setRevealId(null), []); const handleContextMenu = useCallback( @@ -201,7 +211,13 @@ export default function App() { // Only a pure `ext:` filter is treated as the active type list; other queries start fresh. const { filter } = scan; const activeExts = useMemo( - () => filter?.match(/^ext:([^\s]+)$/)?.[1].split(",") ?? [], + () => + filter + ?.trim() + .match(/^ext:([^\s]+)$/)?.[1] + .split(",") + .map((ext) => ext.replace(/^\.+/, "").toLowerCase()) + .filter(Boolean) ?? [], [filter], ); @@ -357,6 +373,7 @@ export default function App() { viewRootId={viewRootId} startError={scan.startError} hideSystem={scan.hideSystem} + treemapPrefs={treemapPrefs} filter={scan.filter} typePanelOpen={typePanelOpen} themePref={theme.pref} @@ -364,6 +381,7 @@ export default function App() { onScan={handleScan} onCancel={scan.cancel} onToggleHideSystem={scan.toggleHideSystem} + onTreemapPrefs={handleTreemapPrefs} onToggleTypePanel={() => setTypePanelOpen((v) => !v)} onSearchSelect={handleSearchSelect} onApplyFilter={scan.setFilter} @@ -422,6 +440,8 @@ export default function App() { themeRev={theme.themeRev} hideSystem={scan.hideSystem} filter={scan.filter} + maxDepth={treemapDepth(treemapPrefs)} + openInPlace={treemapPrefs.style === "spacesniffer"} selected={selected} hoveredId={hoveredId} onSelect={handleTreemapSelect} diff --git a/ui/src/components/DepthPicker.tsx b/ui/src/components/DepthPicker.tsx new file mode 100644 index 0000000..7e40bd7 --- /dev/null +++ b/ui/src/components/DepthPicker.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; + +interface DepthPickerProps { + /** null means every level. */ + value: number | null; + presets: readonly (number | null)[]; + /** What the custom box holds while a preset is chosen. */ + custom: number; + max: number; + title?: string; + onChange: (depth: number | null) => void; + onCustom: (depth: number) => void; +} + +export function DepthPicker({ + value, + presets, + custom, + max, + title, + onChange, + onCustom, +}: DepthPickerProps) { + const isCustom = !presets.includes(value); + const firstCustom = Math.max(...presets.map((d) => d ?? 0)) + 1; + const [text, setText] = useState(String(custom)); + + const pick = (n: number) => { + const depth = Math.min(n, max); + onCustom(depth); + onChange(depth); + }; + + return ( +
+ {presets.map((d) => ( + + ))} + pick(custom)} + onChange={(e) => { + const digits = e.target.value + .replace(/\D/g, "") + .slice(0, String(max).length); + setText(digits); + if (Number(digits) >= 1) pick(Number(digits)); + }} + onBlur={() => setText(String(custom))} + placeholder={`${firstCustom}+`} + title={`Any depth up to ${max}`} + className={`h-6 w-0 min-w-0 flex-1 rounded text-center text-[12px] outline-none placeholder:text-ink-5 ${ + isCustom + ? "bg-raised text-ink" + : "bg-transparent text-ink-4 hover:text-ink-2" + }`} + /> +
+ ); +} diff --git a/ui/src/components/ExportMenu.tsx b/ui/src/components/ExportMenu.tsx index 29d3f5a..9114337 100644 --- a/ui/src/components/ExportMenu.tsx +++ b/ui/src/components/ExportMenu.tsx @@ -2,9 +2,11 @@ import { useEffect, useRef, useState } from "react"; import { save } from "@tauri-apps/plugin-dialog"; import { api, type ExportFormat } from "../lib/api"; import { copyText } from "../lib/clipboard"; +import { DepthPicker } from "./DepthPicker"; import { ExportIcon } from "./icons"; -const DEPTHS = ["all", "1", "2"] as const; +const DEPTH_PRESETS = [null, 1, 2] as const; +const MAX_EXPORT_DEPTH = 999; interface ExportMenuProps { generation: number; @@ -21,9 +23,8 @@ export function ExportMenu({ }: ExportMenuProps) { const [open, setOpen] = useState(false); const [format, setFormat] = useState("csv"); - // "all", "1", "2", or whatever digits sit in the custom box. - const [depth, setDepth] = useState("all"); - const [custom, setCustom] = useState("3"); + const [depth, setDepth] = useState(null); + const [customDepth, setCustomDepth] = useState(3); const [dirsOnly, setDirsOnly] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState<{ ok: boolean; msg: string } | null>( @@ -51,7 +52,7 @@ export function ExportMenu({ setBusy(true); setStatus(null); const args = { - maxDepth: depth === "all" ? null : Math.max(1, Number(depth) || 1), + maxDepth: depth, dirsOnly, hideSystem, }; @@ -128,44 +129,14 @@ export function ExportMenu({
Depth
-
- {DEPTHS.map((d) => ( - - ))} - setDepth(custom)} - onChange={(e) => { - const v = e.target.value.replace(/\D/g, "").slice(0, 3); - setCustom(v); - setDepth(v); - }} - onBlur={() => { - if (/^[1-9]\d{0,2}$/.test(custom)) return; - setCustom("3"); - if (!DEPTHS.includes(depth as (typeof DEPTHS)[number])) { - setDepth("3"); - } - }} - placeholder="3+" - title="Any depth — type a number" - className={`h-6 w-0 min-w-0 flex-1 rounded text-center text-[12px] outline-none placeholder:text-ink-5 ${ - !DEPTHS.includes(depth as (typeof DEPTHS)[number]) - ? "bg-raised text-ink" - : "bg-transparent text-ink-4 hover:text-ink-2" - }`} - /> -
+ +
Treemap style
+
+ {STYLE_OPTIONS.map((opt) => ( + + ))} +
+
Treemap depth
+ onTreemapPrefs({ ...treemapPrefs, depth: d })} + onCustom={setCustomDepth} + />
Theme
diff --git a/ui/src/components/Toolbar.tsx b/ui/src/components/Toolbar.tsx index 0cb9404..28554bf 100644 --- a/ui/src/components/Toolbar.tsx +++ b/ui/src/components/Toolbar.tsx @@ -1,4 +1,5 @@ import type { SearchHit } from "../lib/api"; +import type { TreemapPrefs } from "../lib/prefs"; import type { AccentName, ThemePref } from "../lib/theme"; import { ExportMenu } from "./ExportMenu"; import { ScanMenu } from "./ScanMenu"; @@ -13,6 +14,7 @@ interface ToolbarProps { viewRootId: number; startError: string | null; hideSystem: boolean; + treemapPrefs: TreemapPrefs; filter: string | null; typePanelOpen: boolean; themePref: ThemePref; @@ -20,6 +22,7 @@ interface ToolbarProps { onScan: (path: string) => void; onCancel: () => void; onToggleHideSystem: () => void; + onTreemapPrefs: (prefs: TreemapPrefs) => void; onToggleTypePanel: () => void; onSearchSelect: (hit: SearchHit) => void; onApplyFilter: (query: string | null) => void; @@ -33,6 +36,7 @@ export function Toolbar({ viewRootId, startError, hideSystem, + treemapPrefs, filter, typePanelOpen, themePref, @@ -40,6 +44,7 @@ export function Toolbar({ onScan, onCancel, onToggleHideSystem, + onTreemapPrefs, onToggleTypePanel, onSearchSelect, onApplyFilter, @@ -100,6 +105,8 @@ export function Toolbar({ themePref={themePref} accent={accent} onToggleHideSystem={onToggleHideSystem} + treemapPrefs={treemapPrefs} + onTreemapPrefs={onTreemapPrefs} onThemePref={onThemePref} onAccent={onAccent} /> diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 7af81ef..a995a1e 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -30,6 +30,7 @@ import { PALETTE, canvasColors } from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; +const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; // Grain tile for directory plates. By the layout's contract, culled children // still consume their share of space, so every bare plate pixel is real bytes @@ -88,6 +89,22 @@ function snap(r: TreemapRect, dpr: number, gap: number): Snapped { return { x: x0, y: y0, w: x1 - x0 - gap, h: y1 - y0 - gap }; } +// Pre-order: the nearest shallower rect before a child is its parent. +function parentOf( + rects: TreemapRect[], + child: TreemapRect, +): TreemapRect | undefined { + for (let i = rects.indexOf(child) - 1; i >= 0; i--) { + if (rects[i].depth < child.depth) return rects[i]; + } + return undefined; +} + +const isFolded = (r: TreemapRect) => r.isDir && !r.expanded; + +const folderOf = (rects: TreemapRect[], r: TreemapRect) => + r.isDir ? r : parentOf(rects, r); + interface TooltipData { name: string; size: string; @@ -106,9 +123,13 @@ export interface TreemapProps { hideSystem: boolean; /** Active view filter (search grammar) or null. */ filter: string | null; + /** Levels drawn below the view root; null draws them all. */ + maxDepth: number | null; + /** Clicking a folder opens it in place instead of zooming into it. */ + openInPlace: boolean; selected: number | null; hoveredId: number | null; - onSelect: (rect: TreemapRect) => void; + onSelect: (id: number) => void; onHover: (id: number | null) => void; onNavigate: (id: number) => void; onContext: (id: number, x: number, y: number, zoom: ZoomTargets) => void; @@ -128,6 +149,8 @@ export function Treemap({ themeRev, hideSystem, filter, + maxDepth, + openInPlace, selected, hoveredId, onSelect, @@ -155,6 +178,11 @@ export function Treemap({ const tooltipSeqRef = useRef(0); const tooltipTimerRef = useRef(0); const lastMouseRef = useRef({ x: 0, y: 0 }); + // The clicked node the layout keeps open past the depth cap, until the view moves. + const openRef = useRef(null); + // Just clicked open; zoom instead if nothing inside is big enough to draw. + const pendingOpenRef = useRef(null); + const layoutCappedRef = useRef(false); const generationRef = useRef(generation); generationRef.current = generation; @@ -166,6 +194,12 @@ export function Treemap({ hideSystemRef.current = hideSystem; const filterRef = useRef(filter); filterRef.current = filter; + const maxDepthRef = useRef(maxDepth); + maxDepthRef.current = maxDepth; + const openInPlaceRef = useRef(openInPlace); + openInPlaceRef.current = openInPlace; + const onNavigateRef = useRef(onNavigate); + onNavigateRef.current = onNavigate; const [crumbs, setCrumbs] = useState([]); const [tooltip, setTooltip] = useState(null); @@ -195,15 +229,17 @@ export function Treemap({ }; outline(selectedRef.current, theme.selection, 2); const hovered = hoveredIdRef.current; - if (hovered !== null && hovered !== selectedRef.current) { - if (hovered === rootIdRef.current || crumbIdsRef.current.has(hovered)) { - ctx.strokeStyle = theme.hoverRing; - ctx.lineWidth = 3; - ctx.strokeRect(1.5, 1.5, overlay.width - 3, overlay.height - 3); - } else { - outline(hovered, theme.hoverRing, 2); - } + if (hovered === null) return; + if (hovered === rootIdRef.current || crumbIdsRef.current.has(hovered)) { + ctx.strokeStyle = theme.hoverRing; + ctx.lineWidth = 3; + ctx.strokeRect(1.5, 1.5, overlay.width - 3, overlay.height - 3); + return; } + const r = byIdRef.current.get(hovered); + const folder = r && parentOf(rectsRef.current, r); + if (folder) outline(folder.id, theme.hoverRing, 1); + if (hovered !== selectedRef.current) outline(hovered, theme.hoverRing, 2); }, []); const blit = useCallback(() => { @@ -248,9 +284,11 @@ export function Treemap({ getGrainTile(theme.plate, theme.plateGrain), "repeat", )!; + const capped = layoutCappedRef.current; + const closed = (r: TreemapRect) => capped && isFolded(r); ctx.beginPath(); for (const r of rects) { - if (!r.isDir) continue; + if (!r.isDir || closed(r)) continue; const s = snap(r, dpr, 0); if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); } @@ -272,6 +310,15 @@ export function Treemap({ ctx.fill(); } + ctx.fillStyle = theme.folder; + ctx.beginPath(); + for (const r of rects) { + if (!closed(r)) continue; + const s = snap(r, dpr, 1); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); + } + ctx.fill(); + const sprite = getHighlightSprite(); for (const r of rects) { if (r.isDir) continue; @@ -279,6 +326,21 @@ export function Treemap({ if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } + ctx.font = `${12 * dpr}px ${LABEL_FONT}`; + ctx.textBaseline = "top"; + for (const r of rects) { + if (!closed(r) || !r.name) continue; + const s = snap(r, dpr, 1); + if (s.w < 48 * dpr || s.h < 24 * dpr) continue; + ctx.save(); + ctx.beginPath(); + ctx.rect(s.x + 6 * dpr, s.y, s.w - 12 * dpr, s.h); + ctx.clip(); + ctx.fillStyle = theme.selection; + ctx.fillText(r.name, s.x + 6 * dpr, s.y + 6 * dpr); + ctx.restore(); + } + if (zoomRafRef.current === 0) blit(); }, [blit]); @@ -320,21 +382,29 @@ export function Treemap({ const forRoot = rootIdRef.current; lastFetchRef.current = performance.now(); try { - const rects = await api.getTreemap( - generation, - forRoot, - w, - h, - hideSystemRef.current, - filterRef.current, - ); + const maxDepth = maxDepthRef.current; + const reveal = maxDepth === null ? null : openRef.current; + const rects = await api.getTreemap(generation, forRoot, { + width: w, + height: h, + hideSystem: hideSystemRef.current, + filter: filterRef.current, + maxDepth, + reveal, + }); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; + layoutCappedRef.current = maxDepth !== null; byIdRef.current = new Map(rects.map((r) => [r.id, r])); hitFrozenRef.current = false; setHasRects(rects.length > 0); bake(); if (crumbsRootRef.current !== forRoot) refreshCrumbs(); + const pending = pendingOpenRef.current; + if (pending === null || pending !== reveal) return; + pendingOpenRef.current = null; + const opened = byIdRef.current.get(pending); + if (opened && isFolded(opened)) onNavigateRef.current(opened.id); } catch (e) { reportUnlessStale("loading treemap", e); if (seq === fetchSeqRef.current) hitFrozenRef.current = false; @@ -345,6 +415,8 @@ export function Treemap({ (id: number) => { if (id === rootIdRef.current) return; const zoomFrom = byIdRef.current.get(id); + openRef.current = null; + pendingOpenRef.current = null; rootIdRef.current = id; hitFrozenRef.current = true; setTooltip(null); @@ -402,6 +474,8 @@ export function Treemap({ hitFrozenRef.current = false; crumbsRootRef.current = null; crumbIdsRef.current = new Set(); + openRef.current = null; + pendingOpenRef.current = null; offscreenRef.current = null; const base = baseRef.current; if (base) base.getContext("2d")!.clearRect(0, 0, base.width, base.height); @@ -422,8 +496,11 @@ export function Treemap({ }, [themeRev, bake]); useEffect(() => { + // An open folder belongs to the layout it was clicked in. + openRef.current = null; + pendingOpenRef.current = null; void fetchLayout(); - }, [hideSystem, filter, fetchLayout]); + }, [hideSystem, filter, maxDepth, fetchLayout]); const prevStateRef = useRef(undefined); useEffect(() => { @@ -578,11 +655,22 @@ export function Treemap({ const handleClick = useCallback( (e: React.MouseEvent) => { + // The second click of a double-click would land on what the first revealed. + if (e.detail > 1) return; const bounds = containerRef.current!.getBoundingClientRect(); const hit = hitTest(e.clientX - bounds.left, e.clientY - bounds.top); - if (hit) onSelect(hit); + if (!hit) return; + onSelect(hit.id); + if (!openInPlaceRef.current && hit.isDir) { + onNavigate(hit.id); + return; + } + if (maxDepthRef.current === null || openRef.current === hit.id) return; + openRef.current = hit.id; + pendingOpenRef.current = hit.id; + void fetchLayout(); }, - [hitTest, onSelect], + [hitTest, onSelect, onNavigate, fetchLayout], ); const handleContextMenu = useCallback( @@ -601,15 +689,17 @@ export function Treemap({ [hitTest, regionAt, crumbs, onContext], ); - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - if (zoomRafRef.current !== 0) return; - const bounds = containerRef.current!.getBoundingClientRect(); - const region = regionAt(e.clientX - bounds.left, e.clientY - bounds.top); - if (region) onNavigate(region.id); - }, - [regionAt, onNavigate], - ); + const selectedFolder = useCallback(() => { + const id = selectedRef.current; + const r = id === null ? undefined : byIdRef.current.get(id); + return r && folderOf(rectsRef.current, r); + }, []); + + const handleDoubleClick = useCallback(() => { + if (zoomRafRef.current !== 0) return; + const target = selectedFolder(); + if (target) onNavigate(target.id); + }, [selectedFolder, onNavigate]); const zoomOut = useCallback(() => { if (crumbs.length < 2 || hitFrozenRef.current) return; diff --git a/ui/src/components/TypePanel.tsx b/ui/src/components/TypePanel.tsx index 8a948b9..86f0b83 100644 --- a/ui/src/components/TypePanel.tsx +++ b/ui/src/components/TypePanel.tsx @@ -107,7 +107,7 @@ export function TypePanel({ Appears during a scan ) : null - ) : data.totalFiles === 0 ? ( + ) : data.types.length === 0 ? (
No files here
) : ( sum + type.bytes, + 0, + ), activeExts, canFilter, onFilterType, @@ -153,7 +156,7 @@ export function TypePanel({ interface TypeRowsProps { types: TypeStat[]; - totalBytes: number; + displayedBytes: number; activeExts: string[]; canFilter: boolean; onFilterType: (ext: string, additive: boolean) => void; @@ -163,7 +166,7 @@ function TypeRow({ index, style, types, - totalBytes, + displayedBytes, activeExts, canFilter, onFilterType, @@ -202,7 +205,7 @@ function TypeRow({ {formatBytes(t.bytes)} - {formatPercent(totalBytes > 0 ? t.bytes / totalBytes : 0)} + {formatPercent(displayedBytes > 0 ? t.bytes / displayedBytes : 0)} ); diff --git a/ui/src/index.css b/ui/src/index.css index 0a77bfb..f00477e 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -11,6 +11,7 @@ --color-edge-strong: #3f3f46; --color-plate: #30323a; /* treemap directory plate */ --color-plate-grain: #454a57; /* dots on bare plate: culled-content texture */ + --color-folder: #6b6146; /* treemap folder closed by the depth limit */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -46,6 +47,7 @@ --color-edge-strong: #d4d4d8; --color-plate: #dcdce0; --color-plate-grain: #bfc0c8; + --color-folder: #d9cda3; --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 3a7e806..7363e6a 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -46,6 +46,21 @@ export interface TreemapRect { depth: number; isDir: boolean; category: number; + /** A directory whose children were laid out. */ + expanded: boolean; + /** Only on folded directories under a depth cap. */ + name?: string; +} + +export interface TreemapArgs { + width: number; + height: number; + hideSystem: boolean; + filter: string | null; + /** null draws every level. */ + maxDepth: number | null; + /** Kept visible past maxDepth. */ + reveal: number | null; } export interface Crumb { @@ -81,8 +96,6 @@ export interface TypeStat { export interface TypePanelData { /** Every extension in the subtree, largest first. */ types: TypeStat[]; - totalBytes: number; - totalFiles: number; topFiles: Row[]; } @@ -152,22 +165,8 @@ export const api = { invoke("get_node", { generation, id }), getPath: (generation: number, id: number) => invoke("get_path", { generation, id }), - getTreemap: ( - generation: number, - rootId: number, - width: number, - height: number, - hideSystem: boolean, - filter: string | null, - ) => - invoke("get_treemap", { - generation, - rootId, - width, - height, - hideSystem, - filter, - }), + getTreemap: (generation: number, rootId: number, args: TreemapArgs) => + invoke("get_treemap", { generation, rootId, args }), getTypeStats: ( generation: number, rootId: number, diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 350de0d..08d4f72 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -22,6 +22,7 @@ export function canvasColors() { background: v("--color-app"), plate: v("--color-plate"), plateGrain: v("--color-plate-grain"), + folder: v("--color-folder"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; diff --git a/ui/src/lib/prefs.ts b/ui/src/lib/prefs.ts new file mode 100644 index 0000000..029ac75 --- /dev/null +++ b/ui/src/lib/prefs.ts @@ -0,0 +1,35 @@ +const STYLE_KEY = "mathom:treemap-style"; +const DEPTH_KEY = "mathom:treemap-depth"; + +/** Mirrors mathom-core's MAX_LAYOUT_DEPTH. */ +export const MAX_TREEMAP_DEPTH = 24; + +/** Default zooms into a clicked folder; SpaceSniffer opens it in place. */ +export type TreemapStyle = "default" | "spacesniffer"; + +export interface TreemapPrefs { + style: TreemapStyle; + /** null draws every level. */ + depth: number | null; +} + +/** SpaceSniffer needs something folded to open; a stored All is kept for Default. */ +export function treemapDepth(prefs: TreemapPrefs): number | null { + return prefs.style === "spacesniffer" ? (prefs.depth ?? 1) : prefs.depth; +} + +export function loadTreemapPrefs(): TreemapPrefs { + const n = Number(localStorage.getItem(DEPTH_KEY)); + return { + style: + localStorage.getItem(STYLE_KEY) === "spacesniffer" + ? "spacesniffer" + : "default", + depth: Number.isInteger(n) && n >= 1 && n <= MAX_TREEMAP_DEPTH ? n : null, + }; +} + +export function saveTreemapPrefs(prefs: TreemapPrefs) { + localStorage.setItem(STYLE_KEY, prefs.style); + localStorage.setItem(DEPTH_KEY, String(prefs.depth ?? "all")); +}