From 8feaf44a2a34d4e025440814178e7ea7645efa94 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 2 Oct 2026 08:02:32 +0100 Subject: [PATCH] Initial Go project structure, docs, build and CI --- .github/workflows/ci.yml | 30 ++++ .gitignore | 5 + ARCHITECTURE.md | 279 +++++++++++++++++++++++++++++++++ CHANGELOG.md | 8 + DEVELOPMENT.md | 41 +++++ Makefile | 44 ++++++ README.md | 86 ++++++++++ ROADMAP.md | 47 ++++++ go.mod | 8 + go.sum | 4 + internal/brand/brand.go | 9 ++ internal/config/config.go | 72 +++++++++ internal/textutil/text.go | 125 +++++++++++++++ internal/textutil/text_test.go | 64 ++++++++ internal/textutil/units.go | 125 +++++++++++++++ 15 files changed, 947 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 DEVELOPMENT.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/brand/brand.go create mode 100644 internal/config/config.go create mode 100644 internal/textutil/text.go create mode 100644 internal/textutil/text_test.go create mode 100644 internal/textutil/units.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e3d9221 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: ci +on: + push: + pull_request: +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - 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: | + for t in linux/arm64 darwin/arm64 windows/amd64 freebsd/amd64; do + GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/mapsize + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aae86f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +*.msz +*.prof +*.test +/mapsize diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..3c07e78 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,279 @@ +# mapsize architecture + +This document describes how mapsize is put together and the invariants each +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. + +``` +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 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` (`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 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 +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 `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 +persistence trivial (see below). + +## Scanner worker architecture + +``` + +------------------ controller goroutine ------------------+ + | owns: Tree (write lock), pending FIFO of dir NodeIDs, | + | hard-link table, visited-dir table | + +----------------------------------------------------------+ + | jobs (bounded chan, cap = workers*2) ^ results (bounded chan) + v | + worker 1 .. worker N (fixed pool, N from --workers / mode) +``` + +* Workers do I/O only: open a directory, read entries in chunks of up to 1024, + `lstat` each entry (relative to the directory fd where the platform allows), + and send a `result` chunk back. Workers never touch the `Tree`. +* The controller is the only writer of the `Tree`. It applies result chunks, + creates child nodes, assigns IDs, propagates aggregate deltas, and decides + which subdirectories become new jobs. +* Worker count: `conservative` = 2, `balanced` = min(8, 2×CPU), + `aggressive` = min(64, 8×CPU), or an explicit number. More is not always + faster — on rotational media 2–4 is often best. + +## Bounded directory scheduling (no deadlock under backpressure) + +The classic deadlock is: workers block sending new work into a full queue +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 `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 + as 4-byte NodeIDs (paths are rebuilt at dispatch). Its size is bounded by the + number of directory nodes already in the inventory, so it adds O(1) bytes per + directory on top of memory we already spend. +* FIFO order means the scan is roughly breadth-first: every top-level directory + starts growing early, which is what makes the live treemap informative. + +Huge directories are streamed: a worker sends a result per 1024 entries, so a +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 (`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 (`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. + +## Concurrent aggregation strategy + +Because the controller is the single writer, aggregation needs no atomics. +For each result chunk it sums the chunk's file sizes, allocated sizes, counts +and per-category bytes **once**, then walks the ancestor chain adding that one +delta. Cost is O(depth) per 1024-entry chunk rather than O(depth) per file — +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 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 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. + +## Hard links and sizes + +* *Logical size* = `st_size`. *Allocated size* = `st_blocks × 512` on Unix. + On Windows allocated size is reported as unknown (equal to logical, flagged) + because getting it costs an extra handle-open per file. +* For regular files with link count > 1, the controller keys `(dev, inode)`. + The first path seen contributes to directory aggregates; later paths are + flagged `HardlinkDup` and contribute **zero** to aggregates (their own size + is still shown). Which path is "first" depends on scan order and is not + deterministic. This matches `du`'s default semantics. +* Windows does not expose link counts from directory enumeration, so hard + links are not deduplicated there. This is documented, not hidden. + +## Symlinks, mount points, exclusions + +* `--follow-symlinks=none` (default): symlinks are leaf nodes counted at their + own (tiny) size. `same-filesystem` / `all` follow directory symlinks; every + descended directory's `(dev, inode)` is recorded and a directory already + visited is never descended twice — loops terminate and nothing is counted + twice. +* A directory whose device differs from its parent is flagged `MountPoint`. + With `--one-file-system` it is not descended. On Linux, virtual filesystems + (proc, sysfs, cgroup, debugfs, …) are never descended — `/proc/kcore` would + otherwise dominate any scan of `/` — and are reported in scan info. +* `--exclude PATTERN`: patterns containing a path separator match full paths; + others match the entry name with glob semantics. Excluded entries are + counted and the UI shows that exclusions are active. + +## TUI event flow + +``` +scan controller ──(applies under Lock)──> Tree + │ atomic progress counters + ▼ +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 +``` + +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. + +`View()` is called by Bubble Tea after *every* `Update`. The model therefore +caches the rendered frame and only repaints when something visible changed. + +## 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 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. Selection never relies on colour alone: the selected +rectangle uses a double-line border, bold reversed title and a status-line +description. + +## Treemap algorithm + +`treemap.Squarify` implements Bruls, Huizing & van Wijk's squarified layout +on float rectangles: items sorted by size descending are added to the current +row while the worst aspect ratio improves; then the row is laid out along the +shorter side. Float rectangles are snapped to the cell grid by rounding the +*edges* (not the widths), so neighbouring rectangles share edges exactly — no +gaps, no overlaps. Layout is deterministic (stable sort by size, then ID). + +Nested levels are laid out recursively inside each block's interior (inside +its border) while the interior is large enough to be useful. + +## Level-of-detail model + +A 160×50 terminal has 8 000 cells; the tree may have millions of entries. +For each directory being laid out, children are reduced to the top K by size +using a bounded heap (O(n log K)). Children whose proportional area would be +smaller than a minimum useful block (a few cells), or beyond K, are merged +into one synthetic *group* block ("312 smaller items, 8.4 GiB"). Zooming into +a directory gives its children the whole screen, so they become individually +visible. Groups are selectable and inspectable. + +## Spatial navigation algorithm + +`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 + selected centre). +2. Each candidate is scored by the primary-axis gap (edge to edge) plus a + penalty for the orthogonal offset. Candidates overlapping the selected + rectangle's orthogonal span get the offset term set to zero and are + preferred over any non-overlapping candidate. +3. Among overlapping candidates, the one whose overlap is largest relative to + the source, then nearest to the source's centre line, wins. +4. Ties are broken by top-left position, then NodeID — fully deterministic. + +## Terminal resize architecture + +`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 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` (plus, for the synthetic LOD group, the group's parent +ID and a group marker). After any relayout the renderer looks the ID up in the +new rectangle list. If it is absent (filtered out, collapsed into a group, +zoom changed) selection falls back to: the group containing it, then its +nearest visible ancestor, then the largest block. + +## Modal architecture + +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 + +`internal/platform` exposes `ReadDir(path, func(chunk []Entry) error)` and +`Meta` structs. Implementations: + +* `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. + +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 + 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 + the raw bytes. +* Text is clipped by display width (grapheme clusters, East-Asian wide + characters) so labels never overflow their rectangle. +* Snapshot decoding validates magic, version, every length, every parent + reference (a parent must precede its child, so cycles and recursion are + impossible), caps decompressed size and node count, and verifies a SHA-256 + trailer. Aggregates are recomputed rather than trusted. +* 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..33d0433 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## Unreleased +- Bounded concurrent scanner with incremental aggregation, cancellation, + hard-link and symlink-loop handling, error accounting. +- Non-interactive reports: `--top`, `--largest-files`, `--json`, `--csv`, + `--duplicates`, `--no-ui`. +- Versioned, checksummed snapshot format; `--compare`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..d74a444 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,41 @@ +# Development + +Requires Go (see `go.mod`). No CGO, no external tools. + +```sh +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 +``` + +## Test data + +`treegen` creates synthetic trees: + +```sh +bin/treegen --out /tmp/t --directories 50000 --files 1000000 --max-depth 20 --sparse --unicode +bin/mapsize /tmp/t +``` + +## Profiling + +```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: + +* `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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac541c2 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +BIN := bin +PKG := ./... +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w + +.PHONY: build test race vet fmt fmt-check benchmark fuzz release clean treegen + +build: + go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/mapsize ./cmd/mapsize + go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/treegen ./cmd/treegen + +test: + go test $(PKG) + +race: + go test -race $(PKG) + +vet: + go vet $(PKG) + +fmt: + gofmt -w . + +fmt-check: + @test -z "$$(gofmt -l .)" || (gofmt -l . && echo 'run make fmt' && exit 1) + +benchmark: + go test -run=NONE -bench=. -benchmem $(PKG) + +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 + +release: + @for t in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do \ + os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \ + echo "$$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \ + -o $(BIN)/release/mapsize-$(VERSION)-$$os-$$arch$$ext ./cmd/mapsize || exit 1; \ + done + +clean: + rm -rf $(BIN) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0983a0b --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# mapsize + +An interactive, full-terminal disk usage analyser built around a live, +navigable treemap. Think WinDirStat + ncdu + WizTree, in one Go binary that +runs over SSH. + +> `mapsize` is a working name; branding lives in `internal/brand`. + +## Status + +Under active development — see [ROADMAP.md](ROADMAP.md) for what works today. + +## Install + +```sh +go install github.com/andydixon/mapsize/cmd/mapsize@latest +# or +make build # → bin/mapsize +``` + +## Usage + +```sh +mapsize . # scan and explore interactively +mapsize --read-only /mnt/evidence +mapsize /srv --save srv.msz # explore, and save a snapshot when done +mapsize srv.msz # reopen a snapshot (no rescan) +mapsize --compare old.msz new.msz + +# non-interactive +mapsize /srv --top 50 +mapsize /srv --largest-files 20 +mapsize /srv --json --depth 3 +mapsize /srv --csv > inventory.csv +mapsize /srv --no-ui --save snapshot.msz +mapsize /srv --duplicates --no-ui +``` + +### Sizes and units + +* By default sizes are **allocated** (disk usage, like `du`); `--apparent` + switches to logical size. Both are shown in the info modal. +* Units are **IEC** (KiB, MiB, GiB — powers of 1024) everywhere; `--si` + switches everything to SI (kB, MB, GB — powers of 1000). Never mixed. +* Hard links are counted once (first path seen); see ARCHITECTURE.md. + +### Filesystem boundaries and exclusions + +* Default: other filesystems *are* descended (like `du`), except Linux + virtual filesystems (proc, sysfs, cgroup, …) which are never scanned. + Mount points are marked. +* `--one-file-system` / `-x`: stay on the root's filesystem. +* `--follow-symlinks=none|same-filesystem|all` (default `none`). Loops are + detected and nothing is counted twice. +* `--exclude PATTERN` (repeatable): glob on entry name, or a path prefix if + the pattern contains a separator. Exclusions are always shown in the UI. + +### Configuration + +Optional JSON file at `$XDG_CONFIG_HOME/mapsize/config.json` +(macOS: `~/Library/Application Support/mapsize/config.json`, Windows: +`%AppData%\mapsize\config.json`): + +```json +{ + "workers": "balanced", + "theme": "default", + "units": "iec", + "excludes": ["/proc", "*.tmp"], + "category_colors": {"Video": "#e06c75"}, + "extension_colors": {"qcow2": "#c678dd"} +} +``` + +### Read-only mode + +`--read-only` disables every modifying action and shows `READ ONLY` in the +status bar. It does not make mapsize a forensic acquisition tool: scanning +uses ordinary filesystem APIs, and duplicate detection reads file content +(which may update access times). + +## Documentation + +* [ARCHITECTURE.md](ARCHITECTURE.md) — design and invariants +* [DEVELOPMENT.md](DEVELOPMENT.md) — building, testing, profiling +* [PERFORMANCE.md](PERFORMANCE.md) — measured results diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f07fe40 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,47 @@ +# Roadmap + +Status markers: `[x]` done, `[~]` partial, `[ ]` not started. + +## 0.1 — inventory engine +- [ ] Go project skeleton, Makefile, CI +- [ ] portable filesystem scanner, bounded worker pool, no-deadlock scheduler +- [ ] inventory node model, incremental aggregation +- [ ] cancellation, progress counters, error accounting +- [ ] CLI: `--top`, `--json`, `--csv`, `--no-ui` + +## 0.2 — TUI shell +- [ ] full-screen Bubble Tea v2 app, canvas renderer, frame cache +- [ ] responsive layout (large / medium / small / too-small) +- [ ] resize handling, status bar, progressive display, directory list + +## 0.3 — treemap +- [ ] squarified layout, edge-snapped cell rectangles +- [ ] renderer with borders, nested preview, adaptive labels +- [ ] level-of-detail grouping, category colouring + +## 0.4 — interaction +- [ ] spatial arrow navigation, stable NodeID selection +- [ ] info modal (scrollable, resize-aware), zoom, breadcrumbs, mouse + +## 0.5 — investigation +- [ ] search & filter language (AND/OR/NOT, parentheses, units, ages) +- [ ] extension statistics, largest files / dirs, error view + +## 0.6 — snapshots +- [ ] versioned binary snapshot format, save/load, comparison, growth treemap + +## 0.7 — duplicates +- [ ] staged duplicate detection (size → sampled hash → SHA-256), view + +## 0.8 — operations +- [ ] read-only mode, JSON/CSV export +- [ ] move-to-trash (freedesktop, macOS ~/.Trash, Windows recycle bin) + +## 0.9 — hardening (ongoing) +- [ ] profiling & benchmarks (see PERFORMANCE.md) +- [ ] memory reduction (name arena) if profiles justify it +- [ ] Windows allocated-size via GetCompressedFileSizeW (opt-in) +- [ ] configurable keybindings + +## 1.0 +- [ ] stable snapshot format v1 guarantee, packaged releases diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..196a898 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/andydixon/mapsize + +go 1.26.0 + +require ( + github.com/rivo/uniseg v0.4.7 + golang.org/x/sys v0.48.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6820e69 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/internal/brand/brand.go b/internal/brand/brand.go new file mode 100644 index 0000000..e668218 --- /dev/null +++ b/internal/brand/brand.go @@ -0,0 +1,9 @@ +// 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 = "0.9.0-dev" +) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b6bd6a6 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/textutil/text.go b/internal/textutil/text.go new file mode 100644 index 0000000..74e0486 --- /dev/null +++ b/internal/textutil/text.go @@ -0,0 +1,125 @@ +// 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/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 +} + +// Width returns the display width of s in terminal cells. +func Width(s string) int { return uniseg.StringWidth(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) + state := -1 + rest := s + for rest != "" && skip > 0 { + var cw int + _, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state) + skip -= cw + } + // A wide cluster may have overshot by one cell; pad to keep width exact. + return "…" + strings.Repeat(" ", -skip) + rest +} + +// clip returns the longest prefix of s that fits in w cells. +func clip(s string, w int) string { + state := -1 + used, end := 0, 0 + rest := s + for rest != "" { + var cluster string + var cw int + cluster, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state) + if used+cw > w { + break + } + used += cw + end += len(cluster) + } + return s[:end] +} + +// 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 +} diff --git a/internal/textutil/text_test.go b/internal/textutil/text_test.go new file mode 100644 index 0000000..e9e45c0 --- /dev/null +++ b/internal/textutil/text_test.go @@ -0,0 +1,64 @@ +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) + } +} diff --git a/internal/textutil/units.go b/internal/textutil/units.go new file mode 100644 index 0000000..0aeedb8 --- /dev/null +++ b/internal/textutil/units.go @@ -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) + "%" +}