Skip to content

feat(cleanup): every row on screen from the start, weight bars, and stale temporary files - #53

Merged
rducom merged 1 commit into
mainfrom
feat/cleanup-stable-list-weight-bars-temp-files
Oct 29, 2026
Merged

rducom merged 1 commit into
mainfrom
feat/cleanup-stable-list-weight-bars-temp-files

Conversation

@rducom

@rducom rducom commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Three changes to Low-Hanging Fruits: the list stops rearranging itself while it loads, each row shows its relative weight, and a new target covers stale temporary files.

Figures come from one developer Mac. They show scale; they are not general claims.

The list holds still

Before this change, rows arrived late and disappeared mid-load. Discovered targets (.NET, Editors, Browsers) only joined once the whole catalog was sized, and the go-build cache alone takes tens of seconds. Rows measuring 0 B were dropped as soon as they were sized. Reading the list while it settled was confusing.

  • Stand-ins. Each discovery search is now a CleanupDiscovery.Pass carrying placeholders. Every target a search can plausibly produce is on screen from the first frame, as a dimmed Searching row in its final section. The search swaps each placeholder in place as soon as it lands, without waiting for catalog sizing.

  • Nothing vanishes. A target that turns out empty keeps its place, reads Empty (a search that found nothing reads none found), and cannot be selected by hand or by Select all. On this machine the rows were identical, in the same order, at open and after 60 s (33 each).

  • Stand-ins only where plausible. A placeholder never goes away, so it is only put up where its target can exist:

    • editors and browsers when their folder exists;
    • project output when its toolchain has left a trace in the home (~/.nuget, ~/.cargo, ~/.npm…).

    If that guess is wrong, the row arrives late; it is never missing.

  • Sizing became a loop per load (sizeRemaining). Rows that land mid-loop join its queue, and walks still never overlap.

Weight bars

Each row has a bar sized relative to the heaviest row. Its heat runs green → amber → red, blending the stops of the existing capacity gauges (Theme.heatColor). Length and colour carry the same value, so colour is never the only signal. The tooltip and VoiceOver give the row's share of the total.

New target: stale temporary files

This covers the user's own leftovers in /private/tmp and $TMPDIR (TemporaryFiles.swift). A temporary folder is disposable by contract, so the only question is whether anything still uses an entry. An entry is offered only when all of these hold:

  • it belongs to the user;
  • it is a regular file or a directory. Sockets and FIFOs are live endpoints, and a directory containing one counts as in use;
  • for 3 days, no file in it has been written or changed and no folder in it has been created. The ctime counts, because unpacked archives keep their original mtimes (npm stamps 1985);
  • the walk could read all of it, and no filesystem is mounted inside;
  • its name is not runtime state (hidden entries, com.apple.*, .lock/.pid), and it is not empty.

Granularity: a warm directory is looked into, down to depth 3, so the dormant parts of a live tree are still found. The motivating case is claude-<uid>/<project>/<session>, kept warm by today's session while it holds weeks of abandoned ones. Loose files are only offered at the top of a temporary folder.

A finding from this machine. macOS 26 runs com.apple.tmp_cleaner every night at midnight. It deletes files in /tmp that have not been read, written or changed for more than 3 whole days. Deleting them moves their folder's mtime to midnight. A first version of this rule counted directory mtimes and therefore saw every thinned folder as busy: it offered 318 MiB out of 17.5 GiB. Folders now count by creation time only, the same reason the scanner leaves directory mtimes out of newestMTime. The figure became 6.29 GiB (379 items). A test pins this behaviour.

Safety:

  • A separate fence. Cleanable.Fence.temporary is checked by detect and clean; the home fence is not widened. A home-fenced target pointing into /tmp gets nothing, and a test covers that.
  • Roots from confstr, never from the environment. The per-user folder comes from confstr(_CS_DARWIN_USER_TEMP_DIR), not from $TMPDIR: a TMPDIR pointing at the home would turn Documents into a "temporary file". No root may be /, contain the home, or sit inside it. /private/var/tmp is left out because it survives reboots on purpose.
  • Re-check at clean time. Every entry is checked again just before deletion; the window may have stayed open for days. An entry that came back to life is left and counted as in use. The row is regenerable: false: badged not a cache, never selected by Select all, and named in the confirmation.

Performance: the walk uses getattrlistbulk with creation and change times. They are requested behind a new activityTimes flag, so the main scanner's buffer is unchanged. Below the decision depth, a subtree whose verdict is already "in use" is not read any further. Measured on this machine (release build):

Folder Content Walk
/tmp 17.5 GiB 0.24 s
$TMPDIR 262k directories 11.4 s

The main scanner takes 11.6 s on the same $TMPDIR: the cost is the filesystem's.

CleanupEngine.size(of:) now counts loose files, which only temporary targets carry.

Review focus

  • TemporaryFiles.swift: candidates, select and the WalkState pruning in claim(). This decides what gets deleted outside the home.
  • CleanupEngine.cleanTemporary: fence, ownership and the dormancy re-check before removeItem.
  • FSAttr.enumerateDirectory: CRTIME/CHGTIME packing order (CRTIME 0x200 before MODTIME 0x400, CHGTIME 0x800 after). Both are gated, so the default buffer is identical to before.
  • CleanupController.load / join / settle: stand-ins, in-place replacement, and .ready only once every search has landed and nothing is pending.

Testing

244 tests (+12, all passing on three consecutive full runs). Changes to existing tests:

  • New TemporaryFilesTests. Because a fixture's ctime cannot be backdated, dormancy is exercised by moving now forward. One test sleeps 2 s, so that removing a file and the file's own times fall in different seconds.
  • New controller test: stand-ins are visible and unselectable, the catalog is sized while a search is out, and stand-ins are replaced in place or marked none found.
  • New discovery test: stand-in ids match the rows each search produces.
  • Rewritten: emptyRowsAreDroppedOnLoadButKeptAfterCleaning became emptyRowsStayInPlaceButCannotBeSelected. reloadDuringCleanIsDeferredUntilBatchEnds now proves the reload ran through lastFreed instead of an emptied list.

The UI was checked on the running debug build: stand-ins at open, the same rows at open and once ready, bars, and the untruncated temp-files label. Nothing was cleaned during those checks.

Known gaps

  • Open question: /tmp vs $TMPDIR. For /tmp, this target mostly gets ahead of macOS's own nightly cleaner by about a day. Its lasting value is $TMPDIR, which that cleaner never touches. Restricting it to $TMPDIR is a one-line change if preferred.
  • explain (MCP) does not classify paths inside temporary folders yet. cleanup_targets lists the new row.
  • Clean still waits for every search. On a $TMPDIR holding 262k directories, that is the ~11 s walk.
  • While sizing runs, bars are relative to the heaviest row so far: the first row measured is red until a bigger one lands. The bars animate as they rescale.
  • Empty catalog rows are now always listed (12 of 33 on this machine). If that reads as clutter, they can be grouped once sizing ends, at the cost of one reorder.

…tale temporary files

Low-Hanging Fruits no longer rearranges itself while it loads. Each disk
search puts its targets up as "Searching" stand-ins in their final section
and swaps them in place as soon as it lands, instead of after the whole
catalog is sized. Targets that turn out empty stay where they are, marked
"Empty" or "none found", and cannot be selected.

Each row gets a weight bar relative to the heaviest row, coloured from green
to red with the capacity gauges' stops, with its share of the total on hover.

New target: the user's own leftovers in /private/tmp and $TMPDIR, offered
only when no file in them has been written or changed, and no folder in them
created, for 3 days, and when they hold no socket or FIFO. Folders count by
creation time only: macOS's nightly tmp_cleaner moves their mtime when it
deletes old files. The target has its own fence (Cleanable.Fence.temporary)
and re-checks every entry just before deleting it.
@rducom
rducom merged commit bcfeca9 into main Oct 29, 2026
6 checks passed
@rducom
rducom deleted the feat/cleanup-stable-list-weight-bars-temp-files branch October 29, 2026 23:42
@rducom rducom added the enhancement New feature or request label Oct 29, 2026
rducom added a commit to qjoly/SpaceMatters that referenced this pull request Oct 30, 2026
Brings in Evariops#53. The cleanup controller's rows gained a searching state and
empty rows that stay on screen without being selectable; the terminal's
cleanup screen mirrors both.
rducom added a commit that referenced this pull request Oct 30, 2026
)

`SpaceMatters --tui [path]` runs the app in a terminal. It covers:

- the home menu: disks, any path, Low-Hanging Fruits, Podman machines,
the container engine, `kubectl` contexts;
- the live outline of a scan, with files listed per folder;
- the queries behind the MCP tools: find, largest, cold;
- the Low-Hanging Fruits pass;
- deletion.

Same binary, no dependency: raw termios and VT sequences
([SPEC-15](docs/specs/SPEC-15-terminal-ui.md)).

## Shape

- **The window's controllers, not a reimplementation.** `AppTUIHost`
drives `ScanController` and `CleanupController`, so a guard added for
the window holds in the terminal too. Long operations run as one *job*
at a time, named in the header. The list keeps drawing underneath.
- **Testable screens.** The screens are pure values over `TUIHost`.
Frames, key handling and confirmations are tested as values against a
fake host.
- **Read-only tables.** Kubernetes and containers show as tables: PVCs
against live usage; images, volumes and containers by size.

## Deleting

`d` moves the selection to the Trash and takes `⏎`. `D` deletes it for
good and only takes `y`. Around them:

- **Where it applies.** Host scans only, never mid-scan, never the scan
root, never while a job runs. Each refusal is stated in the footer.
- **What the question shows.** The size, and the full path wrapped, not
cut.
- A folder the scanner never read says *size unknown*, never "0 B", and
`D` refuses it.
  - After a stopped scan, sizes read *at least*.
- **Who can answer it.** Only a key read after the question was drawn:
  - the rest of the read that opened it is dropped;
  - the input queue is flushed once the question is on screen;
  - while it is up, a read bringing more than one key is refused.

  Bracketed paste is on, and a paste only goes into text fields.
- **Journal.** Every `d`/`D` is written to `cleanup.jsonl`
(`terminal-trash` / `terminal-delete`); the cleanup pass journals
itself, as in the window.
- **Leaving a scan.** At the root, one "up" too many asks before
discarding the scan.

## The terminal

- **Restored on every exit.** Every fatal signal restores the terminal,
then the signal is re-raised with its default disposition. Ctrl-Z and
`fg` work. A resize repaints. Losing the input exits non-zero.
- **Untrusted names.** File names are sanitised and measured in cells.
Autowrap is off. `SPACEMATTERS_TUI_ASCII=1` handles terminals that draw
ambiguous-width characters wide.
- **No redraw timer when idle.** Once nothing moves, the loop waits for
a key.

This branch also merges `main`: the cleanup screen shows #53's
*searching* and *empty* rows, and lists each section once.

## Testing

- **Unit tests.** 306 in total, 62 of them in `TUITests`. SwiftLint
strict is clean.
- **The real binary on a pty.** Debug build, frames read back through a
VT model (`pyte`); 34 checks, all passing:
- **Pastes and bursts.** A bracketed `Do you have a minute?⏎` is
ignored. The same text sent unbracketed in one write opens the question
without answering it. `yy` is refused, and a single `y` answers.
- **Deletion.** `d`/`D` happen and are journalled; the Trash prompt
shows the full path.
- **A `chmod 000` folder.** `D` is refused, and `d` says *size unknown*.
  - **Files.** Listed on the first visit to a folder.
  - **The root.** Leaving it asks first.
  - **Idle cost.** 0 % of a core over 10 s.
- **Signals.** `kill -INT` restores the terminal (paste mode, wrap,
cursor, screen) and the process dies of SIGINT.
  - **Job control.** Ctrl-Z then `fg` under `zsh -i`.
  - **Arguments.** `--tui <file>` exits 1.
- **Cleanup screen.** Stand-ins read *searching*, empty rows read
*empty*.
- **Not covered.**
  - A visual pass in Terminal.app or iTerm2.
  - ssh.
- Whose Full Disk Access applies to a shell-launched process: SPEC-15 §5
marks it unverified, and the UI points at the terminal's grant.

## Known gaps (follow-ups)

- `e` opens the selection without confirmation; over ssh, `e`/`o`/`c`
act on the console session.
- Invisible characters (zero-width, bidi) in names are dropped, not
shown; two names differing only by one print alike.
- The Full Disk Access hint shows for any refused folder, POSIX `EACCES`
included.
- The header's "N unreadable" count does not drop after a successful
re-read; the folder's own flag does.
- SPEC-15 §6: multi-select, verdict marks, `--tui --vm/--k8s`, OSC 52.

---------

Co-authored-by: Raphaël DUCOM <raphael.ducom@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant