Repository navigation
feat(cleanup): every row on screen from the start, weight bars, and stale temporary files - #53
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Passcarrying placeholders. Every target a search can plausibly produce is on screen from the first frame, as a dimmedSearchingrow 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 readsnone 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:
~/.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/tmpand$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: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_cleanerevery night at midnight. It deletes files in/tmpthat 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 ofnewestMTime. The figure became 6.29 GiB (379 items). A test pins this behaviour.Safety:
Cleanable.Fence.temporaryis checked bydetectandclean; the home fence is not widened. A home-fenced target pointing into/tmpgets nothing, and a test covers that.confstr, never from the environment. The per-user folder comes fromconfstr(_CS_DARWIN_USER_TEMP_DIR), not from$TMPDIR: aTMPDIRpointing at the home would turn Documents into a "temporary file". No root may be/, contain the home, or sit inside it./private/var/tmpis left out because it survives reboots on purpose.regenerable: false: badgednot a cache, never selected by Select all, and named in the confirmation.Performance: the walk uses
getattrlistbulkwith creation and change times. They are requested behind a newactivityTimesflag, 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):/tmp$TMPDIRThe 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,selectand theWalkStatepruning inclaim(). This decides what gets deleted outside the home.CleanupEngine.cleanTemporary: fence, ownership and the dormancy re-check beforeremoveItem.FSAttr.enumerateDirectory:CRTIME/CHGTIMEpacking order (CRTIME0x200 beforeMODTIME0x400,CHGTIME0x800 after). Both are gated, so the default buffer is identical to before.CleanupController.load/join/settle: stand-ins, in-place replacement, and.readyonly once every search has landed and nothing is pending.Testing
244 tests (+12, all passing on three consecutive full runs). Changes to existing tests:
TemporaryFilesTests. Because a fixture's ctime cannot be backdated, dormancy is exercised by movingnowforward. One test sleeps 2 s, so that removing a file and the file's own times fall in different seconds.none found.emptyRowsAreDroppedOnLoadButKeptAfterCleaningbecameemptyRowsStayInPlaceButCannotBeSelected.reloadDuringCleanIsDeferredUntilBatchEndsnow proves the reload ran throughlastFreedinstead 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
/tmpvs$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$TMPDIRis a one-line change if preferred.explain(MCP) does not classify paths inside temporary folders yet.cleanup_targetslists the new row.$TMPDIRholding 262k directories, that is the ~11 s walk.