Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions crates/core/src/treemap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,62 @@ mod tests {
assert!(rects.iter().all(|r| r.id != 2), "depth-2 file not emitted");
}

/// A cap hides deeper rects without moving the ones that survive: the
/// capped layout is exactly the prefix of a deeper one. The UI can change
/// depth without reflowing a single rectangle.
#[test]
fn a_depth_cap_only_hides_deeper_rects() {
let tree = nested_tree();
let vp = Viewport { w: 400.0, h: 300.0 };

let capped = layout(&tree, 0, vp, &capped_at(2));
let deep: Vec<TreemapRect> = layout(&tree, 0, vp, &capped_at(8))
.into_iter()
.filter(|r| r.depth <= 2)
.collect();

assert_eq!(capped, deep);
assert!(capped.iter().any(|r| r.id == 3), "depth-2 dir is emitted");
assert!(capped.iter().all(|r| r.id != 5), "depth-3 dir is not");
}

/// Depth counts from the laid-out root, not the scan root — otherwise
/// drilling into a folder would leave its own children permanently out of
/// reach.
#[test]
fn depth_counts_from_the_laid_out_root() {
let tree = nested_tree();
let vp = Viewport { w: 400.0, h: 300.0 };

let one = layout(&tree, 1, vp, &capped_at(1));
assert!(one.iter().any(|r| r.id == 3), "a child of the root shows");
assert!(one.iter().all(|r| r.id != 5), "a grandchild does not");
}

/// root { d1 { d2 { d3 { f_c 50 } + f_b 100 } + f_a 200 } + f_big 400 },
/// so d1..d3 sit at depths 1..3 with f_c at 4.
fn nested_tree() -> Tree {
let mut b = EntryBatch::default();
b.push("root", entry(0, 0, DIR, 0));
b.push("d1", entry(1, 0, DIR, 0));
b.push("f_big", entry(2, 0, FILE, 400));
b.push("d2", entry(3, 1, DIR, 0));
b.push("f_a", entry(4, 1, FILE, 200));
b.push("d3", entry(5, 3, DIR, 0));
b.push("f_b", entry(6, 3, FILE, 100));
b.push("f_c", entry(7, 5, FILE, 50));
let mut builder = TreeBuilder::new();
builder.add_batch(&b);
builder.finish()
}

fn capped_at(max_depth: u8) -> TreemapOptions {
TreemapOptions {
max_depth,
..no_padding()
}
}

#[test]
fn drill_down_layouts_from_a_subdirectory() {
let mut b = EntryBatch::default();
Expand Down
42 changes: 41 additions & 1 deletion src-tauri/src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,29 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu
Ok(tree.path(id))
}

/// The deepest the treemap lays out. Each level insets its children by
/// 2×`padding_px`, so a real viewport runs out of pixels well before this.
/// Mirrored as `LAYOUT_ALL_DEPTH` in `ui/src/lib/prefs.ts`.
const MAX_TREEMAP_DEPTH: u8 = 24;

/// Resolves the UI's `maxDepth` preference into a layout cap, relative to the
/// laid-out root. `None` is "as deep as the layout goes" — the pre-existing
/// behaviour.
///
/// The floor of 1 is a contract with the UI, not a nicety: at a cap of 0 no
/// rect has depth 1, and double-click, wheel and the context menu's zoom-in all
/// hit-test `depth == 1` rects, so every way in but the breadcrumbs would go
/// dead. The ceiling keeps the recursion — and the IPC payload — bounded on
/// hostile input.
fn treemap_max_depth(requested: Option<u32>) -> u8 {
requested.map_or(MAX_TREEMAP_DEPTH, |depth| {
depth.clamp(1, MAX_TREEMAP_DEPTH as u32) as u8
})
}

// The argument list mirrors the UI's query; grouping it into a struct would
// only move the same list one level down.
#[allow(clippy::too_many_arguments)]
#[tauri::command(async)]
pub fn get_treemap(
state: State<'_, AppState>,
Expand All @@ -347,6 +370,7 @@ pub fn get_treemap(
height: f32,
hide_system: bool,
filter: Option<String>,
max_depth: Option<u32>,
) -> Result<Vec<TreemapRectDto>, String> {
let session = session_for(&state, generation)?;
let builder = session.builder.read().unwrap();
Expand All @@ -360,7 +384,7 @@ pub fn get_treemap(
let opts = TreemapOptions {
min_area_px: 3.0,
padding_px: 1.0,
max_depth: 24,
max_depth: treemap_max_depth(max_depth),
hide_system,
};
let viewport = Viewport {
Expand Down Expand Up @@ -964,6 +988,22 @@ mod tests {
assert!(!filters_by_extension_only(None));
}

#[test]
fn a_requested_depth_is_clamped_into_the_layout_contract() {
assert_eq!(treemap_max_depth(None), MAX_TREEMAP_DEPTH);
assert_eq!(treemap_max_depth(Some(1)), 1);
assert_eq!(treemap_max_depth(Some(3)), 3);
assert_eq!(
treemap_max_depth(Some(MAX_TREEMAP_DEPTH as u32)),
MAX_TREEMAP_DEPTH
);

// 0 would leave the UI's depth-1 hit-tests with nothing to hit; the
// ceiling bounds recursion on input the UI would never send itself.
assert_eq!(treemap_max_depth(Some(0)), 1);
assert_eq!(treemap_max_depth(Some(u32::MAX)), MAX_TREEMAP_DEPTH);
}

#[test]
fn deletes_are_blocked_while_scanning() {
assert!(delete_block_reason(ScanState::Scanning, "C:\\Users\\me\\big.iso").is_some());
Expand Down
13 changes: 13 additions & 0 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import {
} from "./lib/api";
import { copyText } from "./lib/clipboard";
import { onUiError, reportUiError, reportUnlessStale } from "./lib/errors";
import {
type DepthPref,
loadTreemapDepth,
saveTreemapDepth,
} from "./lib/prefs";

const TREE_PANE_MIN = 320;
const TREEMAP_PANE_MIN = 280;
Expand Down Expand Up @@ -56,6 +61,7 @@ export default function App() {
const [revealId, setRevealId] = useState<number | null>(null);
const [treeWidth, setTreeWidth] = useState(560);
const [typePanelOpen, setTypePanelOpen] = useState(true);
const [maxDepth, setMaxDepth] = useState<DepthPref>(loadTreemapDepth);
const [uiError, setUiError] = useState<string | null>(null);
const [menu, setMenu] = useState<{
x: number;
Expand All @@ -80,6 +86,10 @@ export default function App() {
.catch(() => setElevation(null)); // unknown — don't nag
}, []);

useEffect(() => {
saveTreemapDepth(maxDepth);
}, [maxDepth]);

useEffect(() => {
let timer = 0;
const off = onUiError((msg) => {
Expand Down Expand Up @@ -359,6 +369,7 @@ export default function App() {
hideSystem={scan.hideSystem}
filter={scan.filter}
typePanelOpen={typePanelOpen}
maxDepth={maxDepth}
themePref={theme.pref}
accent={theme.accent}
onScan={handleScan}
Expand All @@ -367,6 +378,7 @@ export default function App() {
onToggleTypePanel={() => setTypePanelOpen((v) => !v)}
onSearchSelect={handleSearchSelect}
onApplyFilter={scan.setFilter}
onMaxDepth={setMaxDepth}
onThemePref={theme.setPref}
onAccent={theme.setAccent}
/>
Expand Down Expand Up @@ -422,6 +434,7 @@ export default function App() {
themeRev={theme.themeRev}
hideSystem={scan.hideSystem}
filter={scan.filter}
maxDepth={maxDepth}
selected={selected}
hoveredId={hoveredId}
onSelect={handleTreemapSelect}
Expand Down
35 changes: 33 additions & 2 deletions ui/src/components/SettingsMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { DEPTH_OPTIONS, type DepthPref } from "../lib/prefs";
import {
ACCENTS,
type AccentName,
Expand All @@ -15,18 +16,22 @@ const THEME_OPTIONS: { value: ThemePref; label: string }[] = [

interface SettingsMenuProps {
hideSystem: boolean;
maxDepth: DepthPref;
themePref: ThemePref;
accent: AccentName;
onToggleHideSystem: () => void;
onMaxDepth: (depth: DepthPref) => void;
onThemePref: (pref: ThemePref) => void;
onAccent: (accent: AccentName) => void;
}

export function SettingsMenu({
hideSystem,
maxDepth,
themePref,
accent,
onToggleHideSystem,
onMaxDepth,
onThemePref,
onAccent,
}: SettingsMenuProps) {
Expand All @@ -53,8 +58,8 @@ export function SettingsMenu({
<div ref={boxRef} className="relative shrink-0">
<button
onClick={() => setOpen((v) => !v)}
title="Appearance"
aria-label="Appearance"
title="Settings"
aria-label="Settings"
className={`ml-1 flex h-8 w-8 items-center justify-center rounded-md border ${
open
? "border-edge-strong bg-raised text-ink"
Expand All @@ -80,6 +85,32 @@ export function SettingsMenu({
/>
Hide system files
</label>
<div
className="mt-3 text-[11px] font-medium tracking-wide text-ink-4 uppercase"
title="Levels the treemap expands below the folder in view. Click a block to go deeper."
>
Treemap depth
</div>
<div className="mt-1.5 flex rounded-md border border-edge p-0.5">
{DEPTH_OPTIONS.map((opt) => (
<button
key={opt.label}
onClick={() => onMaxDepth(opt.value)}
title={
opt.value === null
? "Expand every level"
: `Expand ${opt.label} level${opt.value > 1 ? "s" : ""} below the folder in view`
}
className={`h-6 flex-1 rounded text-[12px] ${
maxDepth === opt.value
? "bg-raised text-ink"
: "text-ink-4 hover:text-ink-2"
}`}
>
{opt.label}
</button>
))}
</div>
<div className="mt-3 text-[11px] font-medium tracking-wide text-ink-4 uppercase">
Theme
</div>
Expand Down
7 changes: 7 additions & 0 deletions ui/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SearchHit } from "../lib/api";
import type { DepthPref } from "../lib/prefs";
import type { AccentName, ThemePref } from "../lib/theme";
import { ExportMenu } from "./ExportMenu";
import { ScanMenu } from "./ScanMenu";
Expand All @@ -15,6 +16,7 @@ interface ToolbarProps {
hideSystem: boolean;
filter: string | null;
typePanelOpen: boolean;
maxDepth: DepthPref;
themePref: ThemePref;
accent: AccentName;
onScan: (path: string) => void;
Expand All @@ -23,6 +25,7 @@ interface ToolbarProps {
onToggleTypePanel: () => void;
onSearchSelect: (hit: SearchHit) => void;
onApplyFilter: (query: string | null) => void;
onMaxDepth: (depth: DepthPref) => void;
onThemePref: (pref: ThemePref) => void;
onAccent: (accent: AccentName) => void;
}
Expand All @@ -35,6 +38,7 @@ export function Toolbar({
hideSystem,
filter,
typePanelOpen,
maxDepth,
themePref,
accent,
onScan,
Expand All @@ -43,6 +47,7 @@ export function Toolbar({
onToggleTypePanel,
onSearchSelect,
onApplyFilter,
onMaxDepth,
onThemePref,
onAccent,
}: ToolbarProps) {
Expand Down Expand Up @@ -97,9 +102,11 @@ export function Toolbar({
/>
<SettingsMenu
hideSystem={hideSystem}
maxDepth={maxDepth}
themePref={themePref}
accent={accent}
onToggleHideSystem={onToggleHideSystem}
onMaxDepth={onMaxDepth}
onThemePref={onThemePref}
onAccent={onAccent}
/>
Expand Down
Loading